Streaming Contract
This document is the protocol narrative for the Integration API’s streaming surface. The
normative machine-readable definition is the StreamLine schema in the OpenAPI specification;
this document explains how to operate and consume the protocol correctly — what flows over
the wire, what each line means, what can go wrong, and exactly what a client must do about it.
The stream is the platform’s native agent event stream: raw Claude Agent SDK events passed through verbatim, interleaved with a small set of platform notice lines. The platform does not re-encode agent output into a proprietary envelope — adapters translate this stream into whatever shape their host system needs. That is the design stance: the wire carries the runtime’s own event vocabulary at full fidelity (tool calls, tool results, incremental deltas, usage accounting), and the client-specific reduction — “just give me the text”, “mirror tool activity into my UI”, “collect the final reply” — lives in the adapter, where it belongs.
Three operations produce event streams:
| Operation | Method & path | When it streams |
|---|---|---|
createMessage | POST /conversations/{conversation_id}/messages | Always, unless ?stream=false (§9) |
createConversation | POST /conversations | Only when the body includes initial_message; the created conversation’s ID rides in the X-Shiftagent-Conversation-Id response header and in a leading platform.event line (§3.2, §5.5) |
upsertConversationByExternalId | PUT /conversations/by-external-id/{external_id} | Only when the body includes initial_message — created or continued alike; same header + leading line |
Everything below applies identically to all three. Where behavior differs, it is called out.
1. Transport: NDJSON over a plain HTTP response
Section titled “1. Transport: NDJSON over a plain HTTP response”The streaming response is newline-delimited JSON (NDJSON):
- Status:
200withContent-Type: application/x-ndjson. - Framing: one complete JSON object per line, each line terminated by
\n. No enclosing array, no comma separators, no SSEdata:prefixes — each line parses standalone with any JSON parser. - No
Content-Length. The response length is unknown when headers are sent. Delivery usesTransfer-Encoding: chunkedon HTTP/1.1 or ordinary DATA frames on HTTP/2 — the client must not expect a length header and must not wait for the connection to close before processing. - Incremental flush. The server flushes each line as it is produced. A text delta reaches the client within milliseconds of the agent producing it — if nothing in between buffers it (see the operator warnings below).
- Chunk boundaries are not line boundaries. TCP/HTTP chunking is oblivious to the protocol:
a single read may deliver half a line, three lines, or one line plus the beginning of the next.
Clients MUST buffer bytes and split on
\nthemselves (§7); never assume one read = one line. - Empty lines. Clients SHOULD skip empty lines silently rather than treating them as a parse error.
- Encoding: UTF-8, always.
Pre-stream failures are not stream lines. Authentication failures, validation errors, an
archived conversation, a suspended tenant, or on_capacity: reject hitting a full pool are all
rejected before any NDJSON byte is written — as ordinary RFC 9457 application/problem+json
responses with their proper HTTP status (401/403/404/409/422/429). A client therefore
branches on the response’s Content-Type: application/x-ndjson → consume the stream;
application/problem+json → handle the problem. Once the 200 and the NDJSON content type are
committed, any subsequent failure can only arrive in-band — as the agent’s terminal result
event with a failure subtype, or as a terminal platform.event error line (§4) — the HTTP status
is already sent and will not change.
Warnings for adapter operators: keep the pipe unbuffered
Section titled “Warnings for adapter operators: keep the pipe unbuffered”The single most common integration failure is not a protocol bug — it is an intermediary that buffers the response and turns a live stream into a dead wait followed by one giant flush. Every hop between the shiftagent install and the end consumer (adapter, host gateway, ingress, service mesh, CDN) must be audited:
| Intermediary | Failure mode | Remedy |
|---|---|---|
| nginx (reverse proxy) | proxy_buffering on (the default) holds the body until complete or buffer-full | proxy_buffering off; for the streaming routes, or have the origin send X-Accel-Buffering: no; also disable proxy_cache on these routes |
| Envoy / service mesh sidecars | Response buffering filters, aggressive stream_idle_timeout | Exempt the streaming routes from buffering filters; raise the idle timeout (see below) |
| Cloud / hardware load balancers | Idle-connection timeouts (often 60 s) silently kill quiet streams | Raise the idle timeout on the streaming path above the deployment’s maximum capacity-hold time, or accept truncation and rely on the reconcile path (§8) |
| Response compression (gzip/brotli) | Compressors buffer input to build blocks — deltas arrive late or all at once | Do not compress application/x-ndjson responses, or use a streaming-flush compressor configuration |
| The adapter itself | Reading the whole upstream body before forwarding (await res.text(), framework “body parsing” middleware) | Pipe bytes through as they arrive; exempt streaming routes from any body-collection middleware |
| Client-side HTTP libraries | ”Convenience” APIs that resolve only on full body | Use the streaming read API (ReadableStream, chunked iterators) — never the buffered one |
Two timing facts drive timeout budgets:
- Streams can be legitimately quiet. A capacity hold under
on_capacity: "hold"emitsqueuednotices only as the queue state changes, and a long tool execution can produce no deltas for a while. The platform emits{"event":"ping"}keepalive lines during quiet stretches precisely so idle-timeout middleboxes see traffic — but any hop that buffers will also swallow the pings. Budget idle timeouts above the deployment’smax_hold_seconds(getCapacity,GET /capacity) or accept truncation and lean on the reconcile path (§8). - Truncation is survivable by design. If any hop kills the connection, the run continues server-side and the outcome lands in history regardless (§8). Buffering, by contrast, is silent degradation — everything still “works,” just uselessly late. Prioritize fixing buffering over fixing timeouts.
2. The stream format: raw agent events, versioned by header
Section titled “2. The stream format: raw agent events, versioned by header”Every streaming response carries the header:
X-Shiftagent-Stream-Format: claude-sdk/1The value names the event vocabulary flowing on the wire — claude-sdk/1 is the Claude
Agent SDK’s native event stream, passed through verbatim. The header is the compatibility
contract: a client written against claude-sdk/1 keys its parsing on this value, and a future
vocabulary revision announces itself here rather than silently changing line shapes. Within a
vocabulary version, new event types and new fields are additive — clients MUST ignore lines
and fields they do not recognize (§3.3).
Two families of lines interleave on the wire:
- Agent events — the majority of lines. Raw runtime events with a top-level
typefield (§3.1). These are the Claude Agent SDK’s own messages: run initialization, incremental deltas, complete assistant/user turns including tool calls and tool results, and the terminal result. - Platform lines — a small, stable set of notices the platform injects around the agent
events (§3.2): keepalives, capacity-hold notices, the leading conversation line on
create-with-
initial_messagestreams, and terminal platform errors.
Events are not stored in this wire form — the stream is the live transport only. The durable
record is the persisted Message, always retrievable via listMessages
(GET /conversations/{conversation_id}/messages).
3. Line vocabulary
Section titled “3. Line vocabulary”3.1 Agent events (top-level type)
Section titled “3.1 Agent events (top-level type)”type | Terminal | Carries | Emitted when |
|---|---|---|---|
system | no | Run initialization — subtype: "init", the session_id, and run configuration such as available tools | Once, when the run starts. The session_id identifies the agent session that persists across the conversation’s messages |
stream_event | no | An incremental delta. Text arrives as text_delta chunks inside content_block_delta events: {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"…"}}} | Repeatedly, as the agent produces output. Concatenating the text_delta chunks reproduces the persisted assistant content |
assistant | no | A complete assistant turn — the full message payload including any tool_use blocks (tool name, input) | After each assistant turn completes, including intermediate turns that only call tools |
user | no | A complete tool-result turn — tool_result blocks carrying what the tools returned to the agent | After tool executions, before the agent’s next turn |
result | yes | The run outcome: subtype: "success" or an error subtype, plus usage (token accounting) and run metadata | Exactly once, at the end of every run the agent itself concludes — success or failure alike |
The assistant/user complete-turn events mean a consumer never has to reassemble deltas to
know what happened: render stream_event deltas for live typing, or ignore them and read the
complete turns — both are fully supported consumption styles. Tool activity (tool_use /
tool_result blocks) is visible on the wire at full fidelity, so a host UI can mirror what the
agent is doing mid-run.
3.2 Platform lines
Section titled “3.2 Platform lines”| Line shape | Terminal | Meaning |
|---|---|---|
{"event":"ping"} | no | Keepalive. Emitted during quiet stretches so intermediaries see traffic. Ignore it (do not render, do not count) |
{"object":"platform.event","type":"queued","position":n,"retry_hint_seconds":n} | no | Capacity-hold notice, only under on_capacity: "hold" while no sandbox is available — emitted before any agent event, repeating as the queue drains. position is the place in line (1 = next); retry_hint_seconds is an estimate, not a promise. Bounded by the deployment’s maximum hold time (getCapacity → max_hold_seconds); exceeding it terminates the stream with a capacity-exhausted platform error line |
{"object":"platform.event","type":"conversation","conversation":{…}} | no | The leading line on streams initiated by createConversation / upsertConversationByExternalId with initial_message — carries the created (or matched) Conversation object. The conversation’s ID also rides in the X-Shiftagent-Conversation-Id response header, available before the first byte of body |
{"object":"platform.event","type":"error","problem":{…}} | yes | Terminal platform abort: the platform (not the agent) ended the run. problem is a full RFC 9457 object from the same registry as HTTP errors — e.g. missing-secret when the run referenced a secret alias vaulted at no scope (§5.4), or capacity-exhausted when a hold timed out |
3.3 Forward compatibility rule
Section titled “3.3 Forward compatibility rule”Clients MUST ignore lines whose shape they do not recognize — unknown top-level type values,
unknown platform.event types, unknown fields on known lines — while still honoring the
terminal-line contract (§4). New event types are additive within a stream-format version; the
X-Shiftagent-Stream-Format header changes only when the vocabulary itself is revised.
4. Termination and truncation
Section titled “4. Termination and truncation”The legal line grammar for one stream:
[conversation] queued* ( ping | system | stream_event | assistant | user )* ( result | error )stateDiagram-v2
direction LR
[*] --> Queued : on_capacity=hold,\npool full
[*] --> Running : sandbox available
Queued --> Queued : queued (repeats)
Queued --> Running : sandbox freed
Queued --> Aborted : max hold exceeded\n(capacity-exhausted)
Running --> Running : system · stream_event ·\nassistant · user · ping
Running --> Done : result (terminal)
Running --> Aborted : platform error line\n(e.g. missing-secret)
Done --> [*]
Aborted --> [*]
Two invariants make the stream verifiable:
- Exactly one terminal line. Every complete stream ends with exactly one terminal line —
the agent
resultevent (success or failure subtype) or aplatform.eventerrorline — never both, never neither, never anything after it. - Closed-without-terminal ⇒ truncated. If the connection closes (EOF, reset, timeout,
abort) and the client has not seen a terminal line, the client MUST treat the stream as
truncated. The same applies to a line that cuts off mid-JSON at EOF (discard the partial
line). Truncation is not failure: the run continues server-side and its outcome lands in
history regardless. The mandatory follow-up is the reconcile procedure in §8 — never
retry-send the same message on truncation alone (you would create a duplicate user message;
if your delivery pipeline requires at-least-once retries, that is what the
Idempotency-Keyheader oncreateMessageis for).
There is deliberately no re-attach endpoint on this surface: a truncated stream cannot be resumed mid-flight; the durable record is the reconciliation mechanism. This keeps the protocol stateless for the caller and makes “adapter restarted mid-stream” a non-event.
5. Annotated example streams
Section titled “5. Annotated example streams”All transcripts below are real wire format — each line is exactly what arrives on the socket. IDs and timestamps are illustrative.
5.1 Plain reply
Section titled “5.1 Plain reply”The simplest case: pooled conversation, no capacity pressure, no tool use. Request:
POST /conversations/con_01hzx8conv001/messagesAuthorization: Bearer sk_int_...Content-Type: application/json
{"content": "Summarize today's open jobs."}Response — 200, Content-Type: application/x-ndjson,
X-Shiftagent-Stream-Format: claude-sdk/1:
{"type":"system","subtype":"init","session_id":"3f7c9a12-6c1e-4b8a-9f27-c11d2e83a001","tools":["Read","Bash","dispatch-scheduler","invoice-lookup"]}{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"You have three open jobs today: "}}}{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"two installations and one repair visit."}}}{"type":"assistant","message":{"content":[{"type":"text","text":"You have three open jobs today: two installations and one repair visit."}]}}{"type":"result","subtype":"success","usage":{"input_tokens":1830,"output_tokens":24}}Notes:
- Exactly one terminal line (
result,subtype: "success"). - Concatenating the
text_deltachunks reproduces the persisted assistantcontentexactly — which is also what theassistantcomplete-turn line carries, and whatlistMessagesreturns afterwards. A client that renders deltas live needs nothing further; a client that only wants final state can ignore deltas and read theassistantturn (or skip the stream entirely with?stream=false, §9).
5.2 Reply with tool use
Section titled “5.2 Reply with tool use”The agent runs a skill mid-turn. Tool calls and their results are complete-turn events on the wire — the host sees exactly what the agent did:
{"type":"system","subtype":"init","session_id":"3f7c9a12-6c1e-4b8a-9f27-c11d2e83a001","tools":["invoice-lookup"]}{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01a","name":"invoice-lookup","input":{"invoice_ref":"INV-2214"}}]}}{"event":"ping"}{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_01a","content":"Invoice INV-2214: $1,840.00, due 2026-07-15, status open."}]}}{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Invoice INV-2214 is open for $1,840.00, due July 15."}}}{"type":"assistant","message":{"content":[{"type":"text","text":"Invoice INV-2214 is open for $1,840.00, due July 15."}]}}{"type":"result","subtype":"success","usage":{"input_tokens":2412,"output_tokens":58}}Notes:
- The
{"event":"ping"}keepalive bridges the quiet stretch while the tool executes. Ignore it. - An adapter that only wants the final text drops everything except the
text_deltachunks (or the lastassistantturn); an adapter building a rich host UI mirrors thetool_use/tool_resultpairs into activity indicators. Both read the same wire.
5.3 Capacity hold
Section titled “5.3 Capacity hold”The message was sent with on_capacity: "hold" while the sandbox pool was exhausted. (With the
default "reject" there is no stream at all — the request fails pre-stream with 429
capacity-exhausted + Retry-After, as an application/problem+json body.)
{"object":"platform.event","type":"queued","position":2,"retry_hint_seconds":15}{"object":"platform.event","type":"queued","position":1,"retry_hint_seconds":5}{"type":"system","subtype":"init","session_id":"3f7c9a12-6c1e-4b8a-9f27-c11d2e83a001","tools":["dispatch-scheduler"]}{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Done — the report is ready."}}}{"type":"assistant","message":{"content":[{"type":"text","text":"Done — the report is ready."}]}}{"type":"result","subtype":"success","usage":{"input_tokens":1744,"output_tokens":9}}Notes:
queuednotices precede every agent event and may repeat as the position improves; treat each as a UI update (“You’re next…”), not as an accumulating list.- The hold is bounded by the deployment’s maximum hold time (
getCapacity→max_hold_seconds). If it elapses first, the stream ends instead with a terminalplatform.eventerrorline whose problemtypeslug iscapacity-exhausted— in-band, because the200header was already committed when the hold began. - Adapters that want to avoid holds altogether can pre-check
getCapacity(GET /capacity→{pool_size, warm_available, warm_active, at_capacity, max_hold_seconds}) and shed or defer load at their own edge.
5.4 Missing secret — terminal platform error
Section titled “5.4 Missing secret — terminal platform error”The run references {{secret:CRM_API_KEY}}, but no scope — message, conversation, user, or
tenant — has that alias vaulted. The run fails fast: the platform terminates the stream with
a missing-secret problem naming the alias.
{"type":"system","subtype":"init","session_id":"3f7c9a12-6c1e-4b8a-9f27-c11d2e83a001","tools":["crm-sync"]}{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Starting the reconciliation. "}}}{"object":"platform.event","type":"error","problem":{"type":"https://shiftagent.example.com/problems/missing-secret","title":"Missing secret","status":422,"detail":"Alias CRM_API_KEY is vaulted at no scope; re-send with the secret attached.","request_id":"req_01hzx8err001"}}The recovery is a one-step retry: the host re-sends the message with the secret attached —
in message.secrets for a one-run credential, or staged first via putConversationSecrets /
putUserSecrets / the tenant credential registry for a longer-lived one. There is no mid-run
pause: the failed run’s user message is in history with the assistant message marked
status: "failed", and the re-sent message starts a fresh run that resumes the same session.
With ?stream=false the same failure is a plain 422 missing-secret problem response.
5.5 Create-with-initial_message: the leading conversation line
Section titled “5.5 Create-with-initial_message: the leading conversation line”createConversation (or upsertConversationByExternalId) with initial_message collapses
conversation creation and the first run into one round trip. The response is the stream — with
the conversation delivered up front, twice: in the X-Shiftagent-Conversation-Id response header
(available before the first body byte) and as the leading platform line:
{"object":"platform.event","type":"conversation","conversation":{"object":"conversation","id":"con_01hzx8conv001","external_id":"acme:conversation:ticket-4521","tenant_id":"tnt_01hzx8acme001","user_id":"usr_01hzx8jane001","...":"..."}}{"type":"system","subtype":"init","session_id":"3f7c9a12-6c1e-4b8a-9f27-c11d2e83a001","tools":["dispatch-scheduler","invoice-lookup"]}{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"You have three open jobs today."}}}{"type":"assistant","message":{"content":[{"type":"text","text":"You have three open jobs today."}]}}{"type":"result","subtype":"success","usage":{"input_tokens":1830,"output_tokens":12}}Capture the conversation ID from the header or the leading line — everything after it is the
ordinary createMessage protocol.
5.6 Stream interruption (truncation)
Section titled “5.6 Stream interruption (truncation)”A load balancer with a 60-second idle timeout kills the connection while the agent is mid-task. What the client received:
{"type":"system","subtype":"init","session_id":"3f7c9a12-6c1e-4b8a-9f27-c11d2e83a001","tools":["price-book"]}{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Working through the price book now"}}}<-- TCP connection closed: no result, no platform error line -->The client’s obligations, in order:
- Classify: connection closed without a terminal line ⇒ truncated (invariant 2, §4). The same classification applies to a line that cuts off mid-JSON at EOF (discard the partial line).
- Do not re-send the message. The run is still executing server-side. Re-sending creates a
duplicate user message and a second run. (If your delivery pipeline requires at-least-once
retries, send the original with an
Idempotency-Keyso the retry replays instead of duplicating.) - Reconcile (§8): poll
listMessagesuntil the assistant message’sstatusleavesin_progress— the full final content arrives in history exactly as the stream would have carried it.
6. Filler and the stream
Section titled “6. Filler and the stream”When the filler cascade resolves to enabled — tenant.settings.filler_enabled →
conversation.filler → message.filler, most specific wins — a low-latency filler agent bridges
the dead air before the main agent’s first token (“One moment while I pull that up — ”).
Filler output is part of the reply: it arrives as ordinary text_delta chunks and is part of
the persisted assistant message — indistinguishable from agent output by design. There is no
flag on the wire and no post-hoc marker in history; the reply reads as one continuous, natural
response, which is the point. What is configurable is whether the filler agent runs, at each
level of the cascade:
- Tenant —
settings.filler_enabled(defaulttrue) sets the deployment-wide posture. - Conversation —
filler: {enabled: …}on create/update overrides the tenant default for the thread. - Message —
filler: {enabled: …}oncreateMessageoverrides both for one run.
Voice-style hosts typically leave filler on (dead air is costly); hosts that batch replies or post-process text often turn it off per conversation or per message. Because filler is part of the persisted message, history and stream always agree — there is nothing to strip or reconcile.
7. Client parsing recipe
Section titled “7. Client parsing recipe”The reference consumption pattern, as framework-neutral TypeScript pseudocode. The essentials:
byte buffer → line splitter → JSON.parse → classify (platform line vs agent event), with
terminal tracking and text accumulation.
async function streamMessage( conversationId: string, body: MessageCreate,): Promise<{ text: string }> { const res = await fetch( `${BASE_URL}/conversations/${conversationId}/messages`, { method: 'POST', headers: { authorization: `Bearer ${SK_INT_KEY}`, 'content-type': 'application/json', // 'idempotency-key': crypto.randomUUID(), // if your pipeline retries }, body: JSON.stringify(body), }, );
// Pre-stream failures are plain problem+json — not stream lines. const contentType = res.headers.get('content-type') ?? ''; if (!contentType.includes('application/x-ndjson')) { const problem = await res.json(); // RFC 9457 problem object throw new IntegrationApiError(res.status, problem); // honor Retry-After on 429 } // Optional: key parsing on the vocabulary version. // res.headers.get('x-shiftagent-stream-format') === 'claude-sdk/1'
const reader = res.body!.getReader(); const decoder = new TextDecoder(); // UTF-8, streaming mode let buffer = ''; let text = ''; let terminal: 'result' | 'error' | null = null; let problem: Problem | null = null;
const handle = (line: any) => { // ---- Platform lines ---- if (line.event === 'ping') return; // keepalive — ignore if (line.object === 'platform.event') { switch (line.type) { case 'conversation': // Leading line on create/upsert-with-initial_message streams. ui.setConversation(line.conversation); return; case 'queued': ui.showQueued(line.position, line.retry_hint_seconds); return; case 'error': // terminal — RFC 9457 problem (e.g. missing-secret) terminal = 'error'; problem = line.problem; return; default: return; // forward compatibility: ignore unknown platform lines } } // ---- Agent events (raw Claude Agent SDK) ---- switch (line.type) { case 'system': // subtype "init": the run started; carries the session id. break; case 'stream_event': { const delta = line.event?.delta; if (line.event?.type === 'content_block_delta' && delta?.type === 'text_delta') { text += delta.text; ui.append(delta.text); // live typing } break; } case 'assistant': // Complete assistant turn — includes tool_use blocks. Mirror tool // activity into the host UI here if desired. break; case 'user': // Complete tool-result turn. break; case 'result': // terminal — run outcome + usage terminal = 'result'; if (line.subtype !== 'success') problem = toProblem(line); break; default: break; // forward compatibility: ignore unknown agent events } };
try { for (;;) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Chunk boundaries ≠ line boundaries: keep the trailing partial line. const lines = buffer.split('\n'); buffer = lines.pop()!; for (const raw of lines) { if (raw.trim() === '') continue; // tolerate blank lines handle(JSON.parse(raw)); } } // EOF: a leftover partial line means the last line was cut off mid-JSON. if (buffer.trim() !== '') throw new TruncatedStreamError('partial final line'); } catch (err) { // Network error / abort / partial line → reconcile, never re-send. return reconcile(conversationId); }
if (terminal === null) return reconcile(conversationId); // closed w/o terminal if (problem !== null) throw new AgentRunError(problem); return { text }; // concatenated text_delta chunks = persisted content}And the reconcile path — the only correct reaction to truncation:
async function reconcile(conversationId: string): Promise<{ text: string }> { // The run continues server-side; its outcome lands in history regardless. for (;;) { // listMessages — GET /conversations/{conversation_id}/messages const last = await getLatestAssistantMessage(conversationId); switch (last?.status) { case 'completed': return { text: last.content }; case 'failed': throw new AgentRunError(/* the run errored or was aborted, e.g. missing-secret */); case 'in_progress': default: break; // still running } await sleep(POLL_INTERVAL_MS); // e.g. 2 s with capped backoff }}Implementation notes:
- Never
await res.json()/res.text()on the streaming response — that is the buffered API and defeats the protocol. Use the streaming reader. decoder.decode(value, { stream: true })matters: a multi-byte UTF-8 character can straddle a chunk boundary, and streaming mode holds the partial code point instead of corrupting it.- The same loop consumes
createConversation- andupsertConversationByExternalId-with-initial_messagestreams unchanged — the only addition is capturing the leadingplatform.eventconversationline (or theX-Shiftagent-Conversation-Idheader). - For UI cancellation, abort the fetch (
AbortController). Aborting the stream does not abort the run — the assistant message still lands in history; reconcile if you later need it. - This recipe reduces the stream to text — the minimal translation. Richer adapters keep more:
tool activity from
assistant/userturns, usage fromresult, session identity fromsystem. Translate as much or as little as the host needs; the wire always carries everything.
8. Reconnect & reconcile guidance
Section titled “8. Reconnect & reconcile guidance”Summarizing the recovery contract in one place:
| Situation | Client action |
|---|---|
Stream ended with result, subtype: "success" | Done. The concatenated deltas (equivalently, the persisted message) are authoritative; no further calls needed |
Stream ended with result, failure subtype | The agent’s run failed; the assistant message is in history with status: "failed". Handle per host policy |
Stream ended with a platform.event error line | The platform aborted the run; handle the problem object (same registry as HTTP errors). For missing-secret, re-send the message with the secret attached (§5.4) |
| Stream closed without a terminal line, or partial final line | Truncated. Poll listMessages (GET /conversations/{conversation_id}/messages) until the assistant message’s status leaves in_progress. Do not re-send |
Pre-stream 429 capacity-exhausted (on_capacity: reject) | Back off per Retry-After, optionally consult getCapacity (GET /capacity), and re-send — nothing was created, so a plain retry is safe |
Because the user message and the assistant outcome are always durably recorded, the streaming layer carries zero exclusive state — any consumer can crash at any byte and recover from the REST surface alone.
9. The non-streaming alternative: ?stream=false
Section titled “9. The non-streaming alternative: ?stream=false”createMessage accepts ?stream=false for callers that cannot (or need not) consume NDJSON —
server-to-server automations, batch backfills, constrained runtimes:
POST /conversations/{conversation_id}/messages?stream=falseSemantics:
- The request blocks until the run completes, then returns
201with the completed assistantMessageas plainapplication/json— the same content the stream’s deltas would have carried. - Failures return their natural HTTP status with a problem body: capacity rejection is the usual
pre-stream
429capacity-exhausted; a run that references an unvaulted secret alias returns422missing-secretnaming the alias — re-send with the secret attached, exactly as in the streaming case (§5.4). - Everything stream-specific disappears. No
queuedprogress (aholdwait happens silently inside the blocking call), no deltas, no tool-activity visibility — just the final message. Filler is governed by the same enable cascade either way; callers on the blocking path typically sendfiller: {enabled: false}, since filler exists to bridge dead air on live streams. - Mind the blocking window. The call spans the entire run, including any capacity hold. Long
runs can outlast client and intermediary timeouts — a timed-out blocking call behaves exactly
like a truncated stream: the run continues server-side; reconcile via
listMessages(§8). - The
Idempotency-Keyheader is honored identically, and is more important here: a timed-out blocking call is indistinguishable from a lost response, and the key turns a blind retry into a safe replay.
createConversation and upsertConversationByExternalId have no stream parameter: without
initial_message they are already plain JSON responses; with initial_message they always
stream. To get “create + first message” without streaming, make two calls — createConversation
(no initial_message) or the upsert, then createMessage?stream=false.