Caret

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

  1. 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 with orchestrator.port). The status line shows the exact URL.
  2. 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.
  3. 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 /mcp with a JSON-RPC 2.0 body (one message or a batch). Responses are plain application/json; a body that contains only notifications gets 202 Accepted with no content. No SSE stream is ever opened and no Mcp-Session-Id is issued, so every request is self-contained and clients may reconnect at will.
  • GET /mcp returns 405 (no server-initiated stream), DELETE /mcp returns 200.
  • initialize echoes the client's protocolVersion when it is one of 2025-06-18, 2025-03-26, 2024-11-05 and answers 2025-06-18 otherwise; capabilities are { "tools": {} }; serverInfo is { "name": "caret", "version": "<Caret version>" }; instructions summarise the tools.
  • ping, tools/list, tools/call, and notifications/* (accepted silently). Anything else is -32601 Method not found. Malformed JSON is 400 with -32700.
  • Tool failures (unknown thread, missing argument, disabled agent, ...) are returned as results with isError: true and 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/wait return status: "awaiting-approval" with pending_approval filled in (or awaiting-input with pending_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 wait to 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 mode 0600, 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

  1. 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.
  2. In that terminal, ask Claude Code: "Use the caret tools: create a thread driven by Codex, have it add input validation to parseConfig in src/config.ts with tests, and report back what changed."
  3. Claude Code calls, in order:
    • list_agents and finds { "id": "codex", "installed": true, "state": "ready" }.
    • create_thread { "title": "Validate parseConfig", "agent_id": "codex", "use_worktree": true } and gets a thread_id (the thread appears in Caret's sidebar, on branch caret/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 run npm test, the call returns status: "awaiting-approval" with the command, and Claude Code replies with approve { "approval_id", "option": "allow-session" } followed by wait.
    • When status is idle, it reads assistant_text (Codex's summary) and files_changed (src/config.ts, src/config.test.ts), optionally read_thread for the full transcript.
  4. 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/wait subscribe to AgentManager::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-driven reconcile debounced on caret://settings/changed), token and endpoint files, the orchestrator_status, orchestrator_rotate_token and orchestrator_snippets commands, and the snippet formatter.
  • AgentManager::emit now also publishes every AgentEvent on a tokio::sync::broadcast channel (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, so send, approvals and timeouts are exercised end to end.

Edit this page on GitHub