Integrations
Intendant integrates with the outside world in several directions: it consumes external MCP servers and external coding-agent CLIs as tools, routes audio through platform virtual-audio bridges, transcribes speech, records displays, and shells out to a set of system tools. This chapter covers each, plus the control socket and web gateway programmatic surfaces and the CI / setup tooling.
For the MCP server (exposing Intendant’s own control surface as MCP tools) see MCP Server. For the presence layer see Presence Layer.
MCP client
Intendant can connect to external Model Context Protocol servers and surface
their tools to the agent. Servers are declared as mcp_servers entries in
intendant.toml:
[[mcp_servers]]
name = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
[[mcp_servers]]
name = "github"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
[mcp_servers.env]
GITHUB_TOKEN = "ghp_..."
Each entry has name, command, optional args, and an optional env table
(see Configuration). At startup each server is
spawned as a child process, its tool list is fetched, and every tool is
registered to the agent under the name mcp__<server>_<tool> (e.g.
mcp__github_list_issues). When the agent calls one of these, the client routes
the call to the right server by parsing that prefix.
Every outbound mcp__* call passes the controller’s tool_call approval gate
before dispatch. The shipped tool_call = "ask" rule therefore prompts at
Medium autonomy; owners who deliberately trust their configured servers can
set it to auto.
Replies cross a separate context boundary. Intendant preserves the MCP
is_error status and wraps text (including transport error text) as explicitly
untrusted, quoted data before adding it to the model conversation. Control,
invisible-format, and bidirectional-override characters are made visible;
carriage returns are normalized; and the complete rendered result is capped at
64 KiB with an explicit truncation marker. Non-text and structured payloads are
not copied into this text bridge. These measures bound and label the input;
they do not make an external server’s claims trustworthy.
Trust model
This is a privileged integration with no sandboxing:
An
mcp_serversentry is spawned with your full privileges —Command::new(&config.command).args(&config.args)inmcp_client.rs. Intendant performs no checksum verification, no signature check, and no sandboxing of the server binary. Adding one is equivalent to adding a line to your~/.zshrcthat runs a binary.
The default is mcp_servers = [], and intendant.toml is git-ignored, so the
repository ships no MCP servers. Treat copying an intendant.toml between
machines like copying shell rc files: read it before you source it. The same
trust framing applies to the MCP server side — see
MCP Server.
External coding-agent CLIs
Instead of the native agent loop, Intendant can delegate coding work to an
external harness — Codex, Claude Code, Kimi Code, or Pi — selected
per-invocation with --agent <codex|claude-code|kimi|pi> or by default via
[agent] default_backend. Each backend’s binary path, model, sandbox/approval
policy, and tool restrictions are configured under [agent.codex],
[agent.claude_code], [agent.kimi], and [agent.pi] (full key reference in
Configuration).
Notable details:
- Codex runs against its app-server (
thread/start); thesandbox,approval_policy,reasoning_effort,web_search,network_access, andwritable_rootskeys map onto Codex’s own CLI flags. Approval prompts from Codex are surfaced through Intendant’s normal approval UI. - Claude Code runs its stream-json protocol over stdio. Intendant lifts
tool approvals and structured questions, preserves its native session id,
and surfaces async
Agent/Taskchildren as session relationships. - Kimi Code runs an isolated local
kimi serverand uses its authenticated REST/WebSocket surface. This retains native queued steering, undo, fork,:btw/swarm sub-agents, goals, live model/thinking/permission/plan/swarm changes, structured interactions, file upload, and reconnect snapshots that Kimi’s narrower ACP facade does not expose. - Pi runs upstream’s documented JSONL RPC mode with discovered extensions
disabled and one private fail-closed Intendant approval extension. It keeps
Pi’s native subscription/API provider support, sessions, images, steering,
interruption, compaction, fork, rename, and live model/thinking controls.
Pi has no built-in MCP; the supervised prompt truthfully directs it to the
scoped
$INTENDANT ctlbootstrap for platform capabilities.
This is a deep topic with its own chapter — see External Agent Orchestration.
Audio stack
Live voice and phone calls pipe audio through a virtual audio bridge so the
voice model’s I/O can be captured and injected independently of the host’s real
devices. The backend is chosen per platform (audio_routing.rs):
live audio / phone-call session
│
┌──────────────┬──────────────┬──────────────┬─────────────────┐
Unix + Vortex Linux fallback macOS fallback Windows fallback
│ │ │ │
Vortex HAL shm PulseAudio BlackHole ffmpeg DirectShow
rings null sinks + sox + ffplay/VB-CABLE
- Unix preferred: Vortex Audio. The Vortex HAL plugin shipped in
vendor/vortex-guest-tools/exposes a “Vortex Audio” device to apps. Intendant probes/vortex-audioand exchanges PCM through the plugin’s POSIX shared-memory rings directly — no daemon, no socket, no system audio reroute.scripts/setup-macos.shinstalls it on macOS (orscripts/update-vortex-pkg.shupdates the package); see Computer Use & Live Audio. - Linux: PulseAudio null sinks.
pactlcreates null sinks for the mic and speaker paths, and the session temporarily swaps the default source/sink, restoring them on teardown. (pulseaudio-utilsprovidespactl.) - macOS fallback: BlackHole. When Vortex is not present,
BlackHole 2ch/16chvirtual devices are driven withSwitchAudioSourceandsox. - Windows fallback: ffmpeg + VB-CABLE.
ffmpegcaptures the cable’sCABLE Outputrecording endpoint through DirectShow;ffplayplays model PCM through the system default output. The user must install VB-CABLE and route the default output toCABLE Input; there is no automatic per-app route or default-device save/restore.
The phone-call skill places outbound SIP calls via pjsua, with the live voice
model conducting the conversation and returning structured data. The live audio
model is untrusted: zero tools, zero file access, and its outputs are
schema-validated and quarantined.
Transcription (Whisper)
Server-side speech-to-text uses the OpenAI Whisper API (or a compatible
endpoint) when [transcription] is enabled or --transcription is passed. The
browser streams PCM16 audio; the gateway buffers ~3s chunks (buffer_secs,
RMS-filtered to drop silence), wraps each in WAV, and POSTs it to the endpoint.
Transcripts are broadcast as user_transcript events and logged. Configure via
[transcription] (provider, model, language, endpoint) — see
Configuration and
Web Dashboard. Point endpoint
at a self-hosted whisper.cpp server for a fully local pipeline.
Recording (ffmpeg)
Display recording is driven by ffmpeg and configured under [recording]
(enabled, framerate, segment_duration_secs, quality,
max_retention_hours). Recordings are segmented and served by the dashboard’s
/recordings/* and /api/session/{id}/recordings/* endpoints, with timeline
seeking and 1x/2x/4x playback in the Video tab. If [recording] enabled = true
but ffmpeg is not installed, recording is disabled with a logged warning rather
than failing the run. The --record-display <ID> flag records an existing X11
display (:ID) and is repeatable.
System tools
Intendant uses platform APIs and tools for capture, input injection, and search. Install them via the setup scripts (next section); the agent degrades gracefully with a clear error when one is missing.
| Purpose | macOS | Linux (X11) | Linux (Wayland) |
|---|---|---|---|
| Input injection | in-process CGEvents | x11rb/XTest | portal InputEvents |
| Display capture | ScreenCaptureKit | libxcb + libxcb-shm (XShm) | PipeWire (DMA-BUF) |
| H.264 encode | VideoToolbox | ffmpeg + x264 / VA-API | ffmpeg + x264 / VA-API |
| VP8 encode | libvpx | libvpx | libvpx |
| Image handling | ImageMagick | ImageMagick | ImageMagick |
| Code search (used by external agents) | ripgrep | ripgrep | ripgrep |
| Recording | ffmpeg | ffmpeg | ffmpeg |
X11 displays are auto-launched via Xvfb when the agent first needs one. See Display Pipeline for the capture/encode pipeline.
Setup and CI tooling
Setup scripts (scripts/)
| Script | Purpose |
|---|---|
setup-linux.sh | Install the Debian/Ubuntu APT_PACKAGES set + toolchain build deps, build, and provision a managed Chrome for Testing browser; --check to report only |
setup-macos.sh | Install macOS deps (ffmpeg, Vortex/BlackHole fallback deps, wasm-pack), build, and provision a managed Chrome for Testing browser; --check to report only |
setup-windows.ps1 | Windows toolchain + build for x86_64-pc-windows-msvc, plus managed Chrome for Testing provisioning (see Windows Support) |
bundle-macos.sh | Build and codesign the macOS .app (WKWebView wrapper over the intendant:// scheme) and install to /Applications |
setup-lan.sh, setup-lan-macos.sh, setup-lan-guest-macos.sh, setup-lan.bat | Wrappers/orchestrators around the native intendant access cert enrollment flow |
intendant setup browsers can also be run directly to install or repair the
managed browser cache used by CDP browser workspaces.
A legacy scripts/intendant-ctl.sh helper exists for the raw Unix control
socket. It selects the first /tmp/intendant-*.sock, so it is not
multi-instance safe and should be limited to one-instance protocol debugging.
The supported command-line client is the binary itself:
intendant ctl --help discovers the HTTP MCP control surface, and each command
group has focused help.
When you add a new
-syscrate dependency, update bothscripts/setup-linux.sh(APT_PACKAGES) andscripts/setup-macos.shin the same commit — otherwise fresh-machine setups break later with crypticpkg-configerrors.
CI (.github/workflows/)
main on github.com/intendant-dev/Intendant is ruleset-protected: every
landing is a pull request validated by GitHub’s merge queue, and the
checks marked required below gate it. The required-check workflows run
unfiltered on pull_request and merge_group on purpose: GitHub only
adds a PR to the queue once the PR’s own required checks pass, so a
paths:-skipped required check blocks queue entry (and a skipped group check
wedges the entry at “Expected”). Cheap integrity/docs workflows retain
paths-filtered push triggers; the heavy cross-platform workflow has no push
trigger because the merge group just validated the same tree.
Trusted refs (pushes, merge-queue refs, same-repo PRs) run on a
self-hosted fleet (dell-206 / intendant-linux, macbook-a /
intendant-macos, samsung-win / intendant-windows). Its incremental build
state lives in external, per-listener CARGO_TARGET_DIR caches keyed by the
Rust version; checkout cleanup wipes any in-workspace target/. Fork PRs
route to GitHub-hosted runners via a dynamic runs-on (the matrix os
key doubles as the hosted label): external code never executes on the
fleet, while its required checks still genuinely run — a skipped required
check would read as satisfied, so routing beats skipping. Fork-PR
workflows additionally require maintainer approval before running at all
(the Actions fork-approval policy covers all outside collaborators,
not just first-timers), and only maintainers can enqueue. The Dell and
Windows runners run as dedicated non-admin ci users (sudo-less on
Linux; a plain Users-group service account on Windows), runners are
registered per-repo, and the default GITHUB_TOKEN is read-only.
| Workflow | Trigger | What it does |
|---|---|---|
windows.yml | every PR + merge group; no push trigger | Required. Cross-platform test/check matrix for Windows (x86_64-pc-windows-msvc), macOS (aarch64-apple-darwin), and Linux (x86_64-unknown-linux-gnu). A merge group runs the unit suite plus the headless mock-provider e2e suite on all three platforms; a PR runs that full battery on Linux and cargo check on the other two. The Linux leg then reuses its checkout and build tree for the keyless session-vitals, native-goal, and peer-sessions smokes plus the headless dashboard-boot probe. All legs build with CARGO_PROFILE_DEV_DEBUG=0 |
app-html.yml | every PR + merge group; push to main (fragment/assembler paths) | Required. Reruns the assembler and fails when the committed static/app.html doesn’t match the fragments |
agents-md-sync.yml | every PR + merge group; push touching CLAUDE.md/AGENTS.md; manual dispatch | Required. Fails when tracked AGENTS.md differs byte-for-byte from CLAUDE.md |
wasm-drift.yml | every PR + merge group; a GitHub-hosted relevance job gates the Mac leg (irrelevant diffs skip it without occupying a fleet slot; in-job check kept as fail-open backstop) | Required. On the canonical macOS host, rebuilds both browser WASM crates with the pinned Rust/wasm-pack versions and byte-compares the committed artifacts |
audit.yml | push/PR (Cargo paths) + weekly cron (Mon 08:00 UTC) | Advisory: cargo audit against the RustSec advisory DB — new upstream advisories must not block unrelated landings |
codeql.yml | relevant pushes to main + weekly cron (Mon 09:00 UTC) | Advisory, GitHub-hosted build-free CodeQL analysis for Rust and Actions; never a merge-queue requirement |
docs.yml | docs changes on main | Build and deploy this mdBook to GitHub Pages |
release.yml | v* tags + manual dispatch | Build/sign/notarize and publish the macOS app, then submit release hashes to the transparency log. Manual runs without signing secrets produce an explicitly -unsigned-dev dry-run artifact; tag releases fail closed. Never a merge-queue requirement |
tests/e2e/main.rs contains hermetic #[tokio::test] cases. They
spawn the real binaries against the scripted mock provider, use the synthetic
1280×720 display rather than native capture, need no API key or network, and
run in CI on all three supported operating systems. The tests/skills/
scenarios that make real API calls or need a native display (the live
claude-code-e2e battery, browser/Station probes, voice/audio scenarios) stay
outside CI and run on operator hardware.
Run the full keyless battery from Getting Started
before committing: the three package binaries, workspace library gates,
scripted-provider E2E suite, and cargo clippy --workspace -- -D warnings.
The TLS stack is pure-Rust ring / rustls / rcgen (no OpenSSL), which is
why the Windows CI job installs NASM (for ring’s assembly) but no libssl.
CLI discovery descriptor
A gateway-serving daemon records at boot where its controller binary lives,
so unsupervised local agents (cron jobs, plain shells, agents outside
Intendant supervision) can resolve a CLI even when intendant is not on
PATH (app installs name the bundled controller intendant-bin and install
no PATH shim). Under the state root ($INTENDANT_HOME or ~/.intendant):
cli-path— one line, the absolute controller path; readable from a shell one-liner, no jq;cli-path.meta.json— debug context only (controller, port, pid, version, write time). Never secrets.
Written atomically on daemon boot only — ctl and transient invocations
never touch it; on multi-daemon homes the last-booted daemon wins, which is
fine because any controller binary can ctl any daemon at the standard
endpoints (the sidecar identifies the writer for debugging). Every repo
skill that shells to the CLI resolves it through the canonical preamble —
$INTENDANT → PATH → this descriptor → bare intendant — and a repo test
pins that preamble so future skills cannot regress.
Control socket
When --control-socket is enabled, a Unix domain socket is created at
/tmp/intendant-<pid>.sock for programmatic control of a running instance from
external scripts and tools. It is opt-in.
- Outbound: events are broadcast (newline-delimited JSON) to all connected clients.
- Inbound: newline-delimited JSON commands for status, approval/denial, human input, autonomy change, quit, controller-restart workflow, and (in MCP mode) controller-loop intervention.
Representative inbound commands (JSON-line)
{"action": "status"}
{"action": "approve", "id": 123}
{"action": "deny", "id": 123}
{"action": "input", "text": "answer to askHuman"}
{"action": "set_autonomy", "level": "high"}
{"action": "schedule_controller_restart", "controller_id":"codex", "north_star_goal":"audit and improve", "restart_after":"turn_end"}
{"action": "controller_turn_complete", "restart_id":"<id>", "turn_complete_token":"<token>", "status":"ok", "handoff_summary":"..."}
{"action": "get_restart_status"}
{"action": "cancel_controller_restart", "restart_id":"<id>"}
{"action": "request_controller_loop_halt", "persistent": true}
{"action": "clear_controller_loop_halt"}
{"action": "intervene_controller_loop", "mode":"stop"}
{"action": "get_controller_loop_status"}
{"action": "query_detail", "scope": "diff"}
{"action": "query_detail", "scope": "file", "target": "src/main.rs"}
{"action": "search_transcripts", "keywords": ["auth", "login"]}
{"action": "usage"}
{"action": "quit"}
Outbound events
{"event": "turn_started", "turn": 5, "budget_pct": 12.3}
{"event": "agent_output", "stdout": "...", "stderr": "..."}
{"event": "approval_required", "id": 123, "command": "rm -rf /tmp/test"}
{"event": "ask_human", "question": "Which database?"}
{"event": "task_complete", "reason": "done signal"}
{"event": "status", "turn": 3, "phase": "thinking", "autonomy": "medium", "session_id": "abc-123", "task": "fix tests"}
{"event": "usage", "main": {"provider": "openai", "model": "gpt-5.5", "tokens_used": 12000, "context_window": 128000, "usage_pct": 9.4}}
{"event": "usage_update", "main": {"provider": "openai", "model": "gpt-5.5", "tokens_used": 15000, "context_window": 128000, "usage_pct": 11.7}}
{"event": "display_ready", "display_id": 99, "width": 1024, "height": 768}
{"event": "command_result", "action": "get_restart_status", "ok": true, "message": "ok", "data": {}}
statusincludessession_idandtask. Theusageevent answers{"action":"usage"};usage_updateis broadcast automatically after each turn (with apresencefield when the presence layer is active).display_readyfires when a display becomes available for WebRTC streaming; itsdisplay_idis numeric. Request parameters that target a display use strings such asdisplay_99in their own fields.command_result.okisfalsewhen a control action fails (e.g.schedule_controller_restartwithrestart_after="now"and no executable restart action).
Examples
# Raw, opt-in Unix control-socket protocol:
echo '{"action":"status"}' | socat - UNIX:/tmp/intendant-$(pgrep intendant).sock
# Supported CLI (HTTP MCP, default port 8765; add --port PORT when needed):
intendant ctl status
intendant ctl approval pending
intendant ctl approval approve 123
intendant ctl input respond "answer to the pending question"
intendant ctl task start --task "new task description"
Web gateway
The default-on web gateway (--web, see Web Dashboard)
serves the SPA and bridges WebSocket connections to the EventBus. It is the same
control surface as the control socket, plus terminal I/O, presence, and WebRTC
signaling.
Browser ──WebSocket──> Intendant web gateway (default port 8765)
│ │
│ Tool requests │ Events (broadcast to all clients)
│ presence_connect/disconnect │ Tool responses (per-connection direct channel)
│ Voice logs/checkpoints │ State snapshot + log replay (on connect)
│ Audio for transcription │ Presence welcome (on voice connect)
│ WebRTC signaling │ WebRTC signaling (SDP, ICE)
v v
App dashboard (WASM) EventBus + AgentStateSnapshot
+ │
Optional: browser-side │ Dual outbound channels:
live model (Gemini/OpenAI) │ - broadcast::Receiver (events)
│ │ - mpsc::unbounded (direct responses)
│ (function calls → tool_request)
v
Intendant agent loop
The gateway has two layers:
- App dashboard — the SPA at
/, state managed bypresence-webWASM. Events are broadcast; late-connecting browsers get a full log replay. - Presence bridge (optional) — a browser-side live model’s tool calls
become
tool_requestWebSocket messages handled server-side, withtool_responsereturned on the per-connection direct channel.
WebSocket protocol
The tables below show the principal messages, not every dashboard-control, annotation, clip, and transport-maintenance variant.
Representative inbound (browser → server)
| Message | Description |
|---|---|
{"t":"presence_connect",...} | Presence session protocol — replaces server-side presence |
{"t":"presence_disconnect"} | Disconnect presence — resumes server-side presence |
{"t":"make_active"} | Request active voice ownership (handover) |
{"t":"voice_log","text":"...","seq":N} | Voice transcript from the browser presence model |
{"t":"presence_checkpoint","summary":"...","last_event_seq":N} | Context checkpoint |
{"t":"voice_diagnostic","kind":"...","detail":"..."} | Browser voice diagnostics |
{"t":"user_audio","data":"<base64>"} | PCM16 audio for server-side transcription |
{"t":"tool_request","id":"...","tool":"...","args":{}} | Presence tool call |
{"t":"async_query","id":"...","tool":"...","args":{}} | Async query (result returned as text) |
{"t":"display_offer","display_id":"...","sdp":"..."} | WebRTC SDP offer for display streaming |
{"t":"display_ice","display_id":"...","candidate":"..."} | WebRTC ICE candidate |
{"t":"video_frame","frame_id":"...","stream":"...","data":"<base64>"} | Display/camera frame for the frame registry |
{"action":"..."} | ControlMsg (same as the Unix control socket) |
Representative outbound (server → browser)
| Message | Description |
|---|---|
{"t":"state_snapshot","state":{},"connection_id":"...","config":{},"session_id":"..."} | Bootstrap on connect |
{"t":"log_replay","entries":[...]} | Historical session events for late joiners |
{"t":"presence_welcome","session_id":"...","state":{},"events":[...],"is_active":bool,"conversation_context":"..."} | Presence session welcome |
{"t":"active_granted","is_active":true,"handover_context":"...","conversation_context":"..."} | Active ownership granted |
{"t":"force_disconnect_voice","reason":"handover"} | Sent to the old active on handover |
{"t":"presence_checkpoint_ack","seq":N} | Checkpoint acknowledgement |
{"t":"tool_response","id":"...","result":"..."} | Response to a tool_request |
{"t":"async_query_result","id":"...","tool":"...","result":"..."} | Response to async_query |
{"t":"display_answer","display_id":"...","sdp":"..."} | WebRTC SDP answer |
{"t":"display_ice","display_id":"...","candidate":"..."} | WebRTC ICE candidate |
{"event":"..."} | OutboundEvent broadcast (status, agent_output, approval_required, display_ready, …) |
Tool request/response and bootstrap
A browser live model calls presence tools via tagged request/response:
// Browser:
{"t":"tool_request","id":"req-42","tool":"check_status","args":{}}
// Server (direct channel):
{"t":"tool_response","id":"req-42","result":"Phase: Running agent (turn 5). Budget: 23% used."}
- Action tools (
submit_task,approve_action,deny_action,skip_action,respond_to_question,set_autonomy,send_message) dispatch through the EventBus — the same path as control-socket commands. - Query tools (
check_status,query_detail,search_transcripts) are handled asynchronously server-side, reading the sharedAgentStateSnapshot, project-confined files, and the current session’s voice transcript/event-log history. - Video tools (
inspect_frame,inspect_frames) examine frames from the frame registry.
On connect the server queues a deterministic bootstrap before opening the live
broadcast lane: state_snapshot; cached usage/status and per-session state;
cached autonomy, backend, and user-display state; pending display requests; the
browser_workspace_snapshot; live display_ready frames; session/external-log
replay; and, last, the current per-display input-authority snapshots. Optional
cached entries are omitted when absent. This ordering recreates display slots
before replay and then lets the final authority snapshot win over historical
transitions.
HTTP endpoints
See Web Dashboard → HTTP endpoints for the endpoint table.
Requirements
- Microphone access requires a secure context —
localhost(e.g.ssh -L 8765:localhost:8765 host), default HTTPS/mTLS,--tlswith a trusted certificate, or the macOS app’sintendant://scheme. See Web Dashboard → Secure browser contexts and LAN access. - Voice credential (optional) — Gemini’s long-lived key stays in the
unlocked client vault or legacy per-origin
localStorageand goes directly from browser to Gemini. For OpenAI, the daemon holds or leases the long-lived key and mints the browser a short-lived Realtime client secret throughapi_voice_session/POST /session.
Supported tools (browser live model)
| Tool | Type | Description |
|---|---|---|
submit_task | Action | Submit a new task to the agent loop |
approve_action | Action | Approve a pending action |
deny_action | Action | Deny a pending action |
skip_action | Action | Skip a pending action |
respond_to_question | Action | Answer an askHuman question |
set_autonomy | Action | Change the autonomy level |
send_message | Action | Send a mid-task interjection to the agent |
check_status | Query | Current phase, turn, budget, available displays |
query_detail | Query | git diff, file contents, task results, or log details |
search_transcripts | Query | Search voice transcripts and session logs by keywords |
inspect_frame | Video | Examine a specific frame from the frame registry |
inspect_frames | Video | Examine multiple frames for visual context |