MCP agent sessions
botctl mcp exposes a small MCP-compatible JSON-RPC API for persistent agent TUI sessions backed by tmux. Claude, Codex, and Agy are supported through provider-specific spawn tools.
Start a transport
botctl mcp stdio
botctl mcp http --bind 127.0.0.1:8787
botctl mcp http --bind 0.0.0.0:8787 --allow-non-loopback
The HTTP transport is a stateless, Streamable-HTTP-compatible JSON request/response server (single endpoint POST /mcp):
POST /mcpreturnsapplication/json(one JSON-RPC response per request — no SSE). A request body containing only notifications/responses returns202 Acceptedwith an empty body.GET /mcpreturns405 Method Not AllowedwithAllow: POST, DELETE, OPTIONS: there is no server-initiated SSE stream (botctl emits no server notifications).DELETE /mcpreturns204 No Content— a no-op, because the server is stateless (noMcp-Session-Id; agent state lives in SQLite keyed by tool args).OPTIONS /mcpreturns204withAllow: POST, GET, DELETE, OPTIONS(no CORS/Access-Control-*headers — this is not a browser API).- Protocol version: HTTP advertises
2025-03-26. TheMCP-Protocol-Versionheader is optional, but if present on a post-initialize request it must equal2025-03-26or the request is rejected with400. (The stdio transport still advertises2024-11-05.) - Security: A missing
Originis allowed (native MCP clients omit it); if present,Originmust be loopback — a literalOrigin: null(sandboxed/opaque browser context) is rejected with403. The host check parses the authority strictly (only a loopback IP or exactlylocalhost), so loopback-prefixed names like127.0.0.1.evil.comand userinfo smuggling likehttp://127.0.0.1@evil.comare rejected. When bound to loopback, theHostheader must also be loopback (DNS-rebinding protection).Content-Type/Accepthandling is lenient. - Non-loopback bind: binding a non-loopback address (e.g.
0.0.0.0) is hard-rejected unless you pass--allow-non-loopback, because the MCP control plane has no authentication and can spawn/kill agents. With the flag, a prominent warning is printed at startup. - A top-level JSON-RPC batch (array) is rejected with a JSON-RPC
invalid_requesterror.
Explicitly out of scope: SSE response bodies, GET SSE streams, SSE resumability / Last-Event-ID, Mcp-Session-Id / stateful sessions, authentication/OAuth, and browser CORS.
Tools
Tool names are unprefixed; MCP clients typically expose them under the server name (e.g. mcp__botctl__spawn_claude). Provider-specific spawn tools are advertised only when their provider binary is available on PATH.
spawn_claude— Claude spawn tool. Required:cwd. Optional:model_preset(best/balanced/fast/cheap, defaults tobest), advanced rawmodel,effort(low/medium/high/xhigh/max),agent,permission_mode(acceptEdits/auto/bypassPermissions/default/dontAsk/plan),settings,timeout_ms,policy.spawn_codex— Codex spawn tool. Required:cwd. Optional:model_preset(best/balanced/fast/cheap, defaults tobest), advanced rawmodel,effort,timeout_ms,policy.spawn_agy— Agy/Antigravity spawn tool. Required:cwd. Optional:timeout_ms,policy.- Per-provider argument support:
- Claude:
model→--model,effort→--effort,agent→--agent,permission_mode→--permission-mode,settings→--settings. - Codex:
model→-m,effort→-c model_reasoning_effort=<v>.agent,permission_mode, andsettingsare rejected. - Agy:
model/effort/agent/permission_mode/settingsare all rejected (no matching CLI flags).
- Claude:
prompt— submit one prompt to that managed ID, wait for completion, and keep the tmux window alive. If the registry row iskilled/deador its recorded pane is missing,promptcan resurrect the same ID by starting a replacement pane from the persisted launch configuration before submitting the prompt. If the recorded pane ID exists but belongs to a different tmux window, the call blocks asambiguous_targetinstead of overwriting identity.wait— wait for an existing managed session to reach a terminal outcome. It does not resurrect missing panes.kill— kill only the verified managed tmux window; idempotent when already gone.snapshot— capture raw pane text and classifier state for diagnostics.pane_textis the raw tmux capture, whilerecent_linesand structuredoutcome.snapshotdrop only trailing blank terminal padding and then return the last useful lines from that same capture. Callingsnapshotalso refreshes the persistedagent.statefrom the live classified state. It does not resurrect missing panes.send_keys— unsafe operator escape hatch scoped to the managed ID. It does not resurrect missing panes.one_shot— create a temporary managed session, run exactly one prompt to a terminal outcome, then always attempt to kill the window (best-effort cleanup). Preferred arg:prompt(non-empty); aliasestext,message,input, andinitial_promptare accepted. Optional:cwd(defaults to the MCP server current directory),provider(defaults to the first available provider binary inclaude,codex,agyorder),model_preset(bestby default for Claude/Codex), advanced rawmodel,effort/agent/permission_mode/settings/timeout_ms/policy(same provider validation as persistent spawns;permission_mode/settingsare claude-only). It is implemented by composing session creation + prompt +kill, so the prompt is submitted exactly once.- Timeout (per-phase):
timeout_msapplies independently to the spawn-ready wait and the prompt turn;killuses its own default. Worst case ≈2×timeout_ms+ kill. - Auto-approval policy: uses managed auto-approval (
no_yolo=false) — only folder-trust and gated agy command-permission prompts auto-advance; all other approvals block. A caller MAY setpolicy.no_yolo:trueto be more conservative; it can never broaden beyond the existing gate. - Error vs result: argument-validation failures (missing/blank
prompt, badcwd, invalid optional args) surface as JSON-RPC errors. Spawn, turn, and kill failures are NOT errors — they are encoded in the result fields below and the call returns a normal result (isError:false). A failure after the window is created still always attempts the best-effort kill (no leaked tmux window). - Connection slot (operator note):
one_shotholds its HTTP connection slot for the full duration of the call (spawn-ready wait + prompt turn + kill, worst case ≈2×timeout_ms+ kill), unlike the splitspawn/prompt/waitflow. Effectiveone_shotconcurrency is therefore bounded byMAX_ACTIVE_CONNECTIONS(64) — long-running one-shots can saturate the slot pool, so sizetimeout_msand client concurrency accordingly. - Result shape: the call returns a normal result whose fields encode the outcome. Fields:
agent(ornullon spawn failure),spawn_outcome,outcome(ready/needs_user_input/provider_error/blocked/timeout/dead/busy/unknown/spawn_failed),message(set onready/needs_user_input),fresh_message,killed,kill({status: ok|error|busy|skipped, …}), anderror(present on spawn failure, a post-creation failure, or kill-error detail).provider_errorincludeserror_excerptwhen a visible Codex provider/API error is detected in the pane.
- Timeout (per-phase):
Sessions are addressed by generated public IDs, not tmux names. The registry stores exact tmux session/window/pane IDs (and the chosen provider) under the botctl state directory and uses SQLite lock rows so concurrent prompt/wait/kill/snapshot/send operations on the same ID return busy instead of racing.
Lifecycle resilience
Blocked outcomes use stable lower-snake-case reasons in outcome.warnings, outcome.blocked_reason, and the registry's current blocked fields. Current reasons are startup_choice_prompt, agy_folder_trust_prompt, agy_settings_persist_prompt, agy_command_permission_prompt, folder_trust_prompt, permission_dialog, survey_prompt, plan_approval_prompt, diff_dialog, provider_error, ambiguous_target, and unknown_state.
For blocked/provider-error/unknown outcomes, the registry persists only current evidence: blocked_reason, blocked_at_ms, and a bounded blocked_snapshot excerpt equivalent to the last 20 useful lines. It does not store full pane captures in blocked fields. Blocked fields are cleared when the session transitions back to ready/running/dead/killed, except cleanup-killed rows preserve the evidence that justified the automatic kill.
Normal lifecycle calls run conservative best-effort cleanup for stale managed rows. Cleanup may kill only aged, unlocked panes whose live recapture still shows a known blocker/provider error. It does not kill persistent ready sessions, running sessions, active response/editing states, unknown states, ambiguous targets, or newly-created rows.
Managed Codex responses include agent.command_health where botctl can verify registry identity. ok_codex means the verified pane command is codex; ok_node_codex_managed means the verified registry-backed Codex pane is currently running node; unexpected_command means the verified pane has another command; dead and ambiguous_target report missing or mismatched identity; not_applicable is used for non-Codex providers or when no live lookup was performed.
Safety notes
promptCLI behavior is unchanged: one-shotbotctl promptstill cleans up its managed window after success.- MCP sessions are persistent:
promptdoes not auto-kill after a reply. - Only
promptresurrects killed/dead/missing managed IDs;wait,snapshot, andsend_keysreport current state instead. - Automation resolves the managed ID back to one exact tmux pane before acting.
Unknownand unsupported blocker states return structured outcomes instead of guessing.- Visible Codex provider/API errors return structured
provider_erroroutcomes witherror_excerpt; this is a tool result, not a JSON-RPC transport error. FolderTrustPromptmay be advanced with rawEnter; ordinary permission dialogs require supported policy and otherwise block.- Agy command-permission prompts are auto-approved with raw
Enteronly when the pane process isagyand the default cursor is still on1. Yes(skipped whenpolicy.no_yolois set); Agy folder-trust and settings-persist prompts always block for manual review because the latter can mutatesettings.json. - For non-Claude providers, prompt submission pastes the text and presses
Enter; Claude-only keybindings (ClearInput,ExternalEditor) are not consulted. - For Agy, fresh-message extraction is pane-scrape based:
promptreturns the latest assistant text asfresh_message: truewhen a new message is detected, otherwise it keeps polling until the timeout and reportsstale_transcript. Usesnapshotto inspect the pane directly. send_keysonly reports that keys/text were sent. It does not imply the agent made progress.
Smoke and torture testing
Manual smoke flow with real tmux and the chosen agent CLI on PATH:
- Start
botctl mcp stdioorbotctl mcp http --bind 127.0.0.1:8787. - Call the provider-specific spawn tool, such as
spawn_claude, with a temp repocwd. - Call
prompttwice for the same ID and verify the second response uses the same persistent window. - Call
snapshotand inspect the exact tmux IDs. - Call
killand verify the managed window is gone.
Torture scenarios to run manually before widening client use: concurrent prompts for one ID should yield one active operation and busy for the rest; concurrent prompts across different IDs should progress independently; killing the tmux window externally should make prompt/wait return dead; killing while a wait holds the lock should return busy.