Streaming Protocol
This page documents the bytes on the wire: the HTTP surface that createAgentRouter(agent) serves for each agent conversation. Mounting, authentication, and CORS are application decisions covered in the Routing guide; the SDK client wraps this protocol so most applications never speak it by hand.
The protocol is identical on the Node.js and Cloudflare targets — both dispatch into the same handlers, so every request shape, response shape, header, and error below applies to both.
All routes are relative to wherever the router is mounted, plus the caller-chosen conversation id:
POST /:id— deliver one message (202 admission).GET /:id— read the conversation:view=history(default) returns one materialized snapshot;view=updatesreturns incremental chunks, optionally live via long-poll or SSE.HEAD /:id— stream metadata as headers, no body.POST /:id/abort— abort in-flight and queued work.GET /:id/attachments/:attachmentId— attachment byte download.
:id must be a non-empty path segment; an empty or whitespace-only id is rejected with invalid_request (400). Any method not listed for a route is rejected with method_not_allowed (405) and an Allow header. Requests to a conversation that has never received a message return stream_not_found (404) on every read route — the conversation and its stream are created by the first admitted POST.
Offsets
An offset is a resume token addressing a position in the conversation’s durable record stream:
0000000000000000_0000000000000003
- The format is two 16-digit zero-padded integers joined by
_(the Durable Streams offset format). The first component is always0in Flue. -1is the sentinel for “before the first batch”: reading from-1replays the whole conversation.- Offsets address durable record batches, not messages. One message delivery typically produces many batches, so offsets advance faster than messages appear. A batch whose records are all internal projects to zero chunks, so an updates response can be empty while
Stream-Next-Offsetadvances. - Treat offsets as opaque: obtain them from responses (the
offsetfield of an admission or snapshot, theStream-Next-Offsetheader, an SSEcontrolevent) and pass them back verbatim. Do not construct or interpret them. - Reads are exclusive: a read at offset
Xreturns data recorded afterX. - An offset past the current stream head fails the read with
conversation_stream_store_failure(500). A malformed offset (anything other than-1or the two-component form) is rejected withinvalid_request(400).
Coordination headers
Three response headers coordinate reads across requests:
Stream-Next-Offset— the offset to resume from. Present on the 202 admission response, snapshot responses, non-SSE updates responses, andHEAD. In SSE mode the same value ridescontrolevents instead.Stream-Up-To-Date— literallytruewhen the response reached the durable head at the time it was produced. Absent (neverfalse) when more data was already available; keep reading fromStream-Next-Offset. Alwaystrueon snapshot andHEADresponses.Location— on the 202 admission response only: the conversation’s stream URL (mirrors the body’sstreamUrl), following the Durable Streams stream-creation convention.
Browsers do not expose these headers cross-origin unless your CORS middleware lists them in Access-Control-Expose-Headers; see CORS.
POST /:id — message admission
Delivers one message into the conversation. The body is JSON (Content-Type: application/json) and is the same DeliveredMessage shape a server-side dispatch(...) admits, with two optional reserved top-level siblings. @flue/sdk exports DeliveredMessage and DeliveredAttachment.
type PromptBody = DeliveredMessage & {
initialData?: unknown;
uid?: string | null;
};
type DeliveredMessage =
| { kind: 'user'; body: string; attachments?: DeliveredAttachment[] }
| {
kind: 'signal';
type: string;
body: string;
attributes?: Record<string, string>;
tagName?: string;
};
type DeliveredAttachment = {
type: 'image';
data: string; // base64
mimeType: string;
filename?: string;
};
kind: 'user'— a direct user chat turn.attachmentsaccepts images only;datais base64 and limited to 14,680,064 characters (14 × 1024 × 1024) — longer data is rejected withinvalid_request(400).kind: 'signal'— a structured event delivery.typemust be non-empty.bodyis a plain string; JSON-stringify structured payloads yourself.tagNamemust be a valid XML tag name (^[A-Za-z_][A-Za-z0-9_.-]*$); it is rendered unescaped as the signal’s envelope in model context, so looser values are rejected withinvalid_request(400).initialData— instance-creation data, consulted only when this send creates the conversation. When the agent declares aninitialDataschema, a creating send is validated against it; mismatches are rejected withinvalid_request(400) before anything durable is admitted.uid— send condition. A string delivers only to the incarnation with that uid: an absent instance or a mismatched uid is rejected withagent_instance_not_found(404).nullcreates only when no instance exists: an existing instance is rejected withagent_instance_exists(409), whosedetailsnames the existing uid. Omitted sends deliver unconditionally. Combining a stringuidwithinitialDatais a contradiction (the condition forbids creation) and is rejected withinvalid_request(400). Failed conditions leave nothing durable behind.
The bare-string shorthand that dispatch(...) accepts is not part of the wire: the body must be the object shape, or the request is rejected with invalid_request (400).
The route also accepts the W3C trace-context request headers traceparent and tracestate; a valid traceparent links the admitted submission to the caller’s distributed trace.
Admission response
Admission is fire-and-forget: the server responds 202 Accepted once the message is durably admitted, before the agent runs.
{
streamUrl: string; // the conversation's read URL (this URL, query stripped)
offset: string; // durable head at admission
submissionId: string; // identity of this delivery's settlement
uid?: string; // the instance uid, when one exists
}
streamUrl— derived from the request URL with the query string removed. Mirrored as theLocationheader.offset— the conversation’s durable head at admission, after the message itself was recorded. An updates read from this offset observes everything the agent produces in response, without replaying history or the admitted message. Mirrored as theStream-Next-Offsetheader.submissionId— matches thesubmission-settledchunk and the snapshot’ssettlementsentries, so a client can await this specific delivery’s outcome.uid— present when the instance has a uid: minted when this send created the instance, echoed when it continued one. Absent for instances created before uids existed.
There is no synchronous “wait for the reply” mode: any ?wait query parameter is rejected with invalid_request (400). Read the outcome from the conversation stream, or use the SDK’s wait(...).
A body that is not JSON is rejected with unsupported_media_type (415) when the Content-Type is wrong, or invalid_json (400) when the JSON is unparseable.
GET /:id?view=history — snapshot
Returns one materialized snapshot of the conversation: every message reduced to complete, render-ready parts. view=history is the default; a plain GET /:id is the same request. Any view value other than history or updates is rejected with invalid_request (400), as is combining view=history with offset, tail, or live.
Response: 200, Content-Type: application/json, Cache-Control: no-store, Stream-Next-Offset set to the snapshot’s offset, Stream-Up-To-Date: true.
FlueConversationSnapshot
@flue/sdk exports the snapshot shapes as FlueConversationSnapshot, FlueConversationMessage, FlueConversationPart, and FlueConversationSettlement.
interface FlueConversationSnapshot {
v: 1;
conversationId: string;
offset: string;
messages: FlueConversationMessage[];
settlements: FlueConversationSettlement[];
}
interface FlueConversationSettlement {
submissionId: string;
outcome: 'completed' | 'failed' | 'aborted';
error?: unknown;
}
interface FlueConversationMessage {
id: string;
role: 'user' | 'assistant' | 'system';
purpose: 'user' | 'assistant' | 'dispatch' | 'advisory';
display: 'visible' | 'hidden' | 'diagnostic';
submissionId?: string;
turnId?: string;
signal?: { tagName?: string; attributes?: Record<string, string> };
parts: FlueConversationPart[];
metadata?: Record<string, unknown>;
}
type FlueConversationPart =
| { type: 'text'; text: string; state: 'streaming' | 'done' }
| { type: 'reasoning'; text: string; state: 'streaming' | 'done' }
| { type: `data-${string}`; data: unknown }
| { type: 'file'; mediaType: string; id?: string; size?: number; url?: string; filename?: string }
| { type: 'dynamic-tool'; toolName: string; toolCallId: string; state: 'input-available'; input: unknown }
| { type: 'dynamic-tool'; toolName: string; toolCallId: string; state: 'output-available'; input: unknown; output: unknown; durationMs?: number }
| { type: 'dynamic-tool'; toolName: string; toolCallId: string; state: 'output-error'; input: unknown; errorText: string; durationMs?: number };
v— snapshot schema version; currently1.offset— the durable head through which this snapshot was reduced, including batches that project to no visible message. Resuming an updates read from it yields exactly the changes after this snapshot.messages— the conversation transcript in order. One assistant message represents one whole response: every model step of a submission folds into the submission’s first assistant message, with parts accumulating across steps.settlements— the terminal outcome of every settled submission on this conversation.errorcarries the caller-safe error value forfailed/abortedoutcomes.role/purpose/display—roleis the coarse render lane;purposeclassifies semantics (dispatch= delivered signals,advisory= runtime advisories);displayis the visibility hint (visibleprimary chat,diagnosticactivity-panel material,hiddenplumbing).signal— present only onsystem-role messages projected from signal deliveries; carries the deliveredtagNameandattributes.metadata— entirely agent-authored (response-metadata hooks). The runtime stamps nothing; keys likeusageormodelare application conventions.parts—text/reasoningcarrystate: 'streaming'while a live response is mid-stream and'done'once complete.data-<name>parts are named client data writes, one part per write, in emit order.fileparts reference attachments byid;urlis never set by the server (the runtime does not know the public mount — the SDK resolves it client-side, andGET /:id/attachments/:attachmentIdis the underlying route).dynamic-toolparts progressinput-available→output-available/output-error;durationMsis the tool-handler execution time, absent on outcomes recorded before the field existed.
The snapshot covers exactly one conversation per agent instance: the default root conversation. Child conversations (subagent tasks and other internal sessions) are never exposed through this surface. The canonical durable record schema is likewise never exposed — snapshots and update chunks are the only read formats on the wire.
GET /:id?view=updates — updates
Returns the conversation changes after an offset, as a JSON array of update chunks.
Query parameters:
offset— required, exactly once:-1or a previously returned offset. Missing, repeated, or malformed values are rejected withinvalid_request(400).live— optional:long-pollorsse. Any other value is rejected withinvalid_request(400). Omitted = return immediately with whatever is available.tail— not supported on this surface; rejected withinvalid_request(400). A stream suffix can omit message starts, compaction boundaries, and earlier deltas, so it cannot be projected safely.
Without live, the response is 200, Content-Type: application/json, Cache-Control: no-store, with Stream-Next-Offset and (when the read reached the head) Stream-Up-To-Date: true. The body is a chunk array — empty when nothing was recorded after offset.
One response covers at most 100 durable batches (a fixed server page size; there is no wire parameter to change it). When Stream-Up-To-Date is absent, more data was already available: issue the next read from the returned Stream-Next-Offset.
Chunks are deltas against the conversation state at offset. Resume only from an offset whose state you hold — a snapshot’s offset, or -1 (a fresh read begins with a conversation-reset carrying a full snapshot). When local state is lost, request a fresh snapshot instead of guessing.
Serving an updates read reconstructs the conversation’s reduced state through offset before projecting — there is no persisted replay cache — so the setup cost of each read or reconnect grows with the total length of the conversation’s durable stream. Applications with very large conversations should measure reconnect latency and avoid unnecessary reconnect loops.
live=long-poll
Identical to a plain updates read when data is available at offset — the response returns immediately. When nothing is available, the server holds the request until new data arrives or a 30-second window elapses, whichever is first:
- New data →
200with the chunk array, as above. - Timeout →
200with an empty array[],Stream-Next-Offsetunchanged,Stream-Up-To-Date: true. Re-issue the request to continue waiting. - Client disconnect while parked → the response is discarded with status
499and no body.
live=sse
Holds the connection open indefinitely and pushes chunks as server-sent events. Response: 200, Content-Type: text/event-stream, Cache-Control: no-cache.
event: data
data:[{ "type": "message-delta", ... }, ...]
event: control
data:{"streamNextOffset":"0000000000000000_0000000000000007","upToDate":true}
: heartbeat
dataevents — a JSON array of chunks, one event per read cycle. Emitted only when the cycle produced chunks.controlevents — stream coordination in the body, since headers cannot update mid-stream:streamNextOffset(string) andupToDate(present astrueonly when caught up). Emitted after every read cycle, including empty ones, so a caught-up stream still produces acontrolevent at least every 30 seconds. Reconnect from the laststreamNextOffsetafter a disconnect.: heartbeatcomment lines — every 15 seconds, keeping intermediaries from timing out the idle connection.
The stream never ends server-side; it runs until the client disconnects. SSE is an at-least-once transport across reconnects — dedupe chunks by position (below).
ConversationStreamChunk
Every chunk carries a type, the conversationId, and a position. @flue/sdk exports the union as ConversationStreamChunk (for first-party presenters; application code should consume the SDK’s materialized observe() state instead).
type ConversationStreamChunk = ChunkBody & { position: { batch: number; index: number } };
type ChunkBody =
| { type: 'conversation-reset'; conversationId: string; snapshot: FlueConversationSnapshot }
| { type: 'message-appended'; conversationId: string; message: FlueConversationMessage }
| { type: 'message-started'; conversationId: string; messageId: string; submissionId?: string; turnId?: string; metadata?: Record<string, unknown>; timestamp?: string }
| { type: 'message-metadata'; conversationId: string; messageId: string; metadata: Record<string, unknown> }
| { type: 'data-part'; conversationId: string; messageId: string; name: string; data: unknown }
| { type: 'message-delta'; conversationId: string; messageId: string; kind: 'text' | 'reasoning'; delta: string }
| { type: 'tool-input'; conversationId: string; messageId: string; toolCallId: string; toolName: string; input: unknown; timestamp?: string }
| { type: 'tool-output'; conversationId: string; toolCallId: string; output: unknown; durationMs?: number; timestamp?: string }
| { type: 'tool-output-error'; conversationId: string; toolCallId: string; errorText: string; durationMs?: number; timestamp?: string }
| { type: 'message-completed'; conversationId: string; messageId: string; timestamp?: string }
| { type: 'submission-settled'; conversationId: string; submissionId: string; outcome: 'completed' | 'failed' | 'aborted'; error?: unknown; timestamp?: string };
position— a monotonic ordering token:batchis the durable batch ordinal the chunk was projected from,indexits position within that batch’s projection.{ batch, index }is globally unique and ordered across the conversation; compare lexicographically (batch, thenindex) to dedupe redelivered chunks. Otherwise opaque — do not interpret the numbers.conversation-reset— replace all accumulated state with the embedded snapshot. Emitted when a batch contains a structural boundary (conversation creation, compaction); the reset subsumes every other chunk of its batch, so a fresh read fromoffset=-1begins with one. The embedded snapshot may already contain settlements — checksnapshot.settlementsas well assubmission-settledchunks when awaiting an outcome.message-appended— a complete message (user turn or system signal), in the same message format as the snapshot.message-started— an assistant response opened.metadatacarries agent-authored response metadata available at start. Assistant chunks are pre-coalesced: every model step of a submission addresses the submission’s first assistantmessageId, so accumulating parts permessageIdreproduces the snapshot’s one-message-per-response shape. A latermessage-startedfor an already-openmessageIdis a continuation, not a new message.message-metadata— agent-authored metadata for an open response; merge onto the message.data-part— one named client data write; append adata-<name>part.message-delta— streamedtextorreasoningcontent; append to the open part of thatkind, opening one if none is open. Akindchange ormessage-completedcloses the open part.tool-input/tool-output/tool-output-error— tool-call lifecycle, correlated bytoolCallId. Input arrives on the assistant message; outputs update the matchingdynamic-toolpart.message-completed— the assistant response closed; mark streaming partsdone.submission-settled— the terminal outcome of one submission, matching the admission response’ssubmissionId.timestamp— capture time (ISO 8601) of the underlying durable record, present on boundary chunks (message-started,tool-input,tool-output,tool-output-error,message-completed,submission-settled).message-deltadeliberately omits it for wire weight; interpolate between stamped boundaries.
HEAD /:id
Returns stream metadata as headers with no body: Content-Type: application/json, Cache-Control: no-store, Stream-Next-Offset (the current durable head), Stream-Up-To-Date: true. A missing stream returns 404 with error headers and no body.
POST /:id/abort
Aborts all durable work for the conversation: the running submission and everything queued behind it. No request body is required. Response: 200, JSON body:
{ aborted: boolean }
aborted—truewhen in-flight or queued work existed and is now being aborted;falsewhen the conversation was idle. Abort records a durable intent and returns immediately — the affected submissions settle to theabortedoutcome asynchronously. Observe the settlement viasubmission-settledchunks or the snapshot’ssettlements.
Methods other than POST are rejected with method_not_allowed (405), Allow: POST.
GET /:id/attachments/:attachmentId
Serves one attachment’s bytes. :attachmentId is the id of a file part; URI-encode it. Lookups are scoped to the conversation’s default root conversation — attachments belonging to child conversations are never served and return attachment_not_found (404), as does an unknown id. A conversation that does not exist yet returns stream_not_found (404).
Response: 200 with the raw bytes and:
Content-Type— the attachment’s stored MIME type.Content-Length— the byte size.Content-Disposition: inline.Cache-Control: private, max-age=31536000, immutable— attachment content is digest-keyed and immutable, so clients may cache indefinitely.Content-Security-Policy: sandbox— the MIME type is uploader-controlled, so direct navigation is neutralized as an opaque origin;<img>/<a>sub-resource loads are unaffected.
Methods other than GET are rejected with method_not_allowed (405), Allow: GET.
Error responses
Every error on this surface renders the canonical Flue envelope with Content-Type: application/json:
{
error: {
type: string; // stable machine-readable category
message: string;
details: string;
dev?: string; // local development only
meta?: Record<string, unknown>;
};
}
Branch on type; message prose is not API. The type codes, statuses, and field semantics are documented in the Errors Reference. Statuses used by this surface: invalid_request and invalid_json (400), agent_instance_not_found and stream_not_found and attachment_not_found (404), method_not_allowed (405), agent_instance_exists (409), unsupported_media_type (415), runtime_unavailable (503, local dev reloads, with Retry-After), and internal_error or conversation_stream_store_failure (500). Unknown server failures never leak their original message — they render as a generic internal_error.
Fixed response headers
Every read and error response carries two browser security headers: X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: cross-origin.
The protocol sets no other cross-cutting headers by design:
- No
Access-Control-*headers — CORS is application middleware; see CORS, including which coordination headers to expose. - No authentication challenges — protecting a mount is application middleware; see Protecting your agents.
- No cache validators (
ETag,Last-Modified) on conversation reads — offsets are the resume mechanism, and conversation responses areno-store.