Caret as an MCP server (orchestrator)
Caret can expose itself as a Model Context Protocol server. Any MCP client on the same machine (Claude Code in a terminal, Codex, Cursor, another Caret window, your own script) connects to a loopback endpoint with a bearer token and gets a small set of tools to spawn, steer, read and approve Caret threads. Each thread is an ordinary Caret chat session: it runs on whichever provider and model you configured (Anthropic, OpenAI, Azure, Ollama, OpenRouter, Auto routing, ...) or is bound to an external ACP agent (Claude Code, Codex, Gemini CLI, ...). The human sees every orchestrated thread live in the Caret window, with the same tool cards, approvals, review bar and checkpoints as a thread started by hand.
This is the "orchestrator" capability of T3 Code: one agent driving many others through the editor, without the editor dictating which model or vendor does the work.
Enabling it
- Settings > MCP > Caret as an MCP server (orchestrator): switch Enabled on. Caret binds
127.0.0.1:17431(or the next free port up to +10 when it is busy; change the preferred port withorchestrator.port). The status line shows the exact URL. - Copy the token (eye icon reveals it) or one of the ready-to-paste snippets below the status line. Rotate issues a new token and restarts the endpoint; every connected client must then be reconfigured.
- The endpoint follows the setting: disabling it stops the server, enabling starts it, and it is shut down with the app. The endpoint is loopback only; nothing is reachable from other machines.
Files (all under ~/.caret/orchestrator/, honouring CARET_HOME):
| File | Purpose |
|---|---|
token |
The bearer token, created on first start, mode 0600. |
endpoint.json |
{ url, port, pid, startedAt } while the server runs, for discovery by local tooling. Removed on shutdown. |
Settings:
{ "orchestrator": { "enabled": false, "port": 17431 } }Connecting a client
The snippets are generated by Caret with the live URL and token (Settings > MCP, Copy buttons). Shapes:
Claude Code (terminal):
claude mcp add --transport http caret http://127.0.0.1:17431/mcp --header "Authorization: Bearer <token>"Codex (~/.codex/config.toml, token through an environment variable):
[mcp_servers.caret]
url = "http://127.0.0.1:17431/mcp"
bearer_token_env_var = "CARET_MCP_TOKEN"export CARET_MCP_TOKEN=<token>Cursor / another Caret (.cursor/mcp.json or .caret/mcp.json; Caret's own MCP client reads both):
{ "mcpServers": { "caret": { "url": "http://127.0.0.1:17431/mcp", "headers": { "Authorization": "Bearer <token>" } } } }Any client that speaks Streamable HTTP works. x-api-key: <token> is accepted as an alternative to the
Authorization header. GET /health answers { "ok": true, "name": "caret-orchestrator", "version": "..." }
without a token so scripts can probe for a running instance.
Protocol
Streamable HTTP transport, spec revision 2025-06-18, stateless:
POST /mcpwith a JSON-RPC 2.0 body (one message or a batch). Responses are plainapplication/json; a body that contains only notifications gets202 Acceptedwith no content. No SSE stream is ever opened and noMcp-Session-Idis issued, so every request is self-contained and clients may reconnect at will.GET /mcpreturns405(no server-initiated stream),DELETE /mcpreturns200.initializeechoes the client'sprotocolVersionwhen it is one of2025-06-18,2025-03-26,2024-11-05and answers2025-06-18otherwise; capabilities are{ "tools": {} };serverInfois{ "name": "caret", "version": "<Caret version>" };instructionssummarise the tools.ping,tools/list,tools/call, andnotifications/*(accepted silently). Anything else is-32601 Method not found. Malformed JSON is400with-32700.- Tool failures (unknown thread, missing argument, disabled agent, ...) are returned as results with
isError: trueand a readable message, never as transport errors.
Every tools/call result carries the JSON payload twice: pretty-printed in content[0].text for clients that only
read text, and as structuredContent for clients that understand it.
Tools
Names are short and snake_case; thread_id is the Caret session id. Status strings are Caret's session statuses:
idle, running, awaiting-approval, awaiting-input, error, summarizing.
| Tool | Input | Output |
|---|---|---|
list_agents |
none | { agents: [{ id, name, kind: "builtin" | "acp", installed, state, version?, auth_required?, error?, models? }], default_agent_id, workspace }. caret is always first and lists the enabled model ids. |
list_threads |
{ include_archived?: bool, limit?: int (default 50) } |
{ threads: [{ id, title, status, agent, model, mode, created_at, updated_at, workspace, message_count, pending_change_count, worktree_path, archived, last_message_preview }], total } |
create_thread |
{ title?, agent_id?, model_id?, mode?, use_worktree?: bool } |
{ thread_id, title, agent, model, mode, workspace, worktree_path, branch }. agent_id defaults to acp.defaultAgentId (caret unless you changed it); an ACP id binds the thread exactly like the composer's agent picker. Unknown models or agents fail before anything is created. |
send |
{ thread_id, text, wait?: bool = true, timeout_s?: int = 600, model_id?, mode? } |
With wait: the turn result below. Without: { thread_id, status: "running", queued, messages_added }. Routing is identical to the chat UI: ACP-bound threads go through the ACP bridge, everything else through the built-in agent. A message sent to a running thread is queued. |
wait |
{ thread_id, timeout_s? } |
The turn result; returns immediately when the thread is not running. |
read_thread |
{ thread_id, offset?: int = 0, limit?: int = 50 (max 500) } |
{ thread_id, title, status, agent, model, mode, total, offset, messages: [{ id, role, text, tool_calls: [{ id, name, status, summary, error, files }], errors, created_at, status, model_id }], pending_changes, todos, pending_approval, pending_question, worktree_path, branch } |
stop |
{ thread_id } |
{ ok, thread_id, was_running, status }. Cancels the running turn (built-in or ACP) and drops queued messages with it. |
pending |
{ thread_id? } |
{ approvals: [...], questions: [...] } for one thread or all threads. |
approve |
{ approval_id, option: "allow" | "allow-session" | "deny" } |
{ ok, approval_id, option }. allow-session also skips further prompts for the same command prefix in that thread. |
answer |
{ question_id, answer } |
{ ok, question_id } |
delete_thread |
{ thread_id } |
{ ok, thread_id }. Deletes the session, its checkpoints and its worktree if it had one. |
Turn result (send with wait, and wait):
{
"thread_id": "...",
"status": "idle | error | awaiting-approval | awaiting-input | running",
"timed_out": false,
"assistant_text": "last assistant message, text parts joined",
"assistant_message_id": "...",
"messages_added": 2,
"pending_approval": { "approval_id": "...", "thread_id": "...", "kind": "command", "title": "...", "detail": "...", "command": "make build", "cwd": "...", "options": ["allow", "allow-session", "allow-always", "deny", "deny-always"], "risk_tier": 2 },
"pending_question": { "question_id": "...", "thread_id": "...", "text": "...", "options": ["a", "b"], "allow_free_text": true },
"files_changed": [{ "change_id": "...", "path": "src/x.ts", "kind": "modify", "status": "pending", "additions": 3, "deletions": 1 }],
"pending_changes": 1,
"usage": { "turn": { "inputTokens": 5, "outputTokens": 3, "...": "..." }, "thread": { "...": "..." }, "context_tokens": 1234 },
"error": null,
"queued": 0,
"worktree_path": null
}pending_approval / pending_question are null unless the thread is waiting for one. On timeout the result has
"status": "running", "timed_out": true and whatever the thread produced so far; call wait again to continue.
messages_added counts messages appended since the send (user + assistant + system), or since the start of the
current turn for wait; files_changed lists the changes recorded in the same span (for wait, the changes of the
current turn).
How approvals surface
Approvals are not special-cased for the orchestrator: the built-in agent's policy (allow/deny lists, file-delete and
dotfile protection, MCP tool pins, sandbox classification) and ACP agents' session/request_permission both flow
through the manager's single approval queue. When a thread needs one:
send/waitreturnstatus: "awaiting-approval"withpending_approvalfilled in (orawaiting-inputwithpending_question).- The same prompt is shown in the Caret window. Whoever answers first wins: the orchestrator through
approve/answer, or the human through the UI. The other side simply sees the thread continue. - After answering, call
waitto get the rest of the turn.
The orchestrator keeps a small tracker of pending items fed by the manager's event stream (and also scans the session itself, so items raised before the server started are visible). Entries disappear when the thread resumes, errors, is stopped or deleted.
Security model
- Loopback only. The listener binds
127.0.0.1; it never binds other interfaces. - Bearer token on every request except
GET /health. The token is a 64-character random string stored with mode0600, shown in Settings and rotatable there (orchestrator_rotate_token). Rotating restarts the endpoint. - Same guard rails as the UI. A connected agent can only do what a user typing in the composer could: it cannot
bypass approvals, denylists, the sandbox or file protections; it can answer approvals, which is exactly the power
you grant by connecting it. Keep that in mind when wiring an autonomous agent to
approve. - No secrets leave Caret. Provider keys, ACP logins and MCP server credentials stay inside the app; the orchestrator exposes thread content only.
- Disabled by default.
Example: Claude Code asks Codex in Caret to implement X and reads the result
- In Caret: Settings > Agents, install Codex and sign in; Settings > MCP, enable the orchestrator, copy the Claude Code snippet and run it in a terminal.
- In that terminal, ask Claude Code: "Use the caret tools: create a thread driven by Codex, have it add input
validation to
parseConfiginsrc/config.tswith tests, and report back what changed." - Claude Code calls, in order:
list_agentsand finds{ "id": "codex", "installed": true, "state": "ready" }.create_thread { "title": "Validate parseConfig", "agent_id": "codex", "use_worktree": true }and gets athread_id(the thread appears in Caret's sidebar, on branchcaret/validate-parseconfig-xxxx).send { "thread_id", "text": "Add input validation to parseConfig in src/config.ts with unit tests. Run the tests." }. The call blocks while Codex works; if Codex wants to runnpm test, the call returnsstatus: "awaiting-approval"with the command, and Claude Code replies withapprove { "approval_id", "option": "allow-session" }followed bywait.- When
statusisidle, it readsassistant_text(Codex's summary) andfiles_changed(src/config.ts,src/config.test.ts), optionallyread_threadfor the full transcript.
- The human reviews the diff in Caret's review bar (accept/reject per file, checkpoints) or merges the worktree branch; Claude Code can meanwhile start the next thread on a different model.
The same flow works with agent_id omitted (Caret's built-in agent on the model you chose with model_id, or Auto
routing) and with any other MCP client in Claude Code's place.
Implementation notes
src-tauri/src/orchestrator/server.rs: hyper 1 server, one task per connection, cancellation on shutdown; the JSON-RPC layer is a pure function (handle_jsonrpc) over the tool context so it is testable without sockets.src-tauri/src/orchestrator/tools.rs: the tool catalogue (specs()), dispatcher (call), the pending tracker and the wait loop.send/waitsubscribe toAgentManager::subscribe_events()before submitting and also poll the session every 2 s as a fallback.src-tauri/src/orchestrator/mod.rs: lifecycle (init,shutdown, settings-drivenreconciledebounced oncaret://settings/changed), token and endpoint files, theorchestrator_status,orchestrator_rotate_tokenandorchestrator_snippetscommands, and the snippet formatter.AgentManager::emitnow also publishes everyAgentEventon atokio::sync::broadcastchannel (subscribe_events()), which is what the tracker and the wait loop consume.- Tests (
src-tauri/src/orchestrator/e2e.rs) run the whole stack over real HTTP with a headless manager; the built-in agent talks to a scripted fake LLM through the Auto-routing bridge, sosend, approvals and timeouts are exercised end to end.