Resumable Streams
A resumable stream lets a client reload the page, lose its connection, or reconnect after a server restart, and still render the conversation exactly once: no lost text, no duplicated text. Helix does this with two pieces that are read from the same commit:
- a snapshot: the committed conversation up to a cursor, read from one checkpoint;
- the stream after that cursor.
The client renders the snapshot and, while the run is live, resumes the stream from the snapshot's cursor.
Breaking: snapshots are checkpoint-anchored and content replay is removed
The content-replay and partial-content options and the server-side partial-content merge are gone, and so are the snapshot fields timestamp, startSequence and startMessageCount. A snapshot never carries uncommitted content: in-flight content arrives through the resumed stream. See the upgrade guide for the migration.
Key concepts
Sequence numbers
Every chunk a stream manager stores gets a monotonically increasing sequence number. Resumable readers yield each chunk with its sequence stamped on it as _sequence (StoredStreamChunk), and expose the last one they yielded as reader.currentSequence. A reader opened with fromSequence: N yields only chunks with sequence > N.
Stream status
| Status | Meaning |
|---|---|
active | A run is writing to the stream. |
paused | The run suspended at a human-in-the-loop boundary and committed it. |
ended | The run finished. A later run on the same session makes it live again. |
failed | The run failed. A reader ends with one error chunk carrying the error. |
The stream is shared by every turn of a session. A client should not decide "resume or not" from the stream status alone: use the snapshot's status, which the run decides (below).
Snapshots
A snapshot (FrontendSnapshot, built by buildSnapshot) is taken from one checkpoint: the session's latest commit. Every commit writes its message rows and its checkpoint atomically, so the rows and the cursor of one checkpoint always agree. Everything committed up to the cursor is in messages; nothing after it is.
| Field | Meaning |
|---|---|
messages | The committed rows [0, checkpoint.messageCount), converted to AI SDK UIMessage[]. Never a partial message, never capped. |
streamSequence | The cursor: the checkpoint's stream sequence. Resume with chunks seq > streamSequence. |
state | The custom state at the cursor (the checkpoint's state). Later state_patch frames apply on top of it. |
status | active / paused while the current run is live, else failed or ended (see below). |
resumeMessageId | Present when status is active or paused: the id of the UI message the current run's response streams into (generateId(run.startUIMessageCount)). |
latestTruncateCheckpointId | The session's latest truncate checkpoint when the snapshot was read (null if the log was never rewound). Used to skip a resync the snapshot already covers. |
errorDetail | Only when status is failed: the error that ended the turn, code / category / retryable kept, free text scrubbed. |
checkpointId, stepCount | The anchoring checkpoint (null / 0 when there is none yet). |
sessionId | The session. |
Snapshot status
The run decides, never the stream alone (core deriveSnapshotStatus):
- The current run is live (
running,suspended_client_tool,suspended_awaiting_children,suspended_step_partial):pausedwhen the run is suspended or the stream ispaused, otherwiseactive. - Otherwise, if the current run or the stream
failed:failed. - Otherwise:
ended. A stream leftactiveby a run that completed or was superseded isended.
A run is live from its run-start commit, before its stream is claimed. In that window the snapshot already holds the run's input rows while the stream still shows the previous turn's ended / failed (or does not exist yet). The snapshot reports active, and its cursor is the input commit's sequence, which precedes the run's first chunk. A client that resumes from it sees every chunk of the run once. A resume in this window waits for the run to go live (see The run-start window).
Consistent reads
buildSnapshot uses the store's own consistent reader when it has one (the Cloudflare DO reads everything in one transactionSync). Otherwise it uses core's fenced generic reader, readSnapshotView:
- Each pass reads the session, the stream status and the current run, then the latest checkpoint and its message prefix, then the session, stream and run again.
- A pass counts only if the tuple (derived status, current run id, latest truncate checkpoint id) did not change. Appends that land mid-pass are harmless: the prefix is bounded by the checkpoint read first.
- It makes up to 3 passes (25 and 50 ms apart), one after another. A pass has no time bound of its own: a slow pass whose tuple held is a correct view. If none is stable it throws
state_history_incompletewithreason: 'snapshot_unstable'(retryable). - One deadline bounds the whole read (default 10 000 ms;
snapshotReadDeadlineMsonbuildSnapshot's andhandleChatStream's deps). A read still running at the deadline stops issuing store calls and throws the same retryablesnapshot_unstable, its message naming the read it waited on (deadline@getCurrentRun). Raise it only when one whole read on your store can take longer;Infinitymeans no deadline, and any other value outside (0, 2 147 483 647] ms is avalidation_error. - A failed or short read throws
state_history_incompletewithreasonstore_error,corrupt_roworshort_read. buildSnapshotreturnsnullonly when the session does not exist.
Legacy mode
A session whose latest checkpoint was written before this release (kind: null), or that has no checkpoint, is read in legacy mode: the snapshot serves the complete live log with the stream head as its cursor, whatever its status. It applies until the session's first new commit. Drain running sessions before upgrading; paused ones are covered. (FrontendSnapshot has no legacy flag; the DO /snapshot wire carries legacy.)
The canonical client
This is the wiring used by examples/resumable-streams-nextjs, examples/nextjs-redis and examples/opennext-cloudflare-do.
Server routes
One chat route serves both sends (POST) and resumes (GET). The snapshot route answers typed errors.
// lib/handler.ts
import { handleChatStream, buildSnapshot, type UIMessage } from '@helix-agents/ai-sdk';
const deps = { executor, stateStore, streamManager, agent: MyAgent };
export const handleChat = (sessionId: string, messages: readonly UIMessage[], request: Request) =>
handleChatStream(deps, { sessionId, messages, request });
export const getSnapshot = (sessionId: string) => buildSnapshot(deps, { sessionId });// app/api/chat/[sessionId]/route.ts
export async function POST(req: Request, { params }: { params: Promise<{ sessionId: string }> }) {
const { sessionId } = await params;
const body = await req.json();
return handleChat(sessionId, body.messages ?? [], req);
}
// The AI SDK's reconnect (resume) is a GET with the resume headers and no body.
export async function GET(req: Request, { params }: { params: Promise<{ sessionId: string }> }) {
const { sessionId } = await params;
return handleChat(sessionId, [], req);
}// app/api/chat/[sessionId]/snapshot/route.ts
export async function GET(_req: Request, { params }: { params: Promise<{ sessionId: string }> }) {
const { sessionId } = await params;
try {
const snapshot = await getSnapshot(sessionId);
return snapshot ? Response.json(snapshot) : snapshotNotFoundResponse(); // 404
} catch (error) {
return snapshotErrorResponse(error); // 503 / 409 {error, code, cause?}
}
}snapshotNotFoundResponse and snapshotErrorResponse are exported from @helix-agents/ai-sdk on every host (@helix-agents/ai-sdk/cloudflare re-exports them); see Wire errors.
Client
'use client';
import { useMemo } from 'react';
import { DefaultChatTransport } from 'ai';
import {
prepareHelixChatRequest,
prepareHelixReconnectRequest,
snapshotResumeOptions,
} from '@helix-agents/ai-sdk/client';
import { useHelixChat } from '@helix-agents/ai-sdk/react';
import type { FrontendSnapshot } from '@helix-agents/ai-sdk';
export function ChatView({
sessionId,
snapshot,
}: {
sessionId: string;
snapshot: FrontendSnapshot<MyState>;
}) {
const resumeOptions = useMemo(() => snapshotResumeOptions(snapshot), [snapshot]);
const transport = useMemo(() => {
const api = `/api/chat/${sessionId}`;
return new DefaultChatTransport({
api,
prepareSendMessagesRequest: prepareHelixChatRequest({ api }),
// The chat route serves POST and GET on one path, so the reconnect must be
// pointed at it. It carries the cursor, the resume message id and the
// replay-prefix flag.
prepareReconnectToStreamRequest: prepareHelixReconnectRequest({
api,
resumeFromSequence: resumeOptions.resumeFromSequence,
existingMessageId: resumeOptions.existingMessageId,
replayPrefix: resumeOptions.replayPrefix,
}),
});
}, [sessionId, resumeOptions]);
const chat = useHelixChat({
id: sessionId,
transport,
messages: resumeOptions.messages, // the snapshot WITHOUT the resume message when live
resume: resumeOptions.resume, // true only for active / paused
resync: { snapshotUrl: `/api/chat/${sessionId}/snapshot`, snapshot },
});
if (chat.resyncError) return <RetryBanner error={chat.resyncError} />;
return <MessageList messages={chat.messages} />;
}snapshotResumeOptions(snapshot, { replayPrefix? }) returns { resume, resumeFromSequence?, existingMessageId?, replayPrefix?, messages }:
Snapshot status | Result |
|---|---|
active, paused | resume: true, resumeFromSequence: snapshot.streamSequence, existingMessageId: snapshot.resumeMessageId, replayPrefix: true, and messages = the snapshot without the resumeMessageId message. |
ended, failed | resume: false, messages: snapshot.messages. No SSE request is made. |
With { replayPrefix: false }, or when a live snapshot has no resumeMessageId, the result keeps every snapshot message and omits replayPrefix.
On the wire the resume is a GET to the chat route with these headers:
| Header | Value |
|---|---|
X-Resume-From-Sequence | The snapshot cursor (Last-Event-ID is accepted as a fallback). |
X-Existing-Message-Id | resumeMessageId: the message the resumed stream writes into. |
X-Helix-Replay-Prefix | 1 when the client removed that message from its seed (C-1, below). |
prepareHelixReconnectRequest sets them from its options, which accept a value or a getter. Per-request headers win over the configured values, so chat.resumeStream({ headers: resumeHeaders(target) }) resumes at any ResumeTarget ({ resumeFromSequence, existingMessageId?, replayPrefix? }). resumeHeaders sends X-Helix-Replay-Prefix only when both replayPrefix and existingMessageId are set.
Why the resume message is removed (C-1)
ai 6.0.281 starts a resumed response from an empty message that replaces the last one. ai ≤ 6.0.230 continue the last message instead. So:
- a chat seeded with the whole snapshot and resumed from the cursor loses the in-flight message's committed steps on 6.0.281;
- a chat seeded with the whole snapshot that also gets the committed prefix replayed duplicates it on ≤ 6.0.230.
The canonical client removes the in-flight message and sends X-Helix-Replay-Prefix: 1. The server then:
- reads one snapshot with
buildSnapshotandHandleChatStreamDeps.snapshotOptions; - replays that message's committed parts under the same id, using only
ai6.0.0 chunk types, between transientdata-helix-replay{ phase: 'begin' | 'end', messageId }markers (the message's committed metadata rides onstart); - streams the tail after that snapshot's cursor (never below the run's start), not the client's cursor, so the prefix and the tail never overlap or leave a gap.
This is identical on every supported ai version, also for a message that spans runs (a human-in-the-loop continuation) and when early chunks were trimmed from the stream. Pass the same snapshotOptions your snapshot route gives buildSnapshot (generateId, includeReasoning, filterHidden, includeToolResults), or the replayed message will not match the one the client removed.
Only a client-supplied existingMessageId is replayed; an id the server derives was never removed from anything. An id the snapshot does not hold, unless it is the live run's own response that has not committed yet, answers 409 { code: 'state_not_resumable', cause: 'resume_message_unknown', retryable: true }: re-read the snapshot and resume from it. A failed prefix read answers a retryable 503 with the error's code.
Replayed tools and approvals
useHelixChat tells replayed tool calls from new ones by the data-helix-replay markers:
- a replayed tool call is never handed to
onToolCalland never dispatched by the client-tool handlers, and its committed result is never sent again; - a replayed client tool that is still pending (awaiting the browser) is dispatched once, after the replay ends, and its output is sent;
- an answered approval replays as its answer and is never asked again: approved →
input-available(the live stream delivers the output), denied →output-denied. Approval ids are${runId}::${toolCallId}.
Clients that cannot suppress replayed tools
A plain useChat, or useHelixChat given a pre-built Chat instance, cannot keep replayed client tools from running again. Such a client must seed the whole snapshot and resume without the flag:
const resumeOptions = snapshotResumeOptions(snapshot, { replayPrefix: false });It then gets the whole snapshot plus the tail, which is lossy on ai 6.0.281. The resync hooks do this automatically for such a chat, with a development warning.
The whole snapshot carries the in-flight message's answered client tools. They are history, never to be sent again (CP-107): a pre-built Chat passes its seed to its predicate, and useHelixChat does the same for the messages it creates its chat with.
const chat = new Chat({
transport,
messages: resumeOptions.messages,
sendAutomaticallyWhen: createHelixSendAutomaticallyWhen({ history: resumeOptions.messages }),
});Fetching the snapshot from the browser
fetchSnapshotWithRetry(url, options?) (from @helix-agents/ai-sdk/client and /react) retries a 5xx, a 429 or a network failure: 4 fetches in total, with 250 / 500 / 1000 ms backoff (backoffMs, sleep and fetch are injectable).
- It returns the snapshot, or
nullfor a 404 (no such session). - It throws
SnapshotUnavailableErrorafter exhaustion, at once for another 4xx, and at once for a 200 whose body is not JSON (reason: 'invalid_json') or not a snapshot (reason: 'invalid_body'). The error carries the server'scode, itscauseasreason, and the last HTTPstatus. signalaborts the fetch and any pending retry with anAbortError, which is never retried.
After exhaustion, render an explicit error state with a retry control. Never render an empty ended conversation: it looks like lost history and invites a send into a session that may still be running. The examples' SnapshotGate component does this for both the server-side read and the browser retry. The React hooks (useResumableChat, useCheckpointSnapshot, the resync re-anchor) all fetch through fetchSnapshotWithRetry.
When a resume fails
A resume that fails (a 409 state_not_resumable, a 503, a network error) ends the chat with status 'error' and the response body in chat.error (parseHelixChatError(chat.error) reads it). The chat was seeded without the in-flight message, and nothing restores it on its own: until the client re-anchors, that message is not shown.
Re-anchor on the latest snapshot. It stops the chat's stream, fetches the snapshot (with retries), sets its messages and, when it is still live, resumes from its cursor; an ended snapshot is seeded whole:
import { chatResyncControl, reanchorOnSnapshot } from '@helix-agents/ai-sdk/client';
async function recover() {
await reanchorOnSnapshot({
snapshotUrl: `/api/chat/${sessionId}/snapshot`,
control: chatResyncControl(chat), // `chat` is the useHelixChat result
setMessages: chat.setMessages,
});
}reanchorOnSnapshot throws the snapshot fetch's SnapshotUnavailableError (render the error state), or SnapshotNotFoundError (state_session_not_found) for a 404. A failure of the resume it starts is reported through onResumeError.
When a send's stream drops
A send's reply streams back on the send's own response. When that request fails while the run goes on (the browser's connection dropped, TypeError: network error; the server's own error event once its stream reader gave up; or fetch itself failed), the chat ends with status 'error'. useHelixChat with resync re-attaches on its own, through the re-attach controller (createReattachController). The controller decides from what the chat's controlled transport recorded about the failed request (HelixStreamControl.connections) and from the latest snapshot, never from the rendered status or an error's name or text:
- A send re-attaches after a drop, the server's in-band error or a failed
fetch, and only when the server holds that turn (the snapshot has the chat's user turns and the same text at its last user turn). It re-anchors on the snapshot, which resumes the run from its cursor while it is live. When the server does not hold the turn (the send never committed), it changes nothing: the message and the error stay, andretry()re-sends it with its own request key. - A refusal the server answered before any stream (any non-2xx), a chunk
aicould not parse, a stop and the app's own throw fromonToolCall/onDatastaychat.error, exactly as on stockuseChat. - A run that ended before the re-attach read its snapshot is drawn from it and the error is cleared.
- Re-attaches are budgeted: at most 5 in a row, none after one whose resumed stream delivered nothing. Then one
ReanchorExhaustedErroris reported (resyncError,resync.onError). - A re-attach stops writing once the app sends again, the chat id changes or the hook unmounts.
It never sends on its own.
On a plain stock useChat (no Helix chat hook), add the same re-attach with one hook. It needs the withHelixStreamControl(...) transport the chat uses, as the SAME instance (any other transport is reported as a ConfigurationError). Details: useReanchorOnError.
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useReanchorOnError, withHelixStreamControl } from '@helix-agents/ai-sdk/react';
const transport = useMemo(
() => withHelixStreamControl(new DefaultChatTransport({ api: `/api/chat/${sessionId}` })),
[sessionId]
);
const chat = useChat({ id: sessionId, transport });
const { reanchorError } = useReanchorOnError(chat, {
snapshotUrl: `/api/chat/${sessionId}/snapshot`,
transport,
});Server side
How handleChatStream resumes
A request with no new user message never executes. With a live run it attaches to the session stream (path 5); with a finished session it replays the run from the stream (path 6). A request WITH a new user message is a send and never attaches: while a run is live it is answered 409 state_already_running (CP-13; before, it attached and the message was silently dropped). See the seven paths.
- The cursor comes from
X-Resume-From-Sequence(orLast-Event-ID), orparams.resumeFromSequence. It is clamped to the current run's start, so a stale cursor never replays an earlier turn. Without a cursor the handler replays the current run from its start. - The message id comes from
X-Existing-Message-Idorparams.existingMessageId. Without one the handler uses the run's response id,generateId(run.startUIMessageCount), which is what the snapshot'sresumeMessageIdand the committed message also carry. - The replay prefix is requested by
X-Helix-Replay-Prefix: 1orparams.replayPrefix: true(C-1, above). - Superseded attempts are dropped from what is replayed (see Retried attempts).
HandleChatStreamDeps
| Option | Default | Meaning |
|---|---|---|
snapshotOptions | none | The options your snapshot route passes to buildSnapshot. The replay-prefix read uses them, so they must match. |
snapshotReadDeadlineMs | 10000 | Bound on the replay-prefix read's generic fenced snapshot read; pass your snapshot route's value. |
liveWaitTimeoutMs | 30000 | Fail-safe bound on waiting for a live run's stream to go live (below). |
maxRequestBytes | 10 MB | Content-Length limit; larger requests get the typed 413 validation_payload_too_large (cause: 'request_body'). |
transformerOptions | none | Forwarded to the StreamTransformer. |
logger | noop | Structured logger. |
createCloudflareChatHandler does not take snapshotOptions or liveWaitTimeoutMs; it uses the defaults.
The run-start window
Between a run's run-start commit and its stream claim, the snapshot reports the run active while its stream still shows the previous turn's terminal status. A resume in that window opens the reader with ResumableReaderOptions.waitForLive: true. The wait is event-driven: the reader parks on the stream manager's own wake-ups (the next write or status change) until the stream goes live, and then normal terminal semantics apply. A terminal status left over from the previous turn never ends it.
The only timer is the handler's fail-safe, liveWaitTimeoutMs. A run that never goes live within it answers a retryable 503 { code: 'transport_stream_drop', cause: 'run_not_live', retryable: true } with Retry-After: 1: retry the snapshot and resume. A client that disconnects during the wait releases the reader (status 499, which no one reads).
A client that disconnects
A client that goes away mid-stream (a reload, a closed tab) stops its own response, never the run: the handler closes the runtime's upstream reader for that response and writes nothing more, the run's hooks fire once, and the next resume picks up from the client's cursor as usual. The handler learns of the disconnect from the request's AbortSignal (pass request, or signal). On Cloudflare Workers that signal fires only with the enable_request_signal compatibility flag, which is required: without it Cloudflare never reports the disconnect, and the reader stays open until the run ends (handleChatStream logs one warning when the runtime reports the flag off). See Client disconnects for the per-server wiring and the measured edge timings.
A send never needs that wait. The executors return the run's handle only once its stream is readable, or the run already left running:
- runtime-js: after the run-start commit, the claim, make-live and
run_started. - Temporal and Cloudflare Workflows: once the run's own stream holds a chunk stamped with this run (its
run_started). - DBOS: once the workflow published its run-start event after the claim. The exception is an entry whose event wait timed out, which falls back to the run record (FU-DBOS-ENTRY-FALLBACK-BEFORE-CLAIM).
On the Cloudflare DO, the SSE route the send attaches to tails the session's stream, which is already active. Before T1-B, Cloudflare Workflows returned on a new or active session before the stream existed, and a send could be answered with data-resume-rejected / stream_unavailable while its run went on unread.
On the Cloudflare DO chat route (createCloudflareChatHandler), DOStreamManagerClient forwards the wait to the DO as /sse?waitForLive=1, and the DO parks the request on its own stream manager's liveness rule (RM-73; before, the DO replayed the previous turn's tail and its end). Deploy the Worker and the DO together: an older DO ignores the parameter.
A custom stream manager that ignores waitForLive keeps the old behavior: a resume in this window ends early on that backend. See Custom stream managers in the upgrade guide.
The backlog boundary: caughtUp
Every resumable reader exposes:
catchUpHead: the highest sequence its initial history read covers, captured atomically with that read;caughtUp: a promise that resolves once every retained backlog chunk up tocatchUpHeadhas been yielded. It never rejects, and it also resolves when the reader ends or is closed.
The handler tells the replayed backlog from the live tail on caughtUp, never on a quiet period. SSE readers learn the boundary from the server's end-of-history marker.
Resync
A resync tells a client that what it shows is no longer the transcript, so it must re-anchor: stop the stream, fetch the latest snapshot, set its messages, and, when it is live, resume from its cursor. Three UI events trigger it:
data-stream-resync{ checkpointId, stepCount, messageCount, fromSequence, reason, runId?, truncateCheckpointId? }: an entry rewound the log or removed chunks.reasonisretry(everyretry()),rollback(resume({ mode: 'from_checkpoint' })) orcrash_recovery(a resume that removed a crashed step's orphan chunks).branchis reserved. A rewind writes exactly one, after the run's start and before its content, and it carries thetruncateCheckpointIdits run-start commit wrote. A resync is identified by(runId, checkpointId).data-attempt-superseded{ reason: 'attempt_superseded', runId, agentId, step, attemptId, supersededAttemptId }: a retried attempt replaced content the client was already sent (see Retried attempts).data-tool-output-elsewhere{ reason: 'tool_output_elsewhere', toolCallId, toolName, runId? }: the stream carried a tool result for a call an EARLIER message holds, which the response message cannot show (see Tool results for a call in an earlier message). Identified by(runId, toolCallId).
The first two are never part of a normal turn: a client-tool continuation or a follow-up message emits neither. The third can be: a follow-up message sent while client-tool calls are pending consumes them. All three stay in the message's parts, so collectResyncTriggers(messages) finds them.
Tool results for a call in an earlier message
ai applies a tool-output-available / tool-output-error chunk only to a tool part of the message the response renders into; for any other call it throws No tool invocation found for tool call ID, and the whole response fails (the reply is lost). A run's stream can legitimately carry such results: a with_message entry finishing the client-tool calls it consumed or abandoned, an approval answered in an earlier message, or a refresh tail re-delivering an entry's frames.
So the stream transformer forwards a tool result only for a call the response message owns:
- a call whose input this response emitted (live, from
replayContent, or in the C-1 committed prefix), or - a call of the message the client already holds and the response is appended to (
StreamTransformerOptions.continuedToolCallIds).handleChatStreampasses it for a client-tool or approval continuation (the request's last message, when it is an assistant message) and for a resume of a client-held message without the replayed prefix (that committed message's calls). The list is client data: it is Zod-checked and only widens which results are forwarded.
Any other result is dropped, and the response carries one data-tool-output-elsewhere per such call instead. A client that re-anchors on it (useHelixChat({ resync }), or useAutoResync below) then shows the result from the snapshot, in the earlier message. A re-delivered trigger with the same identity re-anchors no second time.
Plain useChat cannot re-anchor through its transport (a transport has no setMessages). Without a resync hook the reply still renders, with no error, but the earlier message keeps the call's last-seen state (an abandoned call stays input-available) until the next load. Wire useAutoResync(collectResyncTriggers(chat.messages), { chat, ... }) to re-anchor, as for the other triggers.
useHelixChat({ resync })
The simplest wiring, shown in the canonical client:
const chat = useHelixChat({
id: sessionId,
transport,
messages: resumeOptions.messages,
resume: resumeOptions.resume,
resync: {
snapshotUrl: `/api/chat/${sessionId}/snapshot`,
snapshot, // the seed snapshot (optional; see below)
snapshotFetch: { fetch: authedFetch }, // fetchSnapshotWithRetry options (optional)
onResync: (event) => {}, // AISDKResyncTriggerEvent (optional)
onError: (error) => {}, // also surfaced as chat.resyncError (optional)
},
});useHelixChat returns UseHelixChatResult: useChat's helpers plus resyncError: Error | null (the last re-anchor error, cleared by the next success, including a retry() that re-anchors or re-sends). Its stop aborts the transport connection and resolves once the chat is idle, and its sendMessage stops an open resume first.
A seed snapshot that already covers a rewind. A page refreshed after a rewind started already shows the rewound log, and it replays that rewind's data-stream-resync from its cursor. Pass the seed snapshot as resync.snapshot: the hook skips a resync whose truncateCheckpointId equals the snapshot's latestTruncateCheckpointId (resyncCoveredBySnapshot(event, snapshot)). Without it the page re-anchors once more, which is harmless.
useAutoResync and useResumableChat
The lower-level hooks take the trigger parts (collectResyncTriggers(messages)) and re-anchor the same way. Each re-anchor needs stream control, resolved from exactly one source:
control(a completeHelixResyncControl), elsestopandresumetogether, elsechat(theuseHelixChatresult is safe; a plainuseChatresult goes throughchatResyncControl), else- the mounted
useHelixChatwhose id ischatId, or, withoutchatId, whosesetMessagesis the hook'ssetMessages. The hooks never guess "the one mounted chat".
With none of these, or with only one of stop / resume, a resync surfaces a typed ConfigurationError (missing: ['stop', 'resume']) through resyncError / onError and leaves the messages alone: a re-anchor that cannot stop the running stream would let it keep writing next to the snapshot. A stop that does not abort the transport (for example a bare chat.stop, which cannot stop a resumed stream on ai ≤ 6.0.230) logs a development warning.
const chat = useHelixChat({
id: sessionId,
transport,
messages: resumeOptions.messages,
resume: resumeOptions.resume,
});
const { resyncError } = useAutoResync(collectResyncTriggers(chat.messages), {
snapshotUrl: `/api/chat/${sessionId}/snapshot`,
setMessages: chat.setMessages,
chat,
snapshot,
});onResync receives an AISDKResyncTriggerEvent (AISDKStreamResyncEvent | AISDKAttemptSupersededEvent | AISDKToolOutputElsewhereEvent); narrow on event.type before reading checkpointId. isStreamResyncEvent, isAttemptSupersededEvent, isToolOutputElsewhereEvent and isResyncTriggerEvent are the type guards. Concurrent resyncs: the latest wins. An unmount or a snapshotUrl change drops a re-anchor in flight. Outside React, reanchorOnSnapshot({ snapshotUrl, control, setMessages, ... }) does the same.
Retried attempts
A Temporal activity retry or a Cloudflare Workflows step.do retry re-streams a step under a new attemptId. Every chunk a run writes carries its runId, and an LLM attempt's chunks carry its attemptId; every runtime writes step_start as an attempt's first chunk.
- For a committed step, the final attempt is the one its
step_committed.committedAttemptIdnames, when that attempt streamed in the step. A commit that names no attempt (''), or one never seen in the step, changes nothing. - Before the commit, the current attempt is the one that started last, by the stream position of its
step_start. Order never depends on a clock. - A resume, attach or terminal replay drops a superseded attempt's content (text, reasoning, tool input, sources, files and step markers). Tool results pass through.
- A live attempt switch emits
data-attempt-superseded, and so does a resume whose cursor is past superseded content the client was already sent. Both are resync triggers.
attemptId is never forwarded on content events; it appears only inside the data-attempt-superseded payload.
Superseded runs
An entry (a send, resume or retry) that wins its run-start commit supersedes every other live run of the session. The superseded run writes nothing more: its commits, fenced saves and lease renewals are rejected with state_run_superseded, and its late chunks are dropped once the new run has claimed the stream. Its result reports status: 'superseded'.
A superseded run is not a stream terminal for a chat client. An attached client keeps its subscription to the session stream and receives the superseding run's chunks, because both write to the same session stream. A late client-tool submit to a superseded run answers 409 { code: 'state_run_superseded' }.
Sends and conflicts
A send is a request whose trailing message is a new user message; it is the only request that calls executor.execute(). Its response streams only the new run's chunks: the handler reads the session stream's head before execute() and streams what follows it, so a new turn never replays earlier turns.
| Refusal | Response |
|---|---|
A run is already active (AgentAlreadyRunningError / RunStartConflictError) | 409 { error, code: 'state_already_running', retryable: true } |
RunStartRejectedError with cause expected_mismatch_idle | Retried once by the handler, then as below. |
Any other RunStartRejectedError (idempotency_mismatch, entry_status_changed, ...) | 409 { error, code: 'state_run_start_conflict', cause } |
A refused send never attaches to the running run, whose text would otherwise render as the reply to the new message. A stock useChat / useHelixChat ends the send with status 'error'. Read the body back with parseHelixChatError(chat.error) ({ error?, code, retryable?, cause? }, or undefined for an untyped error) and offer a retry. With useHelixChat, use chat.retry(): it re-sends the same message under the same request key (see Retried sends and request keys). A stock useChat uses chat.regenerate(), which re-sends the same message:
import { parseHelixChatError } from '@helix-agents/ai-sdk/client';
const typed = parseHelixChatError(chat.error);
if (typed?.retryable) return <button onClick={() => void chat.retry()}>Retry</button>;Requests without a new user message (a reconnect, a passive attach) and tool-result resumes still attach to the active run.
Turns share the session stream. A run's endStream / failStream / pauseStream is fenced on its own run id, so a previous run that ends late cannot end the stream under the next turn once the next turn has claimed it.
Retried sends and request keys
A send whose response was lost (a dropped connection, a timeout) is ambiguous without a key: its run may or may not have started, and re-sending it could append the message twice. A request key (D-2) makes the retry safe. The client sends one X-Helix-Request-Key per send, and the run-start commit records it on the run. A repeat of the key never runs again:
| The key's run | What the repeated send gets |
|---|---|
| none (the first attempt never committed) | a normal send, with the key |
| live, suspended included | the original run's stream from its start, under the same message id: the original reply, not a 409 |
| finished, and still the session's latest | that run's terminal replay |
| finished, and a later turn exists | 409 state_request_already_completed (body runId): re-anchor on the snapshot, which holds it |
| any, but the send carries different input | 409 state_request_key_conflict (body runId): nothing written |
The attach writes nothing and stops nothing. A duplicate of a live run is answered from its key before the busy refusal (Sends and conflicts), so a retried send gets its own reply.
Who sends the key.
useHelixChatmints one persendMessage(a caller-supplied header is kept). Itsretry()re-sends with the same key, after a refusal (refusedSend.key) and after a failed send. Itsregenerate()is a deliberate new request and mints a new key. Preferretry()overregenerate()for "the send failed, try again".createHelixChatTransportsends the caller's key, else one it mints per user message.- A stock
useChator a raw HTTP client sends none unless you set the header. A send without a key keeps the keyless rules of Sends and conflicts.
Where it is honoured. handleChatStream attaches a duplicate of the session's current run itself, and forwards the key to an executor that declares supportsRequestKey (runtime-js, the CF Durable Object client). CF Workflows, Temporal and DBOS do not record keys yet: their sends run keyless. A tool-result request stays keyless. See handleChatStream keyed sends and Request keys.
Stream control per chat
ai 6.0.0–6.0.230 call transport.reconnectToStream() without an abort signal, so chat.stop() cannot stop a resumed stream. A re-anchor that cannot stop the old stream would let it write into the same message as the new one.
withHelixStreamControl(transport)(from the package root,/clientand/react) wraps anyChatTransportand gives each connection its ownAbortController, keyed bychatId. A new connection aborts that chat's previous one; two chats sharing a transport never abort each other. It returns aHelixControlledTransportwith theHelixStreamControlmethodsabortActive(chatId),hasActiveConnection(chatId?),activeKind(chatId)andconnections(chatId)(the chat's recent requests with a typedConnectionOutcome, which the re-attach controller reads). A reconnect the transport aborts before its response arrives resolvesnull("nothing to resume").useHelixChatwraps its transport this way automatically. Itsstopaborts the connection, then waits (bounded) for the chat to go idle.- With a plain
useChat, wrap the transport yourself and calltransport.abortActive(chatId)beforechat.stop(), or build a resync control withchatResyncControl(chat, transport). createHelixChatTransportowns its connections the same way.
useHelixChat builds its control with controlChat(chat, transport, { getStatus }) (an abort-aware, bounded stop that rejects with StreamStopTimeoutError if the chat does not go idle, a resume with resumeHeaders, and a sendMessage serialized after an open resume). controlChat is not exported from a public entry point today.
Wire errors
| Route | Case | Status | Body |
|---|---|---|---|
| snapshot | the session does not exist | 404 | { error: 'Session not found', code: 'state_session_not_found' } |
| snapshot | the read failed | 503 retryable / 409 not (httpStatusForHelixError) | { error, code, cause? }, e.g. 503 state_history_incomplete / snapshot_unstable, 409 corrupt_row |
| stream attach | the stream id does not exist (createResumableStreamHandler) | 404 | { error: 'Stream not found' } |
| chat (send) | a run is already active | 409 | { error, code: 'state_already_running', retryable: true } |
| chat (send) | the run start was rejected | 409 | { error, code: 'state_run_start_conflict', cause } |
| chat (send) | the entry failed before its run-start commit (history read, completion refusal, a DO /start typed body, a CFW preCommitFailure handle) | 400 / 404 / 503 / 409 / 500 (httpStatusForHelixError) | { error, code } (core typedErrorResponse, message scrubbed) |
| chat (send) | the send's run committed, then the entry failed | 409 / 500 | { error, code }; never 503 (a re-send would append the message twice) |
| chat (send) | a malformed X-Helix-Request-Key | 400 | { error, code: 'validation_error' } (the key is not echoed) |
| chat (send) | a keyed send reused its key for other input | 409 | { error, code: 'state_request_key_conflict', runId } |
| chat (send) | a keyed send whose run ended and a later run exists | 409 | { error, code: 'state_request_already_completed', runId } |
| chat (resume) | a submit or the resume failed typed (submitToolResult() / resume() threw a HelixError, e.g. a completion refusal, or the resume handle carries preCommitFailure) | 400 / 404 / 503 / 409 / 500 (httpStatusForHelixError) | { error, code } (core typedErrorResponse); never 503 once this request stored a submitted result (DI-39) |
| chat (resume) | the replayed message id is not in the session | 409 | { error, code: 'state_not_resumable', cause: 'resume_message_unknown', retryable: true } |
| chat (resume) | the run did not go live within liveWaitTimeoutMs | 503 | { error, code: 'transport_stream_drop', cause: 'run_not_live', retryable: true } |
| chat (resume) | the committed prefix could not be read | 503 | { error, code, retryable: true } |
| chat (resume) | no reader: the stream failed | 409 | { error: 'stream_failed', code, retryable, sessionId, streamId, message, errorDetail? }: code / retryable are the run's classified failure (else code: 'stream_failed') |
| chat (resume) | no reader: another reader holds the stream | 409 | { error: 'stream_already_attached', sessionId, streamId, message } (no code) |
| chat | the body is larger than maxRequestBytes | 413 | { error, code: 'validation_payload_too_large', retryable: false, cause: 'request_body', limitBytes, sizeBytes } |
| submit | a late client-tool submit to a superseded run | 409 | { error, code: 'state_run_superseded' } |
A tool-result request that leaves a client-tool batch partial (some calls of the batch still unanswered) is not refused by the chat route: its submits land and it answers an empty 200 stream, and the run continues once the last result arrives. Calling executor.resume() directly on such a session throws a retryable state_not_resumable error (R44); submit the remaining results first. A resume() or submit that fails with an unclassified (non-HelixError) error on the chat route is a typed framework_internal_error too, never a 200 (upgrade guide).
@helix-agents/ai-sdk/cloudflare exports the route helpers:
snapshotNotFoundResponse(): the 404 above.snapshotErrorResponse(error): the typed answer above. AHelixErroris coretypedErrorResponse's status (503 retryable, 409 a non-retryablestate_*error) and keeps its code, and itsreasonbecomescause; any other error is{ error: 'Snapshot unavailable', code: 'transport_error' }(its text is never echoed).submitToolResultErrorResponse(error): 409state_run_supersededfor aRunSupersededError, 400 for an invalid client-tool result (INVALID_RESULTwith core's fixedINVALID_RESULT_MESSAGE, no Zod detail) or payload, orundefinedfor an error the route should rethrow.
On the client, DOFrontendExecutor.submitToolResult and createHelixChatTransport().submitToolResult throw a typed RunSupersededError for the 409.
Core SDK usage
Without the AI SDK integration, read a stream from a position with a resumable reader:
const reader = await streamManager.createResumableReader(streamId, {
fromSequence: cursor, // exclusive: yields chunks with sequence > cursor
waitForLive: true, // optional: park through the run-start window instead of ending
});
if (reader) {
try {
for await (const chunk of reader) {
render(chunk, reader.currentSequence);
}
} finally {
await reader.close();
}
}createResumableReader returns null when the stream does not exist or has failed (never with waitForLive). A failed stream ends a reader with one error chunk. That holds on the Cloudflare DO chat route too (DOStreamManagerClient, DI-41): a resume of a failed run answers the typed 409 stream_failed, and a live attach whose run fails ends with the error event, never a clean finish. To build a snapshot without the AI SDK converter, use core's readSnapshotView(deps, sessionId): it returns the same consistent, checkpoint-anchored view (messages, cursor, state, status, run, legacy) that buildSnapshot converts.
StreamManager implementations
Every stream manager implements createResumableReader, getStreamInfo, getAllChunks, getChunksFromStep, pauseStream / resumeStream, the claimStream compare-and-set and waitForLive. They differ in where chunks live:
| Manager | Multi-process | Persistent | TTL |
|---|---|---|---|
InMemoryStreamManager (@helix-agents/store-memory) | No | No | No |
RedisStreamManager (@helix-agents/store-redis) | Yes | Yes | streamTTL (seconds, default 86400) |
DurableObjectStreamManager (@helix-agents/store-cloudflare/stream) | Yes | Yes | Durable Object storage |
getStreamInfo(streamId).status is the stream's own status on every manager, never a run's liveness: in a new run's start window (its run-start commit landed, its stream not yet claimed) the previous turn's stream still reads ended / paused / failed (the contract case G5.info-status-is-the-streams-own; the CF DO's DOStreamManagerClient reported the run-derived snapshot status before RM-73's fix).
getStreamInfo(streamId) returns null for a stream that does not exist, on every manager, including DurableObjectStreamManager (a stream a run has claimed counts as existing) and the in-process DOStreamManager of the Durable Object runtime, which also returns null for an id that is not the DO's own stream (RM-71). A route built on core's createResumableStreamHandler therefore answers 404 { error: 'Stream not found' } for an unknown stream id on Cloudflare too; it used to answer a 500 after the handler's reader retries.
A failed read is not "absent" (CP-28). The managers that read over HTTP return null / [] only when the stream does not exist. DurableObjectStreamManager's getStreamInfo, createReader and createResumableReader return null only for an /info answer of exists: false (the readers also return null for a failed stream, as every manager does), and its getChunks* throw on any non-2xx answer instead of returning []. DOStreamManagerClient's getStreamInfo / getChunks* return null / [] only for a 404 or a DO with no stream. Any other failure throws a typed error (StreamConnectionError, recoverable on a 5xx; DOPeerError for the DO client), so an outage is never read as a missing stream.
A cut stream is a drop. The SSE-backed readers (DurableObjectStreamManager, the CF DO's DOStreamManagerClient) read stream wire contract v2: a body that ends with no terminal frame is a drop, unless the reader's own caller closed or aborted it, and a truncated frame, or a rewound frame that leaves the reader behind the head, throws StreamTruncatedError. They never end cleanly on a dropped connection. On a drop (or a body read that fails) they first re-open /sse themselves from the highest stored sequence they took in, so an evicted or restarted DO does not end the read: chunk sequences come from a persisted counter that is never rewound, so nothing is delivered twice. They throw the retryable StreamDropError (re-attach from the reader's cursor) only when a re-open cannot help: a second drop with no new chunk since the last re-open, more than MAX_STREAM_REOPENS (8) counted re-opens, or a re-open that fails.
An eviction is typed (WE C2b). When a DO evicts a connection it says why: the last frame is evicted{resumeFrom, reason} (stream wire › Evictions). A reader re-opens from its own cursor; an eviction for the client's own pace (slow_consumer, stale) never counts toward MAX_STREAM_REOPENS, while one for the DO's connection cap or a server error does. The read ends typed when a re-open cannot help: StreamEvictedError (transport_stream_evicted, the reason as cause) after a second eviction with no new chunk or a counted re-open past the cap, StreamRunChangedError (transport_stream_run_changed) when a newer run of the same session took the stream meanwhile (re-anchor from the snapshot), and StreamTruncatedError when the reader's cursor is below resumeFrom (a frame lost on the way). Both new errors are StreamDropErrors and retryable. A chat client gets any of them as the route's error event, and useHelixChat re-anchors on it from the snapshot (it never reads the code).
createResumableStreamHandler frames
The handler serves a stream manager's chunks as SSE in the stream wire protocol:
- Chunks are the protocol's
chunkframe,{ sequence, chunk }with the sequence as the SSEid:(read with coredecodeStreamFrame). With a customeventName/serializeChunkthe chunk frames keep your event name and data, still withid:= the sequence. - The terminal frame is always the protocol's and comes from the stream's own
getStreamInfowhen the reader ends:ended→end { status }(completed, orinterruptedfor a stopped run),paused→end { status: 'paused' },failed→fail { error, errorDetail?, recoverable: false }. The oldend { streamId }anderrorframes are gone (upgrade guide). - A reader that ends while the stream is still
active(or gone) did not see the end: the handler writes a recoverablefailwitherrorDetail.codetransport_stream_drop, never a cleanend. A reader error (or a failedgetStreamInfo) is a recoverablefail. Re-attach from your cursor (Last-Event-ID) on a recoverablefail.
import { RedisStreamManager } from '@helix-agents/store-redis';
// A second client for pub/sub.
const streamManager = new RedisStreamManager(redis, subscriberRedis, {
keyPrefix: 'myapp',
streamTTL: 86400,
});import { DurableObjectStreamManager } from '@helix-agents/store-cloudflare/stream';
const streamManager = new DurableObjectStreamManager({ streamNamespace: env.STREAMS });Troubleshooting
Text is missing or duplicated after a refresh
- Seed the chat with
snapshotResumeOptions(snapshot).messages, notsnapshot.messages, and send itsreplayPrefixon the reconnect. Seeding the whole snapshot with the flag duplicates the message onai≤ 6.0.230; seeding it without the message and without the flag loses its committed steps. - Pass the same
snapshotOptionstohandleChatStreamas your snapshot route givesbuildSnapshot. - Use
useHelixChat(with a transport, not a pre-builtChat) for the replayed prefix.
The resume returns 404 or never reads the stream
prepareHelixReconnectRequest is missing. The AI SDK's default reconnect URL is ${api}/${chatId}/stream, which the Helix chat route does not serve.
The page shows an empty conversation after a server error
The snapshot read failed and the client treated it as empty. Use fetchSnapshotWithRetry and render an error state with a retry control for a SnapshotUnavailableError.
A send fails with 409 state_already_running
A run is still active on the session (another tab, or the previous turn has not finished). Offer a retry with useHelixChat().retry(), or chat.regenerate() on a stock useChat (see Sends and conflicts).
A resync surfaces ConfigurationError
The resync hook has no stream control. Pass chat (the useHelixChat result), both stop and resume, or the chatId of a mounted useHelixChat.
createResumableReader returns null
The stream does not exist or has failed. Check getStreamInfo() and show the error state for a failed stream.
Next steps
- AI SDK Integration: the chat handler, client helpers and hooks
- Streaming Guide: stream chunk types
- Stream Protocol: chunk shapes and per-runtime emission
- Upgrade guide: snapshots and checkpoint truth
- Resumable Streams Example: a complete Next.js app