The client API is served on BAE_ADDR (default 0.0.0.0:8080). This is the
only port SDKs and agents communicate with; admin operations use the separate
admin port (see 02-admin-api.md).
The client port is a hybrid:
- REST/HTTP for management — session open/close, metadata, event replay.
- JSON-RPC 2.0 over NDJSON for the live session loop — one endpoint,
POST /api/v1/sessions/{id}/rpc.
All REST endpoints use Content-Type: application/json with snake_case field
names. The /rpc endpoint uses Content-Type: application/x-ndjson. See
Wire Protocol for transport details.
Every /api/v1/sessions* endpoint requires an Authorization header:
Authorization: Bearer <key>
Both Bearer (capital B) and bearer (lowercase) are accepted. An
absent or empty header returns 401.
| Endpoint | Required key type |
|---|---|
POST /api/v1/sessions |
Client key (bae_…) |
POST /api/v1/sessions/{id}/join |
Client key (bae_…) — may be a different client key than the one that created the session, as long as it shares the session's profile |
All other /api/v1/sessions/{id}/* |
Session key (bae_ses_…) for that session |
A valid session key presented for a different session id returns 401 (the
session key is bound to its session at creation). A session can have
multiple valid session keys at once — one per client key that created or
joined it (see join
below).
Returns 200 OK with an empty body. No authentication required. Use this for
liveness probes. Plain HTTP — no JSON-RPC envelope.
Returns server version information. No authentication required.
Response 200 OK:
{"version": "0.1.0", "api_versions": ["v1"]}Every non-2xx response from REST endpoints follows RFC 7807:
{
"type": "unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "invalid or revoked client key"
}type |
HTTP status | When |
|---|---|---|
unauthorized |
401 | Missing, invalid, or revoked key. |
not_found |
404 | Session does not exist. |
tool_not_allowed |
403 | A declared tool is not in the profile's allowed_tools. |
profile_mismatch |
403 | POST /join only — the joining client key's profile differs from the session's profile. |
session_closed |
409 | Session is not open (already closed or errored) — REST endpoints only. |
profile_unavailable |
422 | The profile was deleted after the key was created. |
primary_provider_unavailable |
422 | The profile's primary_provider name is not in the server's [providers] registry. Logged on every attempt, never deduplicated. See Profiles. |
internal |
500 | Unexpected server error. |
POST /api/v1/sessions/{id}/rpcchecks auth before opening the stream; a bad key returns401(RFC 7807). Once the stream is open, session-state errors (session not open, profile deleted mid-session) are delivered as JSON-RPC error objects inside the NDJSON stream — not as HTTP error codes. See Wire Protocol — Error codes.
GET /api/v1/sessions/{id}/events accepts ?cursor=<opaque>&limit=<n>:
{
"items": [ … ],
"next_cursor": "42"
}next_cursorisnullon the last page.- Default limit: 50. Maximum: 200.
- Cursor is opaque — never parse it.
Auth: client key.
Request body:
{
"client_version": "1.0.0",
"tools": [
{
"name": "get_current_time",
"description": "Return the current UTC time as a string",
"input_schema": {
"type": "object",
"properties": {}
}
}
],
"sandbox_tools": [
{
"name": "run_shell_command",
"description": "Run an arbitrary shell command inside the configured sandbox.",
"input_schema": { "type": "object", "properties": { "command": { "type": "string" } }, "required": ["command"] }
}
],
"subagent_tools": [
{
"name": "launch_subagent",
"description": "Launch a CLI subagent (claude, codex) to work on a task in the background.",
"input_schema": { "type": "object", "properties": { "harness": { "type": "string" }, "model": { "type": "string" }, "prompt": { "type": "string" } }, "required": ["harness", "model", "prompt"] },
"image": "bae-subagents:latest",
"subagents": [
{ "harness": "claude", "command_template": "claude --model {model} --print", "prompt_via": "stdin", "timeout_secs": 600 },
{ "harness": "codex", "command_template": "codex exec --model {model}", "prompt_via": "stdin", "timeout_secs": 600 }
]
}
],
"compaction": { "mode": "auto", "size": 128000 }
}client_version— optional.tools— optional, default[]. These are the client-side tools the harness can execute. Every declared tool name must be in the profile'sallowed_tools; an emptyallowed_toolson the profile means no tools are allowed.descriptionandinput_schemaare optional per tool.sandbox_tools— optional, default[]. Auto-mode sandbox tool declarations (see Sandboxes guide — Auto vs. manual remote dispatch): the server dispatches these directly against the session's remote sandbox insiderun_turn, without ever pausing the loop or involving the client. Stored per-client, sibling to (never merged with)tools. Not validated against the profile'sallowed_tools— that check governs client-dispatched tools only; the sandbox trust boundary isavailable_sandboxes, enforced atsession.startRemoteSandboxtime. Each entry'sinput_schemamust require a stringcommandproperty (the server execsinput.command). Omit the key entirely when no Auto-mode tool is registered — this keeps a pre-work-item-0006 session-open body byte-identical.subagent_tools— optional, default[]. Remote-launch declarations forlaunch_subagent; each includes an image and one or more configured{harness, command_template, prompt_via, timeout_secs}entries. These are stored per client and are not checked againstallowed_tools; the image is checked againstavailable_sandboxeswhen the remote launch is dispatched. The provider receives only the tool name, description, and input schema. Invalid declarations are rejected with422 invalid_subagent_tools.compaction— optional, default omitted. Configures how this session's history is compacted once it grows large. Immutable for the life of the session — set only at creation; seePOST .../joinbelow for the rejection behavior onjoin. Two wire shapes:{"mode": "auto", "size": 128000} {"mode": "client"} {"mode": "client", "prompt": "custom instruction"}mode: "auto"— the server automatically compacts this session's history once a completed turn's own reported usage (input_tokens + output_tokens, from the provider'susage— see Message Types —provider.response) exceedssize.sizeis a token count, not bytes or characters, with a minimum of 1,000 — a lower value is rejected with400 bad_requestbefore the session is created. Seesession.compaction.started/completedfor the events this produces and the Event Streaming guide for the behavior when a provider doesn't report usage.mode: "client"(also the default whencompactionis omitted ornull) — the server never auto-compacts; the harness decides when to callsession.compact. An optionalpromptis used as this session's default custom compaction instruction for futuresession.compactcalls that don't supply their ownprompt.
At session creation, BAE also connects to any MCP servers named in the
profile's mcp_servers list, runs the MCP initialize handshake, and merges
their tools into the tool list advertised to the provider. A server not found
in the registry is skipped non-fatally (logged as an error).
Response 201 Created:
{
"session_id": "ses_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"session_key": "bae_ses_1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f",
"profile": {
"id": "pro_…",
"name": "main",
"allowed_tools": ["get_current_time"],
"mcp_servers": ["filesystem"],
"provider": {
"provider": "anthropic",
"model": "claude-sonnet-4-6"
}
}
}
session_keyis shown exactly once. Store it for all subsequent requests on this session. The returnedprofileis sanitized — noauth_token, no env var names are included.
Errors:
401 unauthorized— bad or revoked client key. Checked before the request body is even parsed, so a bad key wins over a malformed body.400 bad_request— the body is malformed. Three cases, distinguished bydetail:- a non-JSON body, or a missing/wrong
Content-Type—detailis the raw rejection text. - a malformed
compactionvalue (badmode, missingsize, non-integersize, etc.) —detailis"invalid compaction config: <serde error>", e.g."invalid compaction config: missing field `size`". - any other malformed field —
detailis"invalid request body: <serde error>".
- a non-JSON body, or a missing/wrong
400 bad_request—compaction.modeis"auto"andsizeis below the 1,000-token minimum. Checked before the profile is even loaded, so no session row (including an error-state audit row) is created for this case.403 tool_not_allowed— a declared tool is not inallowed_tools.422 profile_unavailable— the profile was deleted between key creation and session open. Asession.errorevent is still recorded for audit.422 primary_provider_unavailable— the profile'sprimary_providername is not in the server's[providers]registry. Logged (tracing::error!) on every attempt, never deduplicated. Asession.errorevent (reason: "primary_provider_unavailable") is recorded for audit, same posture asprofile_unavailable. No session is created and no session key is issued.
Auth: client key. May be a different client key than the one that opened the session — that's the point of this endpoint.
Request body: identical shape to POST /api/v1/sessions:
{
"client_version": "1.0.0",
"tools": [
{ "name": "get_current_time", "description": "…", "input_schema": {} }
],
"subagent_tools": []
}tools are validated against the shared profile's allowed_tools,
exactly like create. A joining client declares its own, independent tool
set — joining never merges with, replaces, or reads any other client's
declared tools. See Message Types — session.join.
compaction is a session-level setting fixed at creation — it cannot be
changed by a joiner. Supplying the compaction key on join at all — including
as "compaction": null — is rejected with 400 bad_request (checked
before auth-adjacent state is even loaded):
{
"type": "bad_request",
"title": "Bad Request",
"status": 400,
"detail": "compaction is a session-level setting fixed at creation and cannot be set on join"
}rather than silently ignored, since a joining client that believed it had
changed the mode would be a silent correctness bug — and a client that
serializes "compaction": null to mean "no change" needs the same rejection
a stray, meaningful null would get. Omitting the key entirely is the only
accepted form on join.
Response 201 Created: identical shape to create:
{
"session_id": "ses_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"session_key": "bae_ses_7f8e9d0c1b2a7f8e9d0c1b2a7f8e9d0c1b2a7f8e9d0c1b2a",
"profile": { "id": "pro_…", "name": "main", "…": "…" }
}The response mints a new session key (distinct from the creator's, and from any other prior joiner's) bound to the joining client key. MCP connections are not re-resolved on join — they are session-wide infrastructure established once, at create.
Checks, in order (first failure wins):
401 unauthorized— bad or missing client key.400 bad_request— thecompactionkey was present in the body at all (including"compaction": null). Checked immediately after auth, before the session is even looked up.404 not_found— no session with this id.409 session_closed— the session isclosedorerror(detail: "session is already <state>", same shape asDELETE's conflict). A joiner cannot resurrect a terminal session.403 profile_mismatch— the joining client key'sprofile_iddiffers from the session'sprofile_id. This is the hard boundary that keeps a client on profile X from ever attaching to a session created under profile Y. No event is logged, no session key is minted, the session is untouched — an authorization failure at the client-key level, same posture astool_not_allowed.422 profile_unavailable— the shared profile was deleted. Same audit posture ascreate: a separatestate='error'session row is logged; the joined session itself is untouched.422 primary_provider_unavailable— the shared profile'sprimary_provideris not in the registry. Same logging/audit posture ascreate's check above.403 tool_not_allowed— a tool the joiner declared is not in the shared profile'sallowed_tools(validated independently of what the creator or any other joiner declared).
See Multi-Client Sessions for a worked example and Wire Protocol — FIFO turn ownership for what happens once both clients start sending messages.
Auth: session key for {id}.
Response 200 OK:
{ "drivers": ["key_a1b2c3d4", "key_e5f6a7b8"] }A sorted array of client-key ids currently registered as drivers (via
session.registerDriver), from the server's
in-memory registry. This is live-only — it resets on server restart, the
same posture as MCP session state. For durable "who ever joined or
registered" history, use GET /api/v1/sessions/{id}/events and look for
session.open, session.join, and session.driver.register events.
Errors: 401 unauthorized, 404 not_found.
Auth: session key for {id}.
Returns the full append-only event history for the session, oldest first. Works on open, closed, and error sessions as long as the session key is valid.
GET /api/v1/sessions/ses_…/events?limit=100&cursor=
Response 200 OK:
{
"items": [
{
"id": "evt_…",
"session_id": "ses_…",
"client_key_id": "key_…",
"event_type": "session.open",
"payload": {"client_version": "1.0.0", "tools": ["get_current_time"]},
"created_at": "2026-07-06T18:26:01.000Z"
},
…
],
"next_cursor": null
}See 04-message-types.md for the full event_type catalog and
payload shapes.
Auth: session key for {id}.
Inserts a session.close event ({"reason":"client_close"}) and moves the
session to closed state. Also terminates any spawned MCP subprocess connections
and drops the session's broadcast channel.
Response 200 OK:
{
"session_id": "ses_…",
"state": "closed"
}Errors:
401 unauthorized409 session_closed— session is already closed or in error state.
Auth: session key for {id}.
This is the single endpoint for live session interaction. It accepts a
JSON-RPC 2.0 request object and responds with an application/x-ndjson stream
of JSON-RPC objects: zero or more session.event notifications, followed by a
terminal response (or no terminal response for session.subscribe while active).
See Wire Protocol for the envelope format, framing rules, and error codes.
Request:
POST /api/v1/sessions/ses_…/rpc
Authorization: Bearer bae_ses_…
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 1,
"method": "session.sendMessage",
"params": { … }
}Response (always 200 once the stream opens):
Content-Type: application/x-ndjson
{"jsonrpc":"2.0","method":"session.event","params":{…}}\n
{"jsonrpc":"2.0","method":"session.event","params":{…}}\n
{"jsonrpc":"2.0","id":1,"result":{…}}\n
The twelve supported method values are session.registerDriver,
session.sendMessage, session.compact (see Compaction
below), session.subscribe, session.unsubscribe,
session.startRemoteSandbox, session.stopRemoteSandbox,
session.execRemoteSandbox, session.reportLocalSandbox (the last four are
documented in Sandboxes below; see the
Sandboxes guide for a walkthrough),
session.reportLocalSubagent, session.cancelSubagent, and
session.updateClientTools (see Subagents).
Register the calling connection's client key as a driver on this session
— required once before that client key's first session.sendMessage call.
SDK harnesses call this automatically as part of connect()/join();
application code normally never calls it directly. See
Wire Protocol — FIFO turn ownership
for the full driver/observer model.
Params: {}
Terminal result:
{ "jsonrpc": "2.0", "id": 1, "result": { "registered": true } }- Idempotent. A repeat call from an already-registered client key returns
registered: truewithout inserting a duplicatesession.driver.registerevent. - Records
session_id → client_key_idin the server's in-memory driver registry (seeGET .../participants) and inserts a broadcastsession.driver.registerevent — other drivers/observers see who registered, live. - No auto-registration anywhere else: a connection that only ever calls
session.subscribenever needs to register, andsession.sendMessagewill never silently register a caller on its behalf.
JSON-RPC errors:
-32000— the session is not inopenstate (mirrorssendMessage's state gate — a terminal session cannot gain drivers).
Send a user turn and stream live events as the provider processes it.
Replaces POST /api/v1/sessions/{id}/messages (removed).
Requires prior driver registration. The calling client key must have
already called session.registerDriver on this session (see above) — SDK
harnesses do this automatically during connect()/join().
Params:
{
"message": {
"role": "user",
"content": "What time is it?"
}
}role— optional, defaults to"user".content— a plain string, or an array of content blocks. Tool result blocks ({"type":"tool_result",…}) are passed here on the second leg of a tool round-trip.
Stream:
Zero or more session.event notifications are emitted in order as the turn
progresses (provider request/response, tool calls, MCP request/response, etc.),
followed by a terminal result.
Terminal result:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"message": {
"role": "assistant",
"content": [
{"type": "text", "text": "It is currently 18:26 UTC."}
]
},
"events": [
{
"id": "evt_…",
"session_id": "ses_…",
"client_key_id": "key_…",
"event_type": "client.message.send",
"payload": {"role": "user", "content": "What time is it?"},
"created_at": "2026-07-06T18:26:10.000Z"
},
{"id": "evt_…", "event_type": "provider.request", "payload": {…}, "created_at": "…"},
{"id": "evt_…", "event_type": "provider.response", "payload": {…}, "created_at": "…"},
{"id": "evt_…", "event_type": "server.message.send", "payload": {…}, "created_at": "…"}
]
}
}result.eventscontains every event appended during the turn, in order — includingclient.message.send. The live notifications are a filtered subset of this (client-generated events are not echoed back as notifications, but are present inresult.events).- A client that ignores notifications and reads only
result.eventsloses nothing.
Tool call response (loop paused):
The loop pauses (Outcome::Paused) whenever the assistant response contains
at least one dispatch:"client" tool_use block. The terminal result
message.content carries every tool_use block from that turn — client,
sandbox, and mcp alike — each tagged with its dispatch (see Content
blocks below):
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"message": {
"role": "assistant",
"content": [
{"type": "tool_use", "id": "tu_abc123", "name": "get_current_time", "input": {}, "dispatch": "client"},
{"type": "tool_use", "id": "tu_xyz789", "name": "list_directory", "input": {"path": "/data"}, "dispatch": "mcp"}
]
},
"events": [ … ]
}
}The mcp/remote-subagent block above was already dispatched and answered by the server
before the turn paused — its mcp.request/mcp.response/tool.result
events are already present in result.events. The client's job:
- Execute only
dispatch:"client"blocks. For each one, call the registered handler bynamewithinputand build atool_resultblock echoingtool_use_id. - Treat every other block as informational. A
sandbox/mcp/remote-subagent block (or, against an older server that omitsdispatch, any block whosenameis not in the client's own registered-tool set) is display-only — surface it to application code/UI if useful (e.g. "server is runninglist_directory"), but do not execute it and do not synthesize atool_resultfor it. The server already owns that result. - Return only the client's own results. Send back a
usermessage whosecontentis exactly thetool_resultblocks for the blocks the client executed — nothing for the server-dispatched ones:
{
"jsonrpc": "2.0",
"id": 2,
"method": "session.sendMessage",
"params": {
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "tu_abc123",
"content": "2026-07-06T18:26:10Z"
}
]
}
}
}Absent-dispatch fallback. A server that predates this contract never sets
dispatch and never sends server-dispatched blocks to the client at all — its
server.message.send/terminal message.content only ever contains blocks the
harness itself declared. A harness talking to such a server falls back to its
old behavior: treat a tool_use block as its own iff name is in its own
registered-tool set.
Server-side merge. The server dispatched and answered the sandbox/mcp/remote-subagent
blocks itself before pausing, and stashes those results across the pause.
When the client resumes with its own tool_results, the server merges both
result sets into the single following user turn recorded in history — one
tool_result per tool_use id in the paused assistant turn, server results
first-class. If the client mistakenly returns a tool_result for a
server-dispatched id, the client's copy is dropped in favor of the server's.
A resume that doesn't answer exactly the paused turn's id set (missing,
duplicate, or unexpected id) is rejected with a session.error
(reason: "tool_result_merge_invalid") and a -32000 JSON-RPC error — the
session moves to error, so its incomplete durable tool exchange can never be
replayed upstream. A plain user message is instead an explicit abandonment:
the server synthesizes error results for unanswered client ids, preserves the
plain content, and keeps the session open. See Wire Protocol — FIFO
turn ownership
for how the pause/resume gate itself works.
A message that carries tool_result blocks when no paused turn is
waiting for them — typically because its owner came back after
BAE_TURN_TIMEOUT and another driver's message or a session.compact had
already retired the pause — is rejected with -32000 "tool result merge invalid: no paused turn is awaiting tool results (it may have timed out and been retired)". Nothing is recorded and the session stays open; send an
ordinary message instead.
Provider failure:
When all providers fail, the terminal response is still a result (not an
error object) with HTTP 200. The result.message contains a generic "provider
unavailable" assistant turn; result.events includes the full failure trail
(including session.error with reason: "all_providers_failed"). The session
moves to error state. SDKs surface this as ProvidersFailedError.
JSON-RPC errors:
-32700 Parse error— request body is not valid JSON.-32600 Invalid Request— not a valid JSON-RPC request; also used for batch arrays.-32601 Method not found— unknown method.-32602 Invalid params— missing or wrong-typed params.-32000— session is not open (openstate required).-32001 driver_not_registered—{"code": -32001, "message": "call session.registerDriver before session.sendMessage"}. Checked first, before the state check, param validation, the turn lock, or broadcast subscription. Never auto-registers — seesession.registerDriverabove.
FIFO queuing. If another driver's turn is already in flight on this session, this call blocks — its NDJSON response opens but stays silent (zero bytes written) until the in-flight turn completes or is judged abandoned. This is not an error: no bytes means "still queued," not "stuck." Apply your own client-side request timeout if you'd rather give up than wait indefinitely — the server itself never times out a queued (not yet started) message. See Wire Protocol — FIFO turn ownership for the full ordering, ownership, and abandonment-timeout semantics.
Trigger compaction manually: ask the model for a single self-contained
summary of the session's history so far, and make it the new starting point
for provider-facing history from the next turn onward. This is the
mode: "client" counterpart to mode: "auto"'s in-turn check — see
Compaction — session creation for the
compaction config and the Event Streaming guide
for when to use each mode.
Requires prior driver registration, exactly like session.sendMessage.
Shares the same per-session turn gate as session.sendMessage — a
compaction can never run concurrently with a live turn on the same session;
a call arriving while a running turn is in flight queues (silent,
zero-byte NDJSON response) exactly like a second driver's sendMessage
would, and proceeds once that turn releases the gate.
Unlike session.sendMessage, session.compact never waits on a paused
turn:
- If a paused turn is already blocking the gate when the compact call
arrives, or a turn that was running when the call queued pauses before
releasing the gate, the call fails immediately with
-32020(see below) instead of waiting — a driver that pauses on a tool call and then tries to compact its own session would otherwise deadlock against itself, and an observer's compact would otherwise block forever if the paused driver never comes back. - A pause whose turn timeout has already expired is instead reclaimed
exactly as
session.sendMessagereclaims it: asession.errorevent (reason: "driver_turn_abandoned") is logged, then oneserver.message.sendevent answers every tool-use id the abandoned turn never got a result for with a synthetic errortool_result(synthetic: "abandoned_tool_results"— see Message Types —server.message.send), followed by onetool.resultevent per synthetic client result (so everytool.callstays paired), and the compaction then proceeds using that repaired history.
Permitted regardless of the session's stored compaction.mode — a harness
may force an early compaction on a mode: "auto" session — and never mutates
the session's persisted config.
Params:
{ "prompt": "Summarize focusing on the open TODOs and their file paths." }prompt— optional string,null, or omitted entirely ({}).- Prompt precedence for this call: this param, if a non-null string, is used
as-is (even on a
mode: "auto"session). Otherwise, the session's storedcompaction: {"mode":"client","prompt":...}value from creation is used, if any. Otherwise, the server's built-in default compaction prompt is used.mode: "auto"sessions have no stored prompt field at all — an auto trigger always uses the built-in default.
Terminal result: the produced session.compaction.completed
event:
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"id": "evt_…",
"session_id": "ses_…",
"client_key_id": "key_…",
"event_type": "session.compaction.completed",
"payload": {
"preamble_event_id": "evt_…",
"summary_event_id": "evt_…",
"compacted_message_count": 17,
"input_tokens": 42100,
"summary_tokens": 900
},
"created_at": "2026-09-23T18:26:10.000Z"
}
}Unlike session.sendMessage, this is not wrapped in a {message, events}
envelope — result is the event itself. Like session.sendMessage, the
response is an NDJSON stream: once the call holds the turn gate, every event
the compaction produces (session.compaction.started, the compaction's own
provider.request/provider.response, the synthetic server.message.send
preamble, the synthetic-free server.message.send holding the summary, and
session.compaction.completed, in that order — preceded, when an expired
pause is reclaimed, by its session.error, synthetic server.message.send
and tool.result events) is written to the caller as a session.event
notification frame before the terminal frame above. The same events also
reach any session.subscribe watcher through the ordinary broadcast path.
The errors checked before the gate is taken (-32001, a non-open session,
-32602) are answered with a single frame.
JSON-RPC errors, checked in this order:
-
-32001— caller is not a registered driver:"call session.registerDriver before session.compact". -
-32000— session is notopen:"session is <state>, not open". -
-32602—promptis present and not a string ornull:"Invalid params: <serde error>". -
-32020 turn_in_progress— a paused turn is blocking the gate and its timeout has not expired:"turn in progress: resolve the paused turn before compacting". Returned immediately, never waited on — see above. An expired pause is reclaimed instead (no error), and compaction proceeds. -
-32000— re-checked once the gate is held: the session no longer exists or is no longeropen("session no longer exists"/"session is <state>, not open"), or its profile was deleted ("profile_unavailable: the profile bound to this session is no longer available"). -
-32000— the compaction attempt itself failed, message"compaction failed: <detail>"where<detail>is one of:"provider_config: <detail>"— the primary provider does not resolve."all <N> providers failed"— every provider/fallback attempt failed."summary truncated: stop_reason max_tokens"— the Anthropic response was cut off by its own output-token budget."summary truncated: finish_reason length"— the OpenAI equivalent."empty summary"— the response had no non-whitespace text.
In every
-32000case above the session's previous effective history is untouched and the session remains compactable on a later attempt; seesession.compaction.completedfor the failure/retry behavior in detail, including the backoff a failed automatic attempt puts on later automatic triggers (session.compactitself always ignores that backoff). -
-32603— internal error, e.g. a database failure while writing the preamble/summary/completed records:"Internal error".
Open a live event subscription. Useful for an observer connection that is not
driving the turn (a dashboard, a log stream, etc.). Calling session.subscribe
is itself the observer registration act — there is no separate
"registerObserver" method and nothing is logged when a connection subscribes;
it stands in deliberate contrast to session.registerDriver, which does log.
Params:
{
"since_event_id": "evt_…"
}since_event_id— optional. When given, the server replays persisted events after this id before switching to the live stream.
Stream:
session.event notifications are emitted indefinitely. There is no terminal
response while the subscription is active. The stream ends on:
- Client disconnect.
- A
session.unsubscribecall from any connection. - A
"lagged"error notification (broadcast channel overrun — see Wire Protocol).
Live events follow the same filter rule as sendMessage notifications: only
non-client-generated events are forwarded.
End all active session.subscribe streams for this session.
Params: {}
Terminal result:
{
"jsonrpc": "2.0",
"id": 3,
"result": { "unsubscribed": true }
}The four methods below implement the remote-sandbox lifecycle and
client-originated local-sandbox telemetry described in the
Sandboxes guide. All four require prior driver
registration (session.registerDriver), the same -32001 gate
session.sendMessage uses.
Ask the server to start this session's one remote sandbox from an image in
the session's own profile's available_sandboxes.
Params:
{ "image": "python:3.12" }image— required, non-empty string.
Terminal result:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"sandbox_id": "…",
"image": "python:3.12",
"started_at": "2026-07-06T18:26:10.000Z"
}
}started_at is the session.sandbox.running event's created_at, or null
if that log write itself failed.
Events: session.sandbox.start ({"image", "dispatch":"remote"}), then
either session.sandbox.running ({"image", "sandbox_id", "dispatch":"remote"})
or session.sandbox.error ({"image", "phase":"start", "detail", "dispatch":"remote"}).
JSON-RPC errors:
-32001— caller is not a registered driver.-32602— missing or blankimage.-32000— session not open, the profile was deleted, or a sandbox is already running for this session (one sandbox per session — see Sandboxes guide — Session-wide, not per-driver).-32011 sandbox_image_not_allowed—imageis not in this session's own profile'savailable_sandboxes, including an image declared only on a different profile. No container is started.-32012 sandbox_start_failed— the driver'sensure_image/startcall failed (asession.sandbox.errorevent, phasestart, carries the detail).
Stop this session's one remote sandbox.
Params: {}
Terminal result:
{ "jsonrpc": "2.0", "id": 4, "result": { "stopped": true, "image": "python:3.12", "sandbox_id": "…" } }Events: session.sandbox.stop ({"image", "sandbox_id", "reason":"explicit", "dispatch":"remote"}),
then session.sandbox.stopped (same shape) on success, or
session.sandbox.error (phase:"stop") on failure. The handle is removed
from server state before the driver call, so a failed stop never leaves
a phantom sandbox other calls could still dispatch against.
JSON-RPC errors:
-32001— caller is not a registered driver.-32013 sandbox_not_running— no sandbox is currently running for this session.-32000—"sandbox stop failed: <detail>"when the driver'sstopcall itself errors. This is a generic code (there is no dedicated slug for a stop failure) — the authoritative signal is thesession.sandbox.errorevent; the handle is removed either way, so this response can be treated as "the sandbox is gone" regardless.
A session close (DELETE /api/v1/sessions/{id})
triggers the identical stop sequence for any still-running remote sandbox,
with "reason": "session_close" instead of "explicit".
Run one shell command in the session's already-started remote sandbox and
return the raw result. This is a manual-dispatch utility call, not part
of the turn loop — see Sandboxes guide — Auto vs. manual remote
dispatch. The caller
(the client harness) builds its own tool_result from the response and
sends it via an ordinary session.sendMessage continuation.
Params:
{ "command": "python --version" }Terminal result:
{
"jsonrpc": "2.0",
"id": 5,
"result": { "stdout": "Python 3.12.3\n", "stderr": "", "exit_code": 0 }
}A non-zero exit_code is still a successful RPC result, not an error —
the command ran and returned a result, whatever that result was.
Events (failure only): session.sandbox.error ({"image", "sandbox_id", "phase":"exec", "detail", "dispatch":"remote"}). There is no lifecycle
event on success — this is a utility call, not a turn.
JSON-RPC errors:
-32001— caller is not a registered driver.-32602— missingcommand.-32013 sandbox_not_running— no remote sandbox is running for this session; callsession.startRemoteSandboxfirst.-32000—"sandbox exec failed: <detail>"when the driver'sexeccall itself errors.
Report a lifecycle transition for a local sandbox — one the calling client harness started against its own Docker/Apple Containers engine, invisible to the server otherwise. Every SDK's builtin sandbox tools call this automatically; see Sandboxes guide — Local sandboxes report their own lifecycle.
Params:
{
"state": "running",
"image": "python:3.12",
"container_id": "…",
"detail": null
}state— required, one of"running","stopped","error".image— required string.container_id— optional,nullif not applicable.detail— optional,nullunlessstateis"error".
Terminal result:
{ "jsonrpc": "2.0", "id": 6, "result": { "reported": true } }Events: state maps to session.sandbox.running/stopped/error,
payload {"dispatch":"local", "image", "container_id", "detail"}, attributed
to the caller's client_key_id.
- No
available_sandboxesvalidation is performed — an arbitrary, unregistered image name is accepted and logged as-is. This is deliberate: a local sandbox is the harness developer's own local trust decision, never a server-governed resource. This method can also never forge a remote lifecycle event — there is no"scope"parameter — the remote lifecycle stays exclusively server-authored via the three methods above. - Any registered driver may call this — it does not need to be the current turn's owner, since local sandbox lifecycle is orthogonal to turn ownership.
JSON-RPC errors:
-32001— caller is not a registered driver.-32602— invalidstate, or missingimage.
These methods support the native CLI-subagent tools described in the
Subagents guide. They use the same JSON-RPC/NDJSON
transport and driver-registration gate as the sandbox methods above. A
subagent launch is asynchronous: the launch tool returns a started
acknowledgment, while a status tool returns the eventual output.
Remote subagent declarations are sent as subagent_tools alongside tools
and sandbox_tools when opening or joining a session. The declaration carries
the pinned launch_subagent schema plus the configured CLI command templates;
only its name, description, and input schema are exposed to the provider.
Local launches are ordinary client tools, and their automatically managed
local_subagent_status declaration is synchronized with
session.updateClientTools. SDKs serialize the tracked-task transition with
this full replacement, so concurrent launches cannot exceed the local cap and
an older removal cannot overwrite a newer addition.
All three methods require prior driver registration. Any registered driver may call them; turn ownership is not required.
Report a lifecycle transition for a local subagent, one whose subprocess is owned by the client harness. SDK local-subagent tools call this automatically. The server records the report as visibility telemetry and does not verify that the claimed process exists or has reached the claimed state.
Params:
{
"state": "start",
"subagent_id": "sba_…",
"harness": "claude",
"model": "claude-sonnet-5",
"detail": null,
"reason": null,
"exit_code": null
}state— required; one ofstart,running,completed,failed, orcancelled.subagent_id,harness,model— required non-empty strings.detail,reason, andexit_code— optional.reasonis normallynonzero_exit,spawn_failed, ortimeoutforfailed, andexplicitorsession_closeforcancelled; the server echoes it without validating the enum.
Terminal result:
{ "jsonrpc": "2.0", "id": 7, "result": { "reported": true } }Events: state maps to the corresponding
session.subagent.start/running/completed/failed/cancelled event.
Every payload contains dispatch: "local", subagent_id, harness,
model, and detail; terminal events add the applicable reason and
exit_code fields. A local timeout is reported as failed with
reason: "timeout".
JSON-RPC errors: -32001 (unregistered driver), -32602 (invalid or
missing fields), and -32603 (internal error).
Telemetry limitation: local reports are not authoritative. If the harness crashes or disconnects before reporting a terminal state, the server has no process handle and cannot reconcile the missing event.
Cancel one remote, server-tracked subagent. A local subagent is cancelled
in the SDK (Session::cancel_subagent); the server does not track its id.
Params:
{ "subagent_id": "sba_…" }Terminal result:
{
"jsonrpc": "2.0",
"id": 8,
"result": { "cancelled": true, "subagent_id": "sba_…", "was_running": true }
}Events: For a running task, the server kills the subprocess, retains the terminal
entry for the status tool, and emits session.subagent.cancelled with
dispatch: "remote" and reason: "explicit". Cancelling an already
terminal task is an idempotent success with was_running: false and no new
event. An unknown, local, or already-evicted id returns -32014 subagent_not_found.
JSON-RPC errors: -32001 (unregistered driver), -32602 (invalid
subagent_id), -32014 (not tracked), and -32603 (internal error).
General-purpose wire-protocol surface: this method is not specific to subagents. It replaces the calling client's complete
client_toolsentry and is reusable for any future feature that needs a dynamic client tool list. Subagents use it to add or removelocal_subagent_status.
Update the calling client's tool declarations for the next provider call.
The array is a full replacement, not a merge or a diff; other clients'
tools and the session's sandbox_tools/subagent_tools are untouched.
Params:
{
"tools": [
{
"name": "get_current_time",
"description": "Return the current UTC time as a string",
"input_schema": { "type": "object", "properties": {} }
}
]
}tools is required and may be empty. Each tool requires a non-empty name;
description and input_schema are optional, using the same ClientToolDef
shape as session open/join. Every name is checked against the profile's
allowed_tools; remote_subagent_status is reserved and rejected. In
particular, a profile used for local subagents must allowlist both
launch_subagent and local_subagent_status.
Terminal result:
{ "jsonrpc": "2.0", "id": 9, "result": { "updated": true } }The update applies to the next provider call. A call racing an in-flight turn does not rewrite the tool list already sent to that provider. SDK-managed subagent updates are ordered with their local task-set mutations; the server therefore receives full replacements in current-state order even when a status eviction and a new launch happen concurrently.
Events: No subagent lifecycle event is emitted. This method updates the calling client's persisted tool list for subsequent provider calls.
JSON-RPC errors: -32001 (unregistered driver), -32000 (session is not
open), -32602 (invalid params), -32015 tool_not_allowed (profile
allowlist or reserved name), and -32603 (internal error).
content on a message can be either a plain string or an array of typed
blocks:
{"type": "text", "text": "…"}
{"type": "tool_use", "id": "tu_…", "name": "…", "input": {…}, "dispatch": "client"}
{"type": "tool_result", "tool_use_id": "tu_…", "content": <string|block[]>}The server passes these through to/from the provider verbatim, except that
dispatch (and the reserved caller field) are stripped from tool_use
blocks before the provider ever sees them — see Message Types —
server.message.send.
A tool_use block in a server.message.send event carries dispatch, one of
"client", "sandbox", "mcp", or "subagent", whenever the turn paused for at least one
client-dispatched tool (see Tool call response above).
Older servers that predate this field omit it; a harness talking to such a
server falls back to treating a block as its own iff the block's name is in
its own registered-tool set.
SDK harnesses implement this loop inside session.send(message):
- POST
session.sendMessageto/rpcwith{message:{role:"user", content}}. - Read NDJSON: fire
on_eventfor each notification; await terminal result. - If
result.message.contentcontains no block that is "ours" (see below) → return the final assistant turn to the caller. Loop ends. - For each
tool_useblock that is ours, call the registered handler bynamewithinput. Build atool_resultblock echoingtool_use_id. Every othertool_useblock is informational only — skip it, do not synthesize atool_resultfor it. - POST
session.sendMessagewith{message:{role:"user", content:[…tool_result blocks for "ours" only]}}. - Go to step 2.
Deciding "ours": a block is ours iff dispatch == "client", or, against a
server that predates the dispatch field, name is in the harness's own
registered-tool set. Tools the client did not declare are dispatched
server-side (through configured MCP servers, the session's Auto-mode sandbox,
or a remote subagent) — against a current server they still surface as
tool_use blocks (tagged dispatch:"sandbox"/"mcp"/"subagent") so the full turn is visible, but the
harness must not execute or answer them; against an older server they never
surface at all. See session.sendMessage — tool call
response for the full contract.
Notes:
tool_use.idmust be echoed verbatim astool_result.tool_use_id.- Hooks (
before_send,after_receive,before_tool_call,after_tool_call,on_event) fire at their respective points; an error from any hook aborts the loop.