Skip to content

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 ​

StatusMeaning
activeA run is writing to the stream.
pausedThe run suspended at a human-in-the-loop boundary and committed it.
endedThe run finished. A later run on the same session makes it live again.
failedThe 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.

FieldMeaning
messagesThe committed rows [0, checkpoint.messageCount), converted to AI SDK UIMessage[]. Never a partial message, never capped.
streamSequenceThe cursor: the checkpoint's stream sequence. Resume with chunks seq > streamSequence.
stateThe custom state at the cursor (the checkpoint's state). Later state_patch frames apply on top of it.
statusactive / paused while the current run is live, else failed or ended (see below).
resumeMessageIdPresent when status is active or paused: the id of the UI message the current run's response streams into (generateId(run.startUIMessageCount)).
latestTruncateCheckpointIdThe 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.
errorDetailOnly when status is failed: the error that ended the turn, code / category / retryable kept, free text scrubbed.
checkpointId, stepCountThe anchoring checkpoint (null / 0 when there is none yet).
sessionIdThe 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): paused when the run is suspended or the stream is paused, otherwise active.
  • Otherwise, if the current run or the stream failed: failed.
  • Otherwise: ended. A stream left active by a run that completed or was superseded is ended.

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_incomplete with reason: 'snapshot_unstable' (retryable).
  • One deadline bounds the whole read (default 10 000 ms; snapshotReadDeadlineMs on buildSnapshot's and handleChatStream's deps). A read still running at the deadline stops issuing store calls and throws the same retryable snapshot_unstable, its message naming the read it waited on (deadline@getCurrentRun). Raise it only when one whole read on your store can take longer; Infinity means no deadline, and any other value outside (0, 2 147 483 647] ms is a validation_error.
  • A failed or short read throws state_history_incomplete with reason store_error, corrupt_row or short_read.
  • buildSnapshot returns null only 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.

typescript
// 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 });
typescript
// 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);
}
typescript
// 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 ​

tsx
'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 statusResult
active, pausedresume: true, resumeFromSequence: snapshot.streamSequence, existingMessageId: snapshot.resumeMessageId, replayPrefix: true, and messages = the snapshot without the resumeMessageId message.
ended, failedresume: 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:

HeaderValue
X-Resume-From-SequenceThe snapshot cursor (Last-Event-ID is accepted as a fallback).
X-Existing-Message-IdresumeMessageId: the message the resumed stream writes into.
X-Helix-Replay-Prefix1 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:

  1. reads one snapshot with buildSnapshot and HandleChatStreamDeps.snapshotOptions;
  2. replays that message's committed parts under the same id, using only ai 6.0.0 chunk types, between transient data-helix-replay { phase: 'begin' | 'end', messageId } markers (the message's committed metadata rides on start);
  3. 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 onToolCall and 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:

typescript
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.

typescript
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 null for a 404 (no such session).
  • It throws SnapshotUnavailableError after 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's code, its cause as reason, and the last HTTP status.
  • signal aborts the fetch and any pending retry with an AbortError, 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:

typescript
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, and retry() re-sends it with its own request key.
  • A refusal the server answered before any stream (any non-2xx), a chunk ai could not parse, a stop and the app's own throw from onToolCall / onData stay chat.error, exactly as on stock useChat.
  • 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 ReanchorExhaustedError is 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.

tsx
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 (or Last-Event-ID), or params.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-Id or params.existingMessageId. Without one the handler uses the run's response id, generateId(run.startUIMessageCount), which is what the snapshot's resumeMessageId and the committed message also carry.
  • The replay prefix is requested by X-Helix-Replay-Prefix: 1 or params.replayPrefix: true (C-1, above).
  • Superseded attempts are dropped from what is replayed (see Retried attempts).

HandleChatStreamDeps ​

OptionDefaultMeaning
snapshotOptionsnoneThe options your snapshot route passes to buildSnapshot. The replay-prefix read uses them, so they must match.
snapshotReadDeadlineMs10000Bound on the replay-prefix read's generic fenced snapshot read; pass your snapshot route's value.
liveWaitTimeoutMs30000Fail-safe bound on waiting for a live run's stream to go live (below).
maxRequestBytes10 MBContent-Length limit; larger requests get the typed 413 validation_payload_too_large (cause: 'request_body').
transformerOptionsnoneForwarded to the StreamTransformer.
loggernoopStructured 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 to catchUpHead has 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. reason is retry (every retry()), rollback (resume({ mode: 'from_checkpoint' })) or crash_recovery (a resume that removed a crashed step's orphan chunks). branch is reserved. A rewind writes exactly one, after the run's start and before its content, and it carries the truncateCheckpointId its 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). handleChatStream passes 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:

typescript
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:

  1. control (a complete HelixResyncControl), else
  2. stop and resume together, else
  3. chat (the useHelixChat result is safe; a plain useChat result goes through chatResyncControl), else
  4. the mounted useHelixChat whose id is chatId, or, without chatId, whose setMessages is the hook's setMessages. 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.

tsx
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.committedAttemptId names, 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.

RefusalResponse
A run is already active (AgentAlreadyRunningError / RunStartConflictError)409 { error, code: 'state_already_running', retryable: true }
RunStartRejectedError with cause expected_mismatch_idleRetried 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:

tsx
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 runWhat the repeated send gets
none (the first attempt never committed)a normal send, with the key
live, suspended includedthe original run's stream from its start, under the same message id: the original reply, not a 409
finished, and still the session's latestthat run's terminal replay
finished, and a later turn exists409 state_request_already_completed (body runId): re-anchor on the snapshot, which holds it
any, but the send carries different input409 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.

  • useHelixChat mints one per sendMessage (a caller-supplied header is kept). Its retry() re-sends with the same key, after a refusal (refusedSend.key) and after a failed send. Its regenerate() is a deliberate new request and mints a new key. Prefer retry() over regenerate() for "the send failed, try again".
  • createHelixChatTransport sends the caller's key, else one it mints per user message.
  • A stock useChat or 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, /client and /react) wraps any ChatTransport and gives each connection its own AbortController, keyed by chatId. A new connection aborts that chat's previous one; two chats sharing a transport never abort each other. It returns a HelixControlledTransport with the HelixStreamControl methods abortActive(chatId), hasActiveConnection(chatId?), activeKind(chatId) and connections(chatId) (the chat's recent requests with a typed ConnectionOutcome, which the re-attach controller reads). A reconnect the transport aborts before its response arrives resolves null ("nothing to resume").
  • useHelixChat wraps its transport this way automatically. Its stop aborts the connection, then waits (bounded) for the chat to go idle.
  • With a plain useChat, wrap the transport yourself and call transport.abortActive(chatId) before chat.stop(), or build a resync control with chatResyncControl(chat, transport).
  • createHelixChatTransport owns 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 ​

RouteCaseStatusBody
snapshotthe session does not exist404{ error: 'Session not found', code: 'state_session_not_found' }
snapshotthe read failed503 retryable / 409 not (httpStatusForHelixError){ error, code, cause? }, e.g. 503 state_history_incomplete / snapshot_unstable, 409 corrupt_row
stream attachthe stream id does not exist (createResumableStreamHandler)404{ error: 'Stream not found' }
chat (send)a run is already active409{ error, code: 'state_already_running', retryable: true }
chat (send)the run start was rejected409{ 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 failed409 / 500{ error, code }; never 503 (a re-send would append the message twice)
chat (send)a malformed X-Helix-Request-Key400{ error, code: 'validation_error' } (the key is not echoed)
chat (send)a keyed send reused its key for other input409{ error, code: 'state_request_key_conflict', runId }
chat (send)a keyed send whose run ended and a later run exists409{ 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 session409{ error, code: 'state_not_resumable', cause: 'resume_message_unknown', retryable: true }
chat (resume)the run did not go live within liveWaitTimeoutMs503{ error, code: 'transport_stream_drop', cause: 'run_not_live', retryable: true }
chat (resume)the committed prefix could not be read503{ error, code, retryable: true }
chat (resume)no reader: the stream failed409{ 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 stream409{ error: 'stream_already_attached', sessionId, streamId, message } (no code)
chatthe body is larger than maxRequestBytes413{ error, code: 'validation_payload_too_large', retryable: false, cause: 'request_body', limitBytes, sizeBytes }
submita late client-tool submit to a superseded run409{ 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. A HelixError is core typedErrorResponse's status (503 retryable, 409 a non-retryable state_* error) and keeps its code, and its reason becomes cause; any other error is { error: 'Snapshot unavailable', code: 'transport_error' } (its text is never echoed).
  • submitToolResultErrorResponse(error): 409 state_run_superseded for a RunSupersededError, 400 for an invalid client-tool result (INVALID_RESULT with core's fixed INVALID_RESULT_MESSAGE, no Zod detail) or payload, or undefined for 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:

typescript
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:

ManagerMulti-processPersistentTTL
InMemoryStreamManager (@helix-agents/store-memory)NoNoNo
RedisStreamManager (@helix-agents/store-redis)YesYesstreamTTL (seconds, default 86400)
DurableObjectStreamManager (@helix-agents/store-cloudflare/stream)YesYesDurable 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 chunk frame, { sequence, chunk } with the sequence as the SSE id: (read with core decodeStreamFrame). With a custom eventName / serializeChunk the chunk frames keep your event name and data, still with id: = the sequence.
  • The terminal frame is always the protocol's and comes from the stream's own getStreamInfo when the reader ends: ended → end { status } (completed, or interrupted for a stopped run), paused → end { status: 'paused' }, failed → fail { error, errorDetail?, recoverable: false }. The old end { streamId } and error frames 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 recoverable fail with errorDetail.code transport_stream_drop, never a clean end. A reader error (or a failed getStreamInfo) is a recoverable fail. Re-attach from your cursor (Last-Event-ID) on a recoverable fail.
typescript
import { RedisStreamManager } from '@helix-agents/store-redis';

// A second client for pub/sub.
const streamManager = new RedisStreamManager(redis, subscriberRedis, {
  keyPrefix: 'myapp',
  streamTTL: 86400,
});
typescript
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, not snapshot.messages, and send its replayPrefix on the reconnect. Seeding the whole snapshot with the flag duplicates the message on ai ≤ 6.0.230; seeding it without the message and without the flag loses its committed steps.
  • Pass the same snapshotOptions to handleChatStream as your snapshot route gives buildSnapshot.
  • Use useHelixChat (with a transport, not a pre-built Chat) 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 ​

Released under the MIT License.