Skip to content

Stream wire contract v2 (WE MR C1) ​

This guide covers the release in which every Helix SSE surface (the CF Durable Object's /sse, the StreamServer DO's /sse, agent-server's /sse, core's ResumableStreamHandler) speaks one protocol, encoded and decoded by core, and a stream that ends without a terminal frame is a typed, retryable failure instead of a clean end. It is a major release of @helix-agents/runtime-cloudflare, @helix-agents/agent-server and @helix-agents/ai-sdk, and a minor (0.x) release of @helix-agents/core and @helix-agents/store-cloudflare. Nothing is deprecated: there is no back-compat.

Protocol reference: Stream wire. The user's rulings: a–c (2026-10-09), D-4 (2026-10-11).

Deploy writers and readers together ​

A v1 producer and a v2 consumer (or the reverse) do not interoperate: a v2 reader ignores the v1 error frame and then reads the body's end as a drop (StreamDropError, retried); a v1 reader does not know truncated, rewound or caught-up and treats a v2 end without its old fields as before. Roll out the packages that write a stream (runtime-cloudflare, store-cloudflare, agent-server, core's ResumableStreamHandler) and the ones that read it (runtime-cloudflare's clients and transports, store-cloudflare's DurableObjectStreamManager, core's HttpRemoteAgentTransport, ai-sdk's chat transport) in one deploy. For cross-service remote agents, upgrade the producer and the consumer services together.

Frame renames ​

v1v2
event: error { error, errorDetail?, recoverable? }event: fail { error, errorDetail?, recoverable }
fail / error with code: 'TRUNCATED', floorevent: truncated { floor, errorDetail } (terminal)
non-terminal truncated { truncatedAtStep, … }event: rewound { atStep, atSequence?, minSequence? }
the DO's : connected commentevent: caught-up { head }
StreamServer end { finalOutput }end { status, output?, state?, usage? }
ResumableStreamHandler end { streamId }, errorend { status } / fail; chunk data { sequence, chunk }

A hand-written SSE client should decode with core's createSSEFrameDecoder + decodeStreamFrame (or iterate parseSSEFrames) and classify the end with classifyStreamTerminal. ResumableStreamHandler keeps a caller-set eventName / serializeChunk for its chunk frames; its terminal frames are always the protocol's.

A cut stream is a drop, never a clean end (ruling a) ​

Every reader now throws the retryable StreamDropError when a body ends with no terminal frame (an eviction, a proxy dropping the connection, a crash), unless its own caller aborted the read (CP-102). Before, these readers ended cleanly and a consumer could not tell a drop from a finished run.

ReaderBeforeNow
DOStreamManagerClient.createResumableReaderdonethrows StreamDropError (re-attach)
DOFrontendExecutor handle stream()donethrows StreamDropError
DurableObjectStreamManager readersdonethrows StreamDropError
HttpRemoteAgentTransport / DOStubTransport.streamthe iterator endedthrows StreamDropError
ai-sdk createHelixChatTransporta clean finishthe UI stream errors with StreamDropError
agent-server streamAgent on an active streama synthesized end{completed}a recoverable transport_stream_drop fail

Retry by re-attaching from your cursor (fromSequence / the snapshot's streamSequence); never re-send the request that started the run. A rewound frame (a cleanup while you were attached) that leaves the reader behind the head throws StreamTruncatedError, as the in-process readers always did.

Reads that failed no longer read as "absent" ​

DOStreamManagerClient.getStreamInfo / getChunks* (CP-28) and DurableObjectStreamManager.createReader / createResumableReader / getChunks* return null / [] only when the stream does not exist (a 404, exists: false). Any other failure throws (DOPeerError / StreamConnectionError, retryable on a 5xx). DOStreamManager.getStreamInfo (the in-DO manager) returns null for a stream never created and for an id that is not the DO's own stream (RM-71).

Remote agents ​

  • TransportEvent's error variant is fail, and truncated{floor, errorDetail} is new. A RemoteAgentTransport you implement should decode with core decodeTransportEvents.
  • RemoteAgentFailedError carries the remote failure's errorDetail (new optional third argument), kept on every runtime's failed sub-agent result.
  • HttpRemoteAgentTransport and DOStubTransport send /start and /resume exactly once (CP-95, interim until D-2's idempotency key). A thrown request, or a 5xx that is not the host's typed retryable answer, rejects with the new non-retryable RemoteEntryOutcomeUnknownError (transport_remote_entry_outcome_unknown); check the remote session's status before acting. HttpTransportConfig.maxRetries now applies to the reads and stop requests only.
  • A remote dispatch re-attaches to a child it already started (any /status answer, or the sub-session ref's remote.streamId) and never re-POSTs /start (the D-2 guard); an interrupted child is a typed failure. After a /start whose outcome is unknown, it reads the child's status once (core readChildAfterUnknownStart) and attaches when the child answers; otherwise the call fails with transport_remote_entry_outcome_unknown.

Chat replies end with finishReason: 'error' (D-4) ​

A reply that forwarded a run error now ends with { type: 'finish', finishReason: 'error' } after the error chunk (AISDKFinishEvent.finishReason). Every supported ai version (6.0.0 and later) accepts it. If you assert on the exact chunk sequence of an errored reply, expect the reason.

Removed ​

  • parseDOSSEStream and its ParsedStreamChunk / ParsedStreamEnd / ParsedStreamFail / ParsedStreamTruncated / ParsedSSEItem / ParseDOSSEStreamOptions types and isParsed* guards (@helix-agents/ai-sdk/cloudflare): decode with core decodeStreamFrame.
  • Core parseNamedSSEEvent: use decodeStreamFrame.

Released under the MIT License.