Stream Protocol Contract
The canonical StreamPart contract and the provider SSE invariants locked by tests.
Il contenuto delle pagine di documentazione è in inglese. Navigazione, ricerca e interfaccia seguono la lingua scelta.
Provider bytes are never exposed directly. Each adapter parses its wire stream and emits the same additive StreamPart union. Consumers can therefore switch providers without changing text, reasoning, tool-call, source, usage, or finish handling.
The SSE parser is regression-tested for:
- UTF-8 code points split across arbitrary byte boundaries;
- LF, CRLF, and bare CR line endings, including a CRLF pair split across chunks;
- UTF-8 BOMs, comments/keep-alives, named events, and multi-line
data:fields; - a final event without a trailing blank line;
- cancellation of the underlying reader when a consumer stops early.
Every successful provider stream must produce exactly one finish part. Provider usage shapes are normalized into Usage; provider finish values are normalized into the locked FinishReason union. A provider error event produces one typed error part and rejects usage and finishReason with the same error object.
Compatibility rule
StreamPart is additive. Applications should handle the variants they need and retain a default branch so a new non-breaking part does not break an older consumer.
for await (const part of result.fullStream) {
switch (part.type) {
case 'text-delta':
process.stdout.write(part.text);
break;
case 'error':
throw part.error;
default:
break;
}
}Additive parts since 1.8
type | When | Notes |
|---|---|---|
verify | verifyStep evaluated on a natural completion | stepIndex, attempt, ok, willRetry, optional feedback. Since 1.9 it is also serialized onto the UI wire (v2 only), journaled to the StreamStateStore and replayed on resume — before that it was dropped by toDeuzStreamResponse, so a verified run looked identical to an unverified one client-side. |
plan-update | Emitted via emitPlanUpdate from a tool | Live TaskList snapshot for a to-do panel. Also a UI v2 part. |
activity | Emitted via emitActivity from a tool | "Computer" feed line (message, optional level / data / agentPath). Also a UI v2 part. |
Additive parts and fields in 1.9
type / field | When | Notes |
|---|---|---|
warning | A lossy mapping was applied (a stripped sampling param, an unknown-slug capability fallback, a tool or document the wire could not carry) | { warning: CallWarning }. Emitted as it is discovered — always before the output of the step that produced it, because every warning site runs during capability resolution and request building. In a loop that means a later step can still emit one, after earlier text. Also serialized onto the UI wire (v2 only, message re-redacted), journaled to the StreamStateStore and replayed on resume; folds into AssistantTurnState.warnings. The same set resolves in bulk on StreamChatResult.warnings. CallWarning['type'] is an open union — keep a default. |
false-finish | The doneWhen guard rejected a natural completion | { stepIndex, attempt, willRetry }. One part per rejection, always before the terminal finish; willRetry: false is the rejection that spent the falseFinishGuard budget, after which finish carries providerMetadata.deuz.stoppedBy = 'false-finish'. Streaming loop only. On the UI wire (v2 only), journaled and replayed, and folded into AssistantTurnState.falseFinishes — the same treatment verify got. |
tool-state.denied / .deniedReason | An approval refused a gated call | Optional fields qualifying a terminal state: 'error' whose cause was a verdict, not a thrown tool. Set by the streaming loop at both terminal sites; deniedReason is the denier's own words (absent for a server-mode approveToolCall, whose verdict is a bare boolean). ToolRunState deliberately gains no 7th member, so exhaustive switches keep compiling. |
data.id | writeData(name, payload, { id }) | Reconciliation key. The wire stays strictly append-only; last-write-wins is the client's job. |
1.9 left wire v1 output unchanged in every byte. The tool-state fields and the data-part id ride carriers a v1 client already drops wholesale, and the two brand-new part types are v2-only — including when they sit inside a sub-agent frame, which is filtered recursively. 2.3 does not keep that property; see Wire versions in 2.3.
Additive fields in 2.3
2.3 adds no member to StreamPart or to DeuzUIPart. It adds optional fields to parts that already exist, and toDeuzStreamResponse (and toDeuzAgentStreamResponse, which frames a native run's parts the same way) forwards them on the UI wire. A field that is absent means what it meant in 2.2.
| Wire frame and field | Canonical source on fullStream | Wire versions | Notes |
|---|---|---|---|
text-delta.providerMetadata | TextDeltaPart.providerMetadata (new) | v1 and v2 | The text's provider round-trip data, such as a Gemini thoughtSignature on a plain text part. It often arrives on a final chunk whose text is empty. applyUIPart merges it onto the UI text part, and assistantMessageFromTurn and canonicalFromUI send it back with the text. Never display it. |
reasoning-delta.id, .encrypted, .redacted, .source, .data | ReasoningDeltaPart.id, .redacted, .source (new) and .encrypted (older; the wire dropped it before 2.3) | v1 and v2 | id names the block, and deltas of one block share it. encrypted (OpenAI) and redacted (Anthropic redacted_thinking) mark an opaque payload: the frame carries it in data and sends an empty text, so a reader that predates these fields and appends text shows nothing for the block. source is the provider family that produced the block. applyUIPart keeps one UI part per block: a new id opens the next block, an opaque block is always a part of its own, and the payload is read back from data. |
tool-call.providerMetadata | ToolCallPart.providerMetadata (new) | v1 and v2 | The providerMetadata of the call's tool_use part, such as a Gemini function call's thoughtSignature. applyUIPart keeps it on UIToolCall.providerMetadata when it is a plain object, so the history a chat client posts sends the call back as the provider produced it. Never display it. |
tool-result.outcome | ToolResultStreamPart.outcome (new) | v1 and v2 | The ToolCallOutcome of an error result: 'tool-error', 'permission-denied' or 'approval-denied'. A successful result carries none. |
tool-state.outcome | ToolStatePart.outcome (new) | v2 only | Set with 'awaiting-approval' ('approval-pending') and with a terminal state: 'error': the outcome of the call's tool-result, 'permission-denied' for the call the execution policy refused (the run then ends with an error part), or 'unknown-effect' for each call a native resume stops on for reconciliation. A call that a refused or cut-off step never dispatched ends in 'error' without one. tool-state is a v2 part, so a v1 client never sees it. applyUIPart keeps the latest tool-state or tool-result value on UIToolCall.outcome: a frame without one, or with a value the client does not know, clears it. |
step-finish.stopDetails | StepFinishPart.providerMetadata (new), read as on finish | v1 and v2 | The step's StopDetails. applyUIPart does not keep it: AssistantTurnState.steps holds usage and finish reason only. |
finish.stopDetails | FinishStreamPart.providerMetadata (anthropic.stop_details, openai.refusal) | v1 and v2 | The provider's explanation of an early stop, normalized like StreamChatResult.stopDetails: { type, category?, explanation? }, from a Claude refusal, or an OpenAI refusal's text as explanation (both with finishReason: 'content_filter'). The explanation passes the secret redactor. Absent when the provider explained nothing. applyUIPart keeps it on AssistantTurnState.stopDetails. |
tool-approval-request.approvalId | ToolApprovalRequestPart.approvalId | v1 and v2 | A changed value, not a new field: the tool call id, @, and 24 hex digits of a digest that binds the approval to the call it was issued for (the tool, the arguments and the sub-agent path, and in a durable or native run the run and the step it suspended at). Treat it as opaque and send it back unchanged on the verdict. A verdict that names only the tool call id still counts when the run used that id once (approval binding). |
warning.warning.type: 'unpriced-model' | WarningPart.warning | v2 only | A new value of the open CallWarning['type'] union: a model without a price ran under a USD budget. Reported once per model in each leg of a run, so a resumed leg reports it again. |
useChat (@deuz-sdk/react) reads these fields; the frozen @deuz-sdk/core/react hook ignores them.
Wire versions in 2.3
A response negotiated to v1 still has no id: lines and no v2-only part. It is not byte-identical to 2.2 output any more: the fields above marked "v1 and v2" ride parts a v1 client already receives (text-delta, reasoning-delta, tool-call, tool-result, step-finish, finish), and approvalId has its new form. A client that does not know a key ignores it, so a 2.2 reader keeps working. tool-state.outcome, the 'unpriced-model' warning and the native agent frames below ride v2-only parts.
Native agent frames (2.3)
toDeuzAgentStreamResponse serializes a native run (streamAgent, resumeStreamAgent). Its ordinary parts go out as above. What only a native run has goes out as data-agent parts on the existing data-${string} member: { type: 'data-agent', id: <kind>, payload: { kind, seq, … } }. The id is the frame's kind, so a reader that knows nothing of agent frames reconciles each kind in place.
payload.kind | Fields | When |
|---|---|---|
run | seq, runId?, status: 'running' | The first frame of every leg, fresh or resumed. runId is the serializer's runId option. |
phase | seq, phase: 'running' | 'finalizing' | The run entered the tool loop or the finalization. |
partial | seq, attempt, value | A draft of the structured output. Never validated. |
element | seq, attempt, index, value | An array element the run's element validator accepted. Still a draft. |
result | seq, runId, status, usage?, modelSteps?, verification?, stopDetails?, and per status output (completed), pendingApprovals and pendingClientCalls (suspended), reason and unknownEffects? (stopped), or error (failed) | The leg's terminal state. It never carries the transcript, a raw tool result, a receipt or the accounting ledger. A failure's message is secret-redacted, and a failed run also gets one error part. |
seqis the frame's own SSE event id, the number on itsid:line. WithstoreandstreamId, every leg journaled under thatstreamIdnumbers on from the last stored event, soseqgrows across the whole run and is the cursor a replay resumes after. Without a store, each response numbers from 0.- v2 only.
data-*parts are v2 parts, so a client that negotiated v1 gets the run's ordinary parts and no frame. - A refused leg is detached. A leg the run refuses before it begins (another executor holds the run, another scope, an unknown run, invalid options) goes out on its own response with no
id:lines and is not journaled. Its frames still number from 0, and fold on their own. - Folding.
agentStateFromPartsandapplyUIPart(intoAssistantTurnState.agent) apply a frame only when itsseqis above the state's, so a replay or an out-of-order frame applies nothing twice. A frame of another run, later in the sequence, starts that run's state. Adata-agentpart whose payload is not a well-formed frame (a malformed one, or an app's owndata-agentdata) changes no agent state:agentStateFromPartsskips it, andapplyUIPartkeeps it as an ordinary data part.
See Native agent runs for the route and client code.
The 2.3 wire fields are pinned by test/ui-display.test.ts, test/chat-reasoning.test.ts, test/ui-text-signature.test.ts and test/ui-agent.test.ts in packages/core.
Additive parts in 2.4
2.4 adds no member to StreamPart and one to DeuzUIPart, and new values of the open CallWarning['type'] union. With none of the 2.4 options set, and for every request that does not ask for the handshake, the wire is what 2.3 sent, byte for byte.
| Wire frame | Source | Wire versions | Notes |
|---|---|---|---|
capabilities | The serializer, when the route passes capabilities and the request asked for the handshake | v2 only | { type: 'capabilities', capabilities: string[] }, the same list as the x-deuz-capabilities response header. The first frame of the response, with no id: line, never journaled and never replayed as a stored frame. applyUIPart ignores it; useChat reads it. See the capability handshake. |
warning.warning.type: 'stream-store-error' | The serializer, when a legacy StreamStateStore fails (H1, H2) | v2 only | The frames still reach the client, but that response is no longer journaled and cannot be resumed. The store's own error goes to onStoreError only, never to the client. |
warning.warning.type: 'embedding-mismatch', 'unaccounted-usage', 'context-source-failed', 'memory-extraction-partial' | WarningPart.warning | v2 only | New values of the open union. A run that reports one on its stream sends it as a warning frame like any other warning. |
A native V3 run's eventsV3 (context, working-memory and memory-intent) are not serialized: toDeuzAgentStreamResponse frames an AgentStreamV3 as it frames an AgentStream, so compiled context, retrieval results and memory intents never reach the UI wire.
The frozen @deuz-sdk/core/react hook ignores the 2.3 and 2.4 additions and is deprecated in favour of @deuz-sdk/react; it will be removed in 3.0.
The capability handshake (2.4)
The handshake tells a 2.4 client which chat and stream features the route behind it supports. It is separate from the v1/v2 negotiation and does not change it.
- A client lists what it understands in the
x-deuz-capabilitiesrequest header.useChatsendschat-snapshot-v1, durable-stream, message-idson every POST and on a resume read from a URL endpoint, unlesscapabilityHandshake: false. - A route that passes
capabilities: { request, announce }totoDeuzStreamResponse,toDeuzObjectStreamResponse,toDeuzAgentStreamResponse,createDeuzStreamorresumeDeuzStreamResponseanswers a request that listed anything with the same response header, on any wire version, and on v2 with the firstcapabilitiesframe. The list isannounceplus what the serializer knows itself:durable-streamfor a durable response, or for a resume over aDurableStreamStateStore. Values are lower-case tokens, each once, sorted and joined by', '; another value throws aTypeErrorwhen the response is built. - A request without the header, which is every 2.3 client, gets no header and no frame.
- The client uses a feature only once the server announced it.
| Capability | DEUZ_CAPABILITY key | What the route promises |
|---|---|---|
message-ids | messageIds | Every stored message reaches the UI with its stable id (ChatRecord.messageIds), so a client dedupes a replay by id |
chat-snapshot-v1 | chatSnapshot | The route accepts the messageIds a client sends with messages, aligned with them, and stores them as ChatRecord.messageIds |
durable-stream | durableStream | Frames are committed to a DurableStreamStateStore before they are sent, so a replay from Last-Event-ID has no gap and repeats no frame |
conditional-chat | conditionalChat | Chat writes are compare-and-set on a revision (ConditionalChatStore) |
parseDeuzCapabilities(source) reads the set from a Request, Response, Headers or header value, and formatDeuzCapabilities builds a header value. A browser reads a cross-origin response header only when the server exposes it with Access-Control-Expose-Headers; otherwise the client reads the first frame.
Durable responses (2.4, preview)
A response with durable: { store, lease } and a streamId keeps the 2.3 frame format, id: numbering and replay through resumeDeuzStreamResponse. What changes is the order: each frame is committed to the DurableStreamStateStore before it is sent, in batches (20 ms or 16 KiB by default), by one producer fenced by its lease token under stream:<streamId>. When the producer is fenced off, the stored log moved past it, or the store fails, the response sends nothing it did not commit, ends with a redacted error frame without an event id and without [DONE], and hands a thrown store error to onStoreError. A client that reconnects to the resume endpoint gets every committed frame once, in order.
The 2.4 wire behaviour is pinned by test/ui-capabilities.test.ts, test/internal-capabilities.test.ts, test/ui-durable.test.ts and test/stream-durable-store.test.ts in packages/core.
Run the focused protocol suite with npm run test:protocol.
Support Matrix
How far each model row and protocol that 2.3 added is verified — documented, fixture-tested, live-verified — kept apart, and what the 2.4 stores and runners were tested against.
Provider Conformance
The shared request, stream, usage, finish, and error behavior every provider adapter must satisfy.