Skip to content

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:

OperationMethod & pathWhen it streams
createMessagePOST /conversations/{conversation_id}/messagesAlways, unless ?stream=false (§9)
createConversationPOST /conversationsOnly 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)
upsertConversationByExternalIdPUT /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: 200 with Content-Type: application/x-ndjson.
  • Framing: one complete JSON object per line, each line terminated by \n. No enclosing array, no comma separators, no SSE data: prefixes — each line parses standalone with any JSON parser.
  • No Content-Length. The response length is unknown when headers are sent. Delivery uses Transfer-Encoding: chunked on 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 \n themselves (§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:

IntermediaryFailure modeRemedy
nginx (reverse proxy)proxy_buffering on (the default) holds the body until complete or buffer-fullproxy_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 sidecarsResponse buffering filters, aggressive stream_idle_timeoutExempt the streaming routes from buffering filters; raise the idle timeout (see below)
Cloud / hardware load balancersIdle-connection timeouts (often 60 s) silently kill quiet streamsRaise 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 onceDo not compress application/x-ndjson responses, or use a streaming-flush compressor configuration
The adapter itselfReading 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 bodyUse the streaming read API (ReadableStream, chunked iterators) — never the buffered one

Two timing facts drive timeout budgets:

  1. Streams can be legitimately quiet. A capacity hold under on_capacity: "hold" emits queued notices 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’s max_hold_seconds (getCapacity, GET /capacity) or accept truncation and lean on the reconcile path (§8).
  2. 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/1

The 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 type field (§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_message streams, 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).

typeTerminalCarriesEmitted when
systemnoRun initialization — subtype: "init", the session_id, and run configuration such as available toolsOnce, when the run starts. The session_id identifies the agent session that persists across the conversation’s messages
stream_eventnoAn 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
assistantnoA 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
usernoA complete tool-result turntool_result blocks carrying what the tools returned to the agentAfter tool executions, before the agent’s next turn
resultyesThe run outcome: subtype: "success" or an error subtype, plus usage (token accounting) and run metadataExactly 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.

Line shapeTerminalMeaning
{"event":"ping"}noKeepalive. 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}noCapacity-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 (getCapacitymax_hold_seconds); exceeding it terminates the stream with a capacity-exhausted platform error line
{"object":"platform.event","type":"conversation","conversation":{…}}noThe 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":{…}}yesTerminal 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

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.

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:

  1. Exactly one terminal line. Every complete stream ends with exactly one terminal line — the agent result event (success or failure subtype) or a platform.event error line — never both, never neither, never anything after it.
  2. 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-Key header on createMessage is 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.

All transcripts below are real wire format — each line is exactly what arrives on the socket. IDs and timestamps are illustrative.

The simplest case: pooled conversation, no capacity pressure, no tool use. Request:

POST /conversations/con_01hzx8conv001/messages
Authorization: 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_delta chunks reproduces the persisted assistant content exactly — which is also what the assistant complete-turn line carries, and what listMessages returns afterwards. A client that renders deltas live needs nothing further; a client that only wants final state can ignore deltas and read the assistant turn (or skip the stream entirely with ?stream=false, §9).

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_delta chunks (or the last assistant turn); an adapter building a rich host UI mirrors the tool_use / tool_result pairs into activity indicators. Both read the same wire.

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:

  • queued notices 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 (getCapacitymax_hold_seconds). If it elapses first, the stream ends instead with a terminal platform.event error line whose problem type slug is capacity-exhausted — in-band, because the 200 header 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.

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:

  1. 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).
  2. 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-Key so the retry replays instead of duplicating.)
  3. Reconcile (§8): poll listMessages until the assistant message’s status leaves in_progress — the full final content arrives in history exactly as the stream would have carried it.

When the filler cascade resolves to enabled — tenant.settings.filler_enabledconversation.fillermessage.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:

  • Tenantsettings.filler_enabled (default true) sets the deployment-wide posture.
  • Conversationfiller: {enabled: …} on create/update overrides the tenant default for the thread.
  • Messagefiller: {enabled: …} on createMessage overrides 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.

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- and upsertConversationByExternalId-with- initial_message streams unchanged — the only addition is capturing the leading platform.event conversation line (or the X-Shiftagent-Conversation-Id header).
  • 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/user turns, usage from result, session identity from system. Translate as much or as little as the host needs; the wire always carries everything.

Summarizing the recovery contract in one place:

SituationClient 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 subtypeThe agent’s run failed; the assistant message is in history with status: "failed". Handle per host policy
Stream ended with a platform.event error lineThe 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 lineTruncated. 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=false

Semantics:

  • The request blocks until the run completes, then returns 201 with the completed assistant Message as plain application/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 429 capacity-exhausted; a run that references an unvaulted secret alias returns 422 missing-secret naming the alias — re-send with the secret attached, exactly as in the streaming case (§5.4).
  • Everything stream-specific disappears. No queued progress (a hold wait 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 send filler: {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-Key header 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.