Streaming
The Server-Sent Events contract — 13 frame types, and what each one means.
The wire
Three endpoints stream: starting a run (POST /v1/agents/{agentId}/runs),
resuming an approval-paused one (POST /v1/runs/{runId}/approvals/{approvalId}) and retrying a failed one (POST /v1/runs/{runId}/retry). Every other
operation returns plain JSON. All three respond 200 text/event-stream and send frames
until a terminal one (except the resume of a team run's assignment approval, which answers 202 with no stream; see Approvals). Each
frame is an event: line naming the type and a data: line carrying that event's JSON payload
— the type is never repeated inside the payload.
event: run_started
data: {"runId":"01936c2f-6d1a-7f60-9a53-2f0f1c9a77e1"}
event: token
data: {"text":"Checking"}
event: tool_call
data: {"callId":"c1","toolName":"web_search","argsJson":"{\"query\":\"…\"}"}
event: tool_result
data: {"callId":"c1","toolName":"web_search","ok":true,"result":"…"}
event: usage
data: {"inputTokens":1841,"outputTokens":612,"totalTokens":2453}
event: run_completed
data: {"runId":"01936c2f-…","status":"completed"}Three rules worth reading before you write a client
- Frames are append-only. New event types are added over time and existing payloads gain fields; they are never repurposed. Ignore an event type you do not recognise rather than failing on it — that is what keeps an old client working.
- Failure arrives in-band. Once the response has begun, the HTTP status cannot
change, so a failed run is still a
200that ends in anerrorframe. A client that only checks the status code will treat a failed run as a successful one. - Hanging up does not cancel the run. Disconnecting ends your view of it, not the
work: the run continues to its terminal state and is readable afterwards with
GET /v1/runs/{runId}. That is deliberate — a dropped mobile connection should not discard a minute of work. To stop a run on purpose, callPOST /v1/runs/{runId}/cancel; see Runs. There is no call that re-attaches to a stream that is still running, so after a dropped connection poll the run instead.
Events
run_started
The run exists and is executing.
Emitted once, as the first frame, as soon as the run row is committed. It carries the run id, so a client knows which run it is watching from frame one rather than from the terminal frame — which is what makes a Stop button, or a later GET /v1/runs/{runId}, possible mid-answer.
{ runId: uuid }
tool_warnings
The run is going ahead without some of your tools, and says which and why.
Sent only when there is something to say: straight after run_started on a start, and as the first frame of a resumed or retried continuation. Each warning carries a code, the tools concerned (at most 32), a fixed message, and server when a server needs a token. mcp_tools_not_requested: the run sent no tools, so your registered MCP tools were not used. default_tools_left_out: tools was sent and left out some of the agent’s own tools. mcp_server_not_connected: a tool is on a server that needs a token in mcpAuth and the request carried none. The run is not refused — it carries on without those tools. The same list is on the awaited result, the submit 202 and GET /v1/runs/{runId} as toolWarnings.
{ runId: uuid, toolWarnings: [{ code: string, tools: string[], message: string, server?: string }] }
token
An incremental slice of the assistant’s answer.
The stream’s bulk. Append text to what you have already rendered; slices are not sentence- or word-aligned, and no slice is ever re-sent.
{ text: string }
tool_call
The model asked to run a tool.
argsJson is the model’s own output, consolidated by callId. Treat it as untrusted text: render it, never execute it, and expect a matching tool_result with the same callId.
{ callId: string, toolName: string, argsJson: string }
tool_result
A tool returned, or failed.
ok is false both when the tool threw and when it returned a curated refusal — to a reader those are the same thing. On a failure, errorCode / errorMessage / errorCapability are the client-safe half, from a closed vocabulary. Read those rather than parsing result, which is truncated at 500 characters.
{ callId: string, toolName: string, ok: boolean, result?: string, errorCode?: string, errorMessage?: string, errorCapability?: string }
usage
Token usage for the coordinator’s own turn.
A final aggregate for that turn, not a running total. It does NOT include what a delegated specialist spent — that lands in the metering records, so the whole-run total is the usage on GET /v1/runs/{runId}, and that is also what your plan is charged against. If you re-bill your own customers, read the run rather than this frame.
{ inputTokens: number, outputTokens: number, totalTokens: number }
sub_run_step
One step of a specialist agent the coordinator delegated to.
Streamed live while the sub-run is still in flight, so a UI can show what a specialist is doing instead of spinning. kind is text | tool_call | tool_result; parentToolCallId is the delegation call it nests under. Each step is emitted exactly once. Safe to ignore entirely — the coordinator’s own answer still arrives on token.
{ agentSlug: string, parentToolCallId: string, kind: string, callId?: string, toolName?: string, text?: string, ok?: boolean }
tool_cap
A metered tool family hit its per-run call cap; the run continued without it.
A status frame, not a failure — the run goes on and completes normally, so render it as a quiet note rather than an error. Emitted at most once per family per run.
{ toolFamily: string, message: string }
notice
Something the user should know that is neither a failure nor a cap.
Today: long-term memory was unavailable, so the answer ran without it. Like tool_cap, a status frame — the run completes normally. notice is a stable key you can switch on; message is the client-safe sentence. At most once per key per run.
{ notice: string, message: string }
needs_approval
terminalA human-in-the-loop tool paused the run.
The model called an approval-gated tool, so the framework yielded the call instead of executing it. The run is persisted as awaiting_approval. Resume it with POST /v1/runs/{runId}/approvals/{approvalId}, which streams the continuation on that response. expiresAt is the deadline after which the pause is no longer decidable and the run ends as expired. approvableScopes lists the wider standing grants this pause admits (run, session, always) — approve-once and deny are always available and are not listed.
{ runId: uuid, approvalId: uuid, toolName: string, argsJson: string, approvableScopes: string[], expiresAt?: date-time, requestingAgentSlug?: string, delegatedGatedTools?: string[] }
needs_answer
terminalA run started by a person in the Console put a question to them. Never sent on a /v1 run.
The sibling of needs_approval, and deliberately not the same event: a question carries no expiry and can never be waived, so it waits indefinitely. Only a run started by a signed-in person in the Console can ask a question, so a run started with an API key never sends this frame. It is listed because the catalogue covers every frame the stream can carry.
{ runId: uuid, questionId: uuid, askingAgentSlug: string, question: string, options: { label: string, reason: string }[], recommendedIndex?: number, allowMultiple: boolean, allowFreeText: boolean }
run_completed
terminalThe run reached a graceful end.
status is the authoritative outcome: completed normally, budget_exceeded when the per-run token budget stopped the loop, or truncated when the answer was cut short. The last two are policy stops carrying the partial answer, not errors. When a stop carries a reason, errorCode names it — for budget_exceeded, cost_budget_exhausted means the organisation’s plan budget was spent and quota_exceeded that its monthly token allowance was used up. Read status; do not infer success from the frame’s presence.
{ runId: uuid, status: string, usage?: AmyUsage, model?: string, contextWindow?: number, suggestedFollowUp?: string, errorCode?: string }
error
terminalThe run failed.
The only failure signal once the stream is open — an HTTP status cannot change after the response has begun, so a failed run still arrives as a 200 carrying this frame. The stream stops after it. traceId is present when the failure carried one, and matches the id a pre-stream ProblemDetails would have returned, so one id identifies the failure either way. The full list of code values is on the Errors page.
{ code: string, message: string, traceId?: string }
Terminal frames
A stream of a /v1 run ends on exactly one of run_completed, error or needs_approval. needs_approval is terminal for this response only — the run is suspended, not finished, and it continues on the response
of the call that resumes it. The catalogue above also
lists needs_answer; a run started with an API key never sends it.
A stream that ends without any of these — the connection dropped, or a proxy cut it — has not told
you how the run ended. Read the run with GET /v1/runs/{runId}.