Skip to content

@helix-agents/ai-sdk ​

Vercel AI SDK UI binding layer for Helix Agents. Transforms Helix internal streaming protocol to AI SDK UI Data Stream protocol for use with useChat and other AI SDK React hooks.

Installation ​

bash
npm install @helix-agents/ai-sdk

Breaking: snapshots are checkpoint-anchored

A snapshot is now read from one checkpoint: committed messages up to its cursor, never partial content. The content-replay options (and their options type), the partial-content snapshot option and the FrontendSnapshot fields timestamp / startSequence / startMessageCount are removed. useHelixChat returns UseHelixChatResult, the resync hooks need stream control, and onResync receives an AISDKResyncTriggerEvent. See the upgrade guide for migration steps.

v7 surface map ​

ExportModulePurpose
handleChatStream@helix-agents/ai-sdkServer-side seven-path orchestrator. Canonical v7 chat entry point.
extractResumeIntent@helix-agents/ai-sdkParse resume signals from incoming AI SDK v6 messages
findExpiredPending@helix-agents/ai-sdkSurface client-tool calls past their deadline
prepareHelixChatRequest@helix-agents/ai-sdk/clientPrepareSendMessagesRequest for DefaultChatTransport
prepareHelixReconnectRequest@helix-agents/ai-sdk/clientPrepareReconnectToStreamRequest for DefaultChatTransport
snapshotResumeOptions / fetchSnapshotWithRetry@helix-agents/ai-sdk/client (also /react)How to seed and resume a chat from a snapshot; fetch a snapshot with retries
pendingCallsMessage@helix-agents/ai-sdk (also /client, /react)Render a snapshot's pendingCalls as one assistant message a chat can answer
reanchorOnSnapshot, resumeHeaders, collectResyncTriggers@helix-agents/ai-sdk/client (also /react)Framework-agnostic resync: re-anchor a chat on the latest snapshot
withHelixStreamControl@helix-agents/ai-sdk (also /client, /react)Wrap a ChatTransport so a resumed stream can be stopped
parseHelixChatError@helix-agents/ai-sdk (also /client, /react)Read a chat route's typed error body back from chat.error
useHelixChat@helix-agents/ai-sdk/reactuseChat + client-tool dispatch + resync, in one hook
useResumeClientTools@helix-agents/ai-sdk/reactReact hook auto-dispatching client-executed tool calls
createHelixChatTransport@helix-agents/ai-sdkChatTransport that talks directly to @helix-agents/agent-server
buildSnapshot@helix-agents/ai-sdkStandalone SSR snapshot helper (replaces FrontendHandler.getSnapshot)
getUIMessages@helix-agents/ai-sdkStandalone message-history loader (replaces FrontendHandler.getMessages)
createCloudflareChatHandler@helix-agents/ai-sdk/cloudflareFactory wiring handleChatStream to a Cloudflare Durable Object backend
snapshotNotFoundResponse / snapshotErrorResponse@helix-agents/ai-sdk (also /cloudflare)Typed 404 / 503 / 409 responses for a snapshot route
submitToolResultErrorResponse@helix-agents/ai-sdk (also /cloudflare)Typed 409 / 400 / 413 answers for a submit-tool-result route
chatErrorResponse@helix-agents/ai-sdk (also /cloudflare)Typed answer for a chat route's own failure (never echoes an unknown error's text)
pipeWebResponseToExpress@helix-agents/ai-sdk/adapters/expressPipe a web Response (from handleChatStream) into an Express response
createWebResponseExpressMiddleware@helix-agents/ai-sdk/adapters/expressExpress middleware adapter for handleChatStream-style handlers
toWebRequest@helix-agents/ai-sdk/adapters/expressExpress req → a body-less web Request (URL, headers, signal)
StreamTransformer@helix-agents/ai-sdkHelix chunk → AI SDK Data Stream event converter
convertToAISDKMessages@helix-agents/ai-sdkHelix Message[] → AI SDK v6 UIMessage[] converter
loadUIMessages / loadAllUIMessages@helix-agents/ai-sdkDirect loaders from a SessionStateStore
Recovery hooks (useResumableChat, useAutoResync, ...)@helix-agents/ai-sdk/reactSnapshot loading + resync handling

handleChatStream ​

The canonical v7 entry point. Takes the parsed { sessionId, messages } body (plus the incoming Request) and dispatches to one of seven paths, returning a web Response with an SSE body.

Signature ​

typescript
function handleChatStream(
  deps: HandleChatStreamDeps,
  params: HandleChatStreamParams
): Promise<Response>;

HandleChatStreamDeps ​

typescript
interface HandleChatStreamDeps {
  executor: MinimalExecutor;
  stateStore: MinimalStateStore;
  streamManager: MinimalStreamManager;
  /**
   * `AnyAgentConfig` for in-process integrations; pass a
   * `MinimalAgentConfig` (`{ name, type }`) when the agent runs in a
   * remote runtime and only routing info is available.
   */
  agent: AnyAgentConfig | MinimalAgentConfig;
  /** Optional structured logger. Defaults to noop. */
  logger?: Logger;
  /** Forwarded to the StreamTransformer when streaming back to the client. */
  transformerOptions?: StreamTransformerOptions;
  /** Max accepted request body size (default 10 MB). Checked against Content-Length. */
  maxRequestBytes?: number;
  /**
   * Fail-safe bound (ms, default `DEFAULT_LIVE_WAIT_TIMEOUT_MS` = 30 000) on
   * waiting for a live run's stream to go live in the run-start window. Past
   * it: 503 `{ code: 'transport_stream_drop', cause: 'run_not_live' }`.
   */
  liveWaitTimeoutMs?: number;
  /**
   * The options your snapshot route passes to `buildSnapshot`
   * (`generateId`, `includeReasoning`, `filterHidden`, `includeToolResults`).
   * A `replayPrefix` resume replays the message as that route renders it, so
   * they must match.
   */
  snapshotOptions?: Partial<SnapshotOptions>;
  /**
   * Bound (ms, default 10 000) on the replay-prefix read's generic fenced
   * snapshot read (core `readSnapshotView`'s `deadlineMs`); pass your
   * snapshot route's `BuildSnapshotDeps` value. A read pass has no bound of
   * its own; a read still running at the deadline issues no further store
   * call and fails with the retryable `state_history_incomplete` /
   * `snapshot_unstable`. Unused when the store reads the view itself (the CF DO).
   */
  snapshotReadDeadlineMs?: number;
  /**
   * #177: the host's state patch for a new turn (an RFC 7396 JSON Merge Patch
   * of `customState`). Called once per request that sends a new message, before
   * its entry; the patch reaches that entry (`execute()`'s input on a fresh
   * turn, the `with_message` resume of an abandon-and-send). Never read from
   * the request body, and never called for a submit-only request or a resume.
   */
  turnStatePatch?: (ctx: {
    request: Request | undefined;
    sessionId: string;
    /** No stored session was found for this request. */
    isNewSession: boolean;
  }) => JsonMergePatch | undefined | Promise<JsonMergePatch | undefined>;
}

The patch is applied once to the turn's entry state and committed with its input; see State: per-turn state. Temporal and DBOS refuse a non-empty patch (framework_not_supported) until #177 MR 2.

handleChatStop (CP-12) ​

The chat stop route for a host that runs its own executor (runtime-js, CF Workflows, Temporal, DBOS). A Cloudflare DO host wires CloudflareChatHandler.stop instead: same wire, same answers.

typescript
handleChatStop(deps: HandleChatStopDeps, params: { sessionId: string; request: Request }): Promise<Response>;

interface HandleChatStopDeps {
  executor: { stop?(sessionId, { kind, reason?, agent? }): Promise<StopAnswer>; getHandle(agent, sessionId) };
  stateStore: Pick<SessionStateStore, 'loadState' | 'getCurrentRun' | 'getRun' | 'readStop'>;
  agentFor(agentType: string): AnyAgentConfig | undefined;
  /** REQUIRED: a stop is a write. Run the same auth / session-ownership check as the send route. */
  authorize(input: { request: Request; sessionId: string }): Promise<{ ok: true } | { ok: false; response: Response }>;
  /** Default ['interrupt']: abort (non-resumable) must be opted into. */
  allowedKinds?: readonly ('interrupt' | 'abort')[];
  logger?: Logger;
}
  • The order: authorize runs first (its refusal response is returned as is). Then the optional JSON body { kind?, reason? } is read under MAX_STOP_REQUEST_BYTES (4096 BYTES, core readRequestText: a larger body, declared or streamed, is the typed 413 validation_payload_too_large, cause: 'request_body', never buffered in full) and validated (empty means a default interrupt). A malformed body or a kind outside allowedKinds is a typed 400 validation_error.
  • The stop: the session's current live run is stopped through core stopSession. That is the executor's stop when it has one, else the run's handle; agent-server shares the same function.
  • The answer: core stopAnswerResponse, at once. 202 { status: 'stopping', runId, kind }, 200 { status: 'interrupted' | 'failed', runId }, 409 state_not_stoppable, 404 state_session_not_found, a typed 503.
  • MinimalExecutor gains an optional stop? (runtime-js has it).
  • Express: expressRequestToWebRequest(req) (from @helix-agents/ai-sdk/adapters/express) builds the Request (method, URL, headers, the express.json() body) for createWebResponseExpressMiddleware. The JSDoc carries a full route example.

HandleChatStreamParams ​

typescript
interface HandleChatStreamParams {
  sessionId: string;
  /** Incoming AI SDK v6 messages. Only the tail is inspected. */
  messages: readonly UIMessage[];
  /** Forwarded to executor.execute as `userId`. */
  userId?: string;
  /** Forwarded to executor.execute / resume as metadata. */
  metadata?: Record<string, string>;
  /**
   * Optional incoming Request. When provided, the orchestrator extracts
   * `X-Resume-From-Sequence` / `Last-Event-ID` / `X-Existing-Message-Id`
   * from its headers — required for path 5 (active-stream attach) and
   * path 6 (already-completed retry). Its `signal` is the default client
   * signal (below).
   */
  request?: Request;
  /**
   * The client's abort signal, for callers without a `Request`. (Express:
   * `createWebResponseExpressMiddleware` hands its handler a `request` whose
   * signal is this one; pass that `request`.) Defaults
   * to `request.signal`. When it aborts, the stream stops: the runtime's
   * upstream reader is closed and the response body ends. The run itself is
   * not affected, and a reconnect resumes as usual. On Cloudflare Workers the
   * incoming `request.signal` only fires with the `enable_request_signal`
   * compatibility flag.
   */
  signal?: AbortSignal;
  /**
   * Explicit override for `X-Resume-From-Sequence`. Without one, a resume
   * replays the current run from its start.
   */
  resumeFromSequence?: number;
  /** Explicit override for `X-Existing-Message-Id`. */
  existingMessageId?: string;
  /**
   * Explicit override for `X-Helix-Replay-Prefix: 1`: the client removed the
   * `existingMessageId` message before resuming, so the resume first replays
   * that message's committed prefix (see below).
   */
  replayPrefix?: boolean;
  /** Fork the session from a checkpoint or a prior turn (`executor.execute({ branch })`). */
  branch?: BranchOptions;
  /** Initial state for a fresh session (`AgentInputObject.state`); ignored, with a warn, once the session has messages. */
  initialState?: unknown;
  /** History override for a fresh session (`AgentInputObject.history`). */
  history?: readonly Message[];
  /**
   * D-2: the request's idempotency key. Wins over the request's `X-Helix-Request-Key`
   * header. Read only for a request that carries messages; a reconnect ignores it.
   */
  requestKey?: string;
  /**
   * The span this request's run parents on (an OpenTelemetry `Context`, typically the route's request span).
   * Forwarded on every entry the request sends: execute(), resume(), and submitToolResult()'s 2nd argument.
   */
  telemetryParent?: Context;
}

telemetryParent puts a chat turn's run in the request's trace. Every entry the request sends carries it as EntryTraceOptions.telemetryParent:

  • execute(): a send, or path 4's fallback after the abandonment submits;
  • resume(): path 3's caller_resumes, and path 4's with_message;
  • submitToolResult(params, { telemetryParent }). On the Cloudflare DO, a submit starts the new_run continuation, which parents on the submit's request.

It is server-side only: nothing on the wire changes for a stock useChat client. Without it every call keeps its shape. agent-server's POST /chat passes its SERVER span to the chat handler as telemetryParent. On Cloudflare, createCloudflareChatHandler forwards it to DOFrontendExecutor's per-call parent. A reconnect sends no entry, so it uses no parent.

The seven dispatch paths ​

  1. Fresh new session → executor.execute()
  2. Continuing session, new user message → executor.execute() with sessionId
  3. Resume after tool / approval submit → submitToolResult × N, then act on the last answered submit's continuation (below): executor.resume() only for caller_resumes
  4. Abandonment recovery → on an executor that declares supportsAbandonOnResume, no submit before routing: ONE resume({ mode: 'with_message', message, abandonPending: true }) entry for a session suspended on client calls alone, the typed 409 state_already_running for one that also awaits sub-agents; otherwise fail every pending tool, then path 2 (or the 409 when a submit's continuation shows a run took the session over)
  5. Active-stream attach → passive subscriber on a running run (a request with no new user message only; a send is never an attach, CP-13)
  6. Already-completed retry → terminal stream replay
  7. Stale runId rejection → 409 state_not_resumable (cause: 'stale_run') when no answer of the request applies (upgrade guide); a stale answer for a still-pending call beside a stored one is 409 answer_not_stored (case C below)

Paths 1 and 2 are sends: the only paths that call executor.execute(), chosen because the request carries a trailing user message. A send's response streams only its own run's chunks (D-2 MR 1.5): it starts at that run's startSequence and ends if a later run of the session starts on the stream. The run comes from the handle when it names one with its start (MinimalExecutionHandle.runId + startSequence, as the CF DO's handle does), else from stateStore.getCurrentRun() after execute(), which must not be the run current before the send and, when the handle names a run, must be that run. A run that cannot be identified answers the typed transport_error with the input committed: re-read the snapshot (useHelixChat().retry()), do not send again. MinimalStateStore.getCurrentRun is required for this. A send refused because a run is already active (AgentAlreadyRunningError, for example a Temporal turn whose workflow has ended its stream but not closed yet) answers HTTP 409 { error, code: 'state_already_running', retryable: true }. It never attaches to that 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 with parseHelixChatError(chat.error) and retry with useHelixChat().retry() (the same request key) or, on a stock useChat, chat.regenerate(). Requests without a new user message (a reconnect, a passive attach) and tool-result resumes still attach to the active run.

A run-start commit rejected with RunStartRejectedError (expected_mismatch_idle: the run the entry expected is no longer current, and none is live) is retried once. A run-status mismatch (expected_run_status_changed: the run the entry saw has moved on) is typed as AgentAlreadyRunningError before it reaches the handler, so it answers 409 state_already_running. Any other RunStartRejectedError (for example idempotency_mismatch, or entry_status_changed when the session changed under the entry, such as an /abort) answers 409 { error, code: 'state_run_start_conflict', cause } instead of attaching.

Keyed sends (request keys) ​

A request that carries messages reads its request key (D-2) from params.requestKey, else the X-Helix-Request-Key header. A malformed key is answered 400 validation_error before anything is read, and the key is never echoed. A request without messages (a reconnect) never reads the header.

  • A duplicate of the current run. When the session's CURRENT run holds the key, the handler answers the send itself, before any routing and without calling the executor. It compares the send's input, by digest, with the input that started the run, in both of the forms a send can take (executeRequestDigest and the abandon-and-send resumeRequestDigest), because the route a send takes depends on session state that the first request changed.
    • Same input: the response attaches to that run, under its response message id (msg-<startUIMessageCount>) and from its startSequence: a suspended run's stream, or a finished run's terminal replay. A running run goes to the executor instead when it declares supportsRequestKey (the send is routed on with its key): only the runtime can tell a live owner from a dead one, and it attaches to a live run or takes a dead owner's run over. Without such an executor the handler attaches to the running run's stream. None of this writes anything, and the busy refusal (CP-13) and the suspended-run refusal (O-70) do not apply: a retried send gets the original reply, not a 409.
    • Other input: 409 { error, code: 'state_request_key_conflict', runId }.
  • Otherwise the key is forwarded on the send's execute() (paths 1 and 2) and on the path-4 resume({ mode: 'with_message', abandonPending: true }), but only to an executor that declares supportsRequestKey: true. Any other executor gets the request keyless (a debug log says so). The executor plans the key: a key held by a finished run with a later run after it is 409 state_request_already_completed (body runId), and a duplicate that raced the original returns a handle with attached: true. The response then streams from the attached run's own startSequence (read with getCurrentRun), not from the head read before execute().
  • Tool-result requests (path 3) stay keyless in this release: a repeated submit is already idempotent, and its continuation attaches to the run that carries it.

The resume path acts on the submit's continuation ​

Each submit answers accepted / already_completed with a required continuation (core SubmitContinuation, decided by the runtime). Path 3 acts on the last answered one and never re-reads the session to infer it:

continuationResponse
awaiting_batch / consumednothing continues on this request: a 200 that ends the client's tool step (start, start-step, any rejections, finish-step, finish; CP-180)
caller_resumesexecutor.resume({ mode: 'continue' }), streamed from the pre-submit head; a classified refusal is the typed answer with inputCommitted (the answer is stored), never 503
new_run / deferredno resume(): attach to the session stream from new_run.startSequence, else the pre-submit head, with waitForLive; a live-wait timeout is the NON-retryable 500 { code: 'transport_stream_drop', cause: 'run_not_live' }

The pre-submit head is read without masking a failure: an unreadable head never attaches from sequence 0; it is answered 500 transport_error (inputCommitted), after resume() ran on caller_resumes. deferred never waits for its blockingRunId to settle: the run that carries the continuation streams on the same session stream.

The attach is pinned to the run that carries the continuation (new_run.runId, deferred.blockingRunId) and goes live only when the attached run's own run_started (carrying that runId) is read on the stream, never on the first chunk past the cursor or the stream turning active: another run's chunks can come first (DBOS's continuation writes under the suspended run before its continuation commits, FU-DBOS-CONTINUATION-ENTRY-ORDER). Before it, another run's tool outputs go through the transformer's foreign-output rule (forwarded to the trailing message when it holds the call, else a data-tool-output-elsewhere trigger) and its other chunks are dropped; another run's run_started first re-anchors (stamped with that run). The wait shares the live-wait bound and its timeout answer; the bound covers any work the runtime does before the attached run's run_started. An attached run that the decision before the attach already reads as the committed, live current run, with its recorded startSequence at or below the attach's cursor, is proven committed by that record. startSequence is the head the run's entry READ for its run-start commit (not taken atomically with the commit), and the attach reads only chunks past the cursor, so it reads no chunk that head read could see. The attach then does not require the run's own run_started (it may be behind the cursor, past it, or never written, by a runtime-js / CF DO run recovered between its run-start commit and its run_started): it goes live on the first chunk past the cursor, within the same bound and with the same exits, so a run with nothing past the cursor is never an open, empty response. The residual: a chunk another writer adds between that head read and the run's first own chunk (before the run's claim fences the stream) is not filtered on this path; no current writer does that on a submit continuation. This is the normal path for an already-running CF Workflows __submit__ run or a CF DO wake that the decision already reads live (both answer with the run's own startSequence as the cursor). A record-proven attach to a run that had already written content before the cursor paints only its content past the cursor into the trailing message; the client gets the rest on its next snapshot read. A stream that ends cleanly before the attached run goes live re-anchors (undecided). One that FAILS first (its status is failed, not merely an earlier error chunk: a sub-agent's terminal error rides the session stream without failing it) is decided as it was before the pin: with the attached run current, its failure streams into the trailing message (data-error-metadata + error); with another run current, the response re-anchors, stamped with that run. A failed stream that a newer run has already reclaimed (made live again) by the time the handler reads its status counts as a clean end: it re-anchors (undecided, stamped with the attached run), and the snapshot carries both. Both live proofs share these exits.

Known regression on DBOS approved tools (W1 MR2b, until FU-DBOS-CONTINUATION-ENTRY-ORDER lands)

On DBOS an approved tool runs under the suspended run, before its continuation run's run_started, and the attach goes live only on that run. So (a) the approved tool's live frames (state_patch, custom ctx.emit events, subagent_*) are not streamed: they reach the client only through the snapshot (the re-anchor, retry() or a reload); and (b) an approved tool that runs past the 30 s live-wait bound (liveWaitTimeoutMs) gets the non-retryable 500 run_not_live, and the client must call retry(). Client-tool continuations are unaffected. This is a regression MR2b introduces; it streamed live before.

Path 4 as one entry (W1 MR2b, D11; S-B). A send (a request with a new user message) never submits anything before it is routed on an executor that declares MinimalExecutor.supportsAbandonOnResume (CF Workflows, runtime-js, the CF DO): on a self-waking runtime the first submit that completes a batch starts the suspended run's continuation (the CF DO wakes inside the submit request), which then refuses the send, and its message was never written (the writer-chat incident, 2026-10-17). On those executors:

  • a session suspended on client calls alone (core planWithMessageEntry) is ONE resume({ mode: 'with_message', message, abandonPending: true }): its drain answers every unanswered call — a lapsed one client_tool_timeout, the rest client_tool_abandoned — the message follows the results, and one model call runs. The response streams from the entry run's own startSequence (read with getCurrentRun after the entry, never the pre-entry head); an unreadable start is the typed transport_error with inputCommitted. A trailing user message with no text or file content is the typed 400 validation_error, as on paths 1 and 2;
  • a session paused on client calls that ALSO awaits sub-agents (core suspendedStatusOf, the same decision the entry makes) is refused before any write: the typed 409 state_already_running (no submit, no execute()). useHelixChat keeps the message in refusedSend and retry() sends it once the run ends (O-71); isRefusedSendError(chat.error) is that same test for an app. The entry refuses the same session with the same busy code (core planWithMessageEntry), so a session that turned mixed after the handler's read is held too. On the CF DO the handler reads the session through the DO's /snapshot wire, which carries the awaited children (suspendedAwaitingChildren). The runtime's own with_message entry now takes such a message (CP-237: the park is kept and the model input pairs the children's late results); whether the chat route sends it is FU-CHAT-SEND-OVER-CHILDREN-PARK;
  • pending calls on a session that is not paused take no submit either: a send while the current run is live but suspended is the same 409 without an execute() (O-70, whether or not pending calls exist); otherwise the runtime's own entry (execute()) decides. Over a failed or completed session that still holds unanswered pending calls, runtime-js (and the CF DO) does not yet answer them first, so the model can see a dangling tool_use (FU-EXECUTE-OVER-FAILED-SESSION-UNANSWERED-PENDING).

Temporal and DBOS executors (no supportsAbandonOnResume) keep the per-call abandonment submits followed by execute(), acting on each submit's continuation: a new_run or deferred answer (a run took the session over) is the typed 409 state_already_running instead of an execute() the runtime would refuse; the abandoned results stay stored (FU-TEMPORAL-DBOS-MIXED-SEND-ABANDONS-BEFORE-REFUSAL). Nothing waits for the session to leave paused.

The deadline sweep (findExpiredPending, below) runs only for a request without a new user message.

The submit response contract (CP-180, CP-179, CP-171, CP-172) ​

Every path-3 request ends in one of three answers (upgrade guide):

  • A, streamed: the continuation streams into the client's trailing message (caller_resumes, a live attach). Its content opens a new step.
  • B, nothing continues for this client: awaiting_batch, consumed (also a re-sent answer for a call that already drained, CP-172), or an attach that re-anchors. A 200 that ends the client's tool step: start (the trailing message's id; left out when the request has none) → start-step → any data-resume-rejected (calls pending nowhere, a stopped run's) → the re-anchor triggers (data-tool-output-elsewhere) → finish-step → finish. ai's stock sendAutomaticallyWhen predicates read only the parts after the last step-start, and ai asks them again after every non-error finish: without the boundary the answered step stays complete and a stock useChat re-sends the same request forever. With it the step they read is empty, so the request is sent once. The empty step stays on the client's copy of the message (the snapshot never has it).
  • C, an answer that did not land: an answer for a call the server still awaits (its session's pending calls, or a call a sub-agent owns) was not stored, because it was rejected (stale-run, a malformed approval id: CP-179) or its submit failed transiently, while a sibling answer of the request was stored (or already was). 409{ error, code: 'state_not_resumable', cause: 'answer_not_stored', toolCallIds }, non-retryable. The client re-reads the snapshot (useHelixChat().retry()), where those calls still await their answers. A submit that fails transiently (other than state_submit_owner_not_ready, below) is re-submitted ONCE first: the runtime stores an answer before it re-reads the owner and starts the continuation, so a submit can fail after its answer was stored. A stored answer's re-submit answers already_completed with the continuation decided then (it counts as answered, and a batch it completes continues); only a re-submit that fails again is case C. A submit that failed state_submit_owner_not_ready (CP-253: its call has not committed; it waited submitOwnerReadyBoundMs, 5 s, and stored nothing) is not re-submitted, and is answered 503 alone or case C after a stored sibling. On either answer a stock useChat ends the request error and, per this handler's doc comment (not verified against ai itself), ai does not auto-send after an error, so the turn waits on the user (chat.regenerate()) or on useHelixChat().retry(); the call stays pending. Several owners that are all still committing wait one after another, up to 5 s each.

Every answer of the request is submitted before the request is answered (CP-171): a failed submit never drops the later answers. A submit refused as sent (a classified, non-retryable error, e.g. validation_error, an oversized answer) is then answered as itself, over a transient failure; with nothing stored by the request, a transient failure keeps its own retryable answer.

Resuming from a snapshot cursor ​

A resume streams the chunks after resumeFromSequence (the snapshot's streamSequence) under existingMessageId (the snapshot's resumeMessageId). With replayPrefix (X-Helix-Replay-Prefix: 1) the client has removed that message from its seed. The handler then reads ONE snapshot (with deps.snapshotOptions), streams that message's committed parts first, between transient data-helix-replay{ phase: 'begin' | 'end', messageId } markers, and then the tail after that snapshot's cursor. This renders the message once on every ai version. Only a client-supplied existingMessageId is replayed. An id the snapshot does not hold (and that is not the live run's response id) answers 409 state_not_resumable with cause: 'resume_message_unknown'.

A resume that arrives in the run-start window (the current run committed but has not claimed its stream yet) waits for the run to go live instead of replaying the previous turn. It waits on the stream manager's wake-up path (ResumableReaderOptions.waitForLive), bounded by liveWaitTimeoutMs. The default is exported as DEFAULT_LIVE_WAIT_TIMEOUT_MS (from @helix-agents/ai-sdk), so a caller that budgets a whole request (a client-tool submit that attaches to its continuation, for example) can derive its deadline from the handler's own bound.

On a resume, attach or terminal replay, the content of superseded LLM attempts (a Temporal activity retry or a CF Workflows step.do retry streams the step again under a new attemptId) is dropped. If the client was already sent superseded content, the response carries a data-attempt-superseded event so the client re-anchors.

Typed refusals ​

Every typed body is user-safe (SDK-5). error is the user-safe sentence for the body's code (core userMessageFor(code), "Something went wrong." for an unmapped code): render it. detail is the developer text (the error's own message, scrubbed by core sanitizeErrorMessage): log it, never render it. Below, { error, code } stands for { error, code, detail }. A stock useChat's chat.error.message is the whole JSON body (its transport throws new Error(await response.text())), detail included, so a stock app renders parseHelixChatError(chat.error)?.error ?? <its own fallback>, never chat.error.message.

CaseStatusBody
Send while another run is active409{ error, code: 'state_already_running', retryable: true }
Run-start commit rejected (other than idle mismatch)409{ error, code: 'state_run_start_conflict', cause }
Malformed request key on a request with messages400{ error, code: 'validation_error' } (the key is not echoed)
Keyed send: its key used for other input409{ error, code: 'state_request_key_conflict', runId }
Keyed send: its run ended and a later run exists409{ error, code: 'state_request_already_completed', runId }
Send whose entry failed before its run-start commit400 / 503 / 409 / 500{ error, code }: core typedErrorResponse (see below)
Send whose run committed, then execute() threw409 / 500{ error, code }, never 503 (assumes a consistent run read after the commit; see below)
A session / run / snapshot read throws a HelixError before this request's input is committed503 / 409 / 500{ error, code, cause? }: core typedErrorResponse, the error's reason as cause
A read throws a HelixError after this request's input is committed (e.g. the send's response-message-id re-read)409 / 500{ error, code }, never 503
Resume / submit failed typed (path 3)400 / 503 / 409 / 500{ error, code }: core typedErrorResponse; never 503 once this request stored a result
replayPrefix for a message the snapshot lacks409{ error, code: 'state_not_resumable', cause: 'resume_message_unknown', retryable: true }
Committed prefix could not be read503{ error, code, retryable: true } (e.g. state_history_incomplete)
Run never went live within liveWaitTimeoutMs503{ error, code: 'transport_stream_drop', cause: 'run_not_live', retryable: true }
A submit's continuation never went live (path 3)500{ error, code: 'transport_stream_drop', cause: 'run_not_live' }: non-retryable (Q12)
Pre-submit head / path-4 run start unreadable500{ error, code: 'transport_error' }: the input is committed; re-read the snapshot
A submit stored its answer, but the continuation it owes did not start (the CF DO retries it on its own)500{ error, code: 'transport_continuation_pending', detail, cause? } (cause: the run's code): re-anchor, never re-submit (SDK-5)
Request body over maxRequestBytes413{ error, code: 'validation_payload_too_large', retryable: false, cause: 'request_body', limitBytes, sizeBytes }
A submitted answer over the executor's limit (DI-51)413{ error, code: 'validation_payload_too_large', retryable: false, cause: 'tool_result', limitBytes, sizeBytes }: nothing stored
A submitted answer that is not a valid submit400{ error, code: 'validation_error' }: fixed message naming the call; nothing stored
A submitted answer the store refuses as too large to hold (its stored pending map would exceed the limit, MA-31)413{ error, code: 'state_size_exceeded', retryable: false, cause, limitBytes, sizeBytes } (cause: the operation); nothing stored

Submits are validated before any is stored (DI-51). Every answer of a request is size-checked against the executor's own limits (executor.submitLimits; the executor owns them, the route configures none) and then shape-checked (core submitToolResultSchemaFor of those limits). One refusal answers the whole request with the typed 4xx above, nothing stored, nothing streamed; the calls stay pending. An executor that declares no limits is held to core's defaults (resolveSubmitLimits(): 1 MB result), never unbounded. The CF DOFrontendExecutor declares the limits of its submitToolResultSchema option (pass the DO's own); a DO whose limit is lower still answers its own typed 413, which is answered with the same status and body. submitToolResultErrorResponse (from @helix-agents/ai-sdk, also /cloudflare) relays such a typed refusal (any validation HelixError) for a custom route. On the client, parseHelixChatError(chat.error) reads { code, retryable, cause, limitBytes, sizeBytes }, and the Helix transport throws HelixPayloadTooLargeError (code: 'validation_payload_too_large', its cause / limitBytes / sizeBytes), except for a 413 state_size_exceeded (the store's limit, not the submit limit), for which it throws core's StateSizeExceededError rebuilt from the body, non-retryable, and makes no further request. See the submit size limit.

A send whose entry fails before its run-start commit wrote nothing (an entry that fails before its run-start commit leaves a session that already has a run untouched), so the session's stream still ends with the previous turn. The SEND is answered with the typed error instead of a stream that would replay that turn. The failure reaches the handler as a HelixError from execute() (a history read, a completion-support refusal, the CF Durable Object's typed /start body via DOFrontendExecutor) or as a handle with preCommitFailure (runtime-js's first history read, CF Workflows). The status follows core httpStatusForHelixError: 400 for a caller error (a completion refusal, validation_error), 404 for a missing session or checkpoint (state_session_not_found, state_checkpoint_not_found; a foreign checkpoint is answered exactly as a missing one, its detail logged at warn through deps.logger), 503 for any other retryable error, 409 for a non-retryable state_* error, else 500. The body is exactly { error, code } with the message scrubbed (no retryable field: 503 ⇔ retryable). If the session's current run changed while execute() ran (the entry's run committed, then something after it failed), a retryable error is answered with its non-retryable row (409 for state_*, else 500), never 503, because re-sending the message would append it twice. A failed run read counts as committed. The check re-reads the session's current run after execute() throws, so it assumes a store read after a commit is consistent (read-your-writes), which the stores as wired today give (memory, a primary Redis or Postgres, D1 without read replication, the CF Durable Object's store client). A lagging read (a read replica) can return the previous run for an entry that did commit, and that send is then answered 503. parseHelixChatError recovers the body on the client.

A resume (path 3: the request carries client-tool or approval results) answers the same typed error, with the same status table and the X-Session-Id header, before any stream opens, when executor.submitToolResult() or executor.resume() rejects with a HelixError (a completion-support refusal, framework_completion_strategy_unavailable / framework_completion_schema_unsupported, is a 400; a pre-commit state_history_incomplete a 409) or resume() returns a handle with preCommitFailure. A submit failure ends the request there (no resume), and a typed error replaces any per-intent rejections (an earlier intent's plain-Error submit failure, a stale intent). Once this request stored a submitted result, a retryable error is answered with its non-retryable row (409 / 500), never 503, since re-sending would submit the answer again. A submit whose continuation is new_run or deferred is never followed by a resume() (the runtime already continues the turn), so the old auto-resume reattach is gone. An unclassified error from either call (a plain Error, a non-Error) is framework_internal_error (upgrade guide). Before this release a typed resume / submit failure was also that 200 data-resume-rejected, with no code (upgrade guide).

One classification for send, resume and submit (WA MR1). The send catch, the resume() catch and the resume path's submit loop share one step: a failure core can classify (a HelixError, a raw core error with a known ErrorCode such as RunSupersededError, a RunStartRejectedError, an AgentAlreadyRunningError, an error carrying an attached ErrorDetail, or a wrapper whose cause chain holds one, followed to depth 8) ends the request with the typed answer above: the busy 409 for a busy refusal (matched by name, or classified state_already_running), 409 state_run_start_conflict with its cause for a rejected run start, else typedErrorResponse. An unclassifiable error (a plain Error with no code, a thrown non-Error) is framework_internal_error: retryable (503) before this request committed anything, its non-retryable row (500) after, logged at error level with the original error (upgrade guide).

Client-visible text is scrubbed. Every free-text field the handler writes for a failure goes through core sanitizeErrorMessage / sanitizeErrorDetail, with code and shape kept: the busy 409's error, the typed answers' error, the failed-stream 409's message and errorDetail (every level of its cause chain), and the errorText of the error chunk the handler writes when the transformer itself throws. A cause reaches the wire only when it parses with core runStartRejectCauseSchema() (every reject cause, consumed_pending_changed and request_key_taken included); any other cause is dropped. The run's own error text the transformer forwards (an error chunk, a failed tool_end, a tool's input / output error) is already sanitized: every stream manager stores only sanitized error text (core). Nothing is HTML-escaped.

A chat resume refused after its answer was stored (runtime-js). A runtime-js submit never refuses: it stores the answer on the pending call (pendingClientToolCalls[id].submittedResult; the role: 'tool' row lands only when a resume drains it), and then the resume is refused with the 400. The stored answer is valid history and is kept: the next resume on an adapter that can finish the agent continues from it. The CF Durable Object refuses the submit itself, before writing it, so there the call stays pending.

Example ​

typescript
import { handleChatStream } from '@helix-agents/ai-sdk';

export async function POST(req: Request, { params }: { params: { sessionId: string } }) {
  const body = await req.json();
  return handleChatStream(
    { executor, stateStore, streamManager, agent: MyAgent },
    {
      sessionId: params.sessionId,
      messages: body.messages ?? [],
      request: req,
    }
  );
}

prepareHelixChatRequest ​

A PrepareSendMessagesRequest factory for use with the AI SDK v6 DefaultChatTransport. Forwards AI SDK fields (id, messages, trigger, messageId) onto the request body and stamps X-Resume-From-Sequence / X-Existing-Message-Id headers the chat orchestrator uses to dispatch.

Signature ​

typescript
function prepareHelixChatRequest<UI_MESSAGE extends UIMessage = UIMessage>(
  opts: PrepareHelixChatRequestOptions
): PrepareSendMessagesRequest<UI_MESSAGE>;

PrepareHelixChatRequestOptions ​

typescript
interface PrepareHelixChatRequestOptions {
  /** API endpoint path. Mirrors DefaultChatTransport's `api` field. */
  api: string;
  /** Sequence to resume from (X-Resume-From-Sequence). A getter is read per request. */
  resumeFromSequence?: number | (() => number | undefined);
  /**
   * Assistant message id to continue (X-Existing-Message-Id). A getter is read
   * per request. A fresh-turn POST ignores it on the server.
   */
  existingMessageId?: string | (() => string | undefined);
  /**
   * Reconnect only: send `X-Helix-Replay-Prefix: 1`. Set it when the chat is
   * seeded with `snapshotResumeOptions(snapshot).messages` (the snapshot
   * WITHOUT the resume message). Default `false`.
   */
  replayPrefix?: boolean;
  /** Static body fields merged underneath the AI SDK fields. */
  body?: Record<string, unknown>;
}

Example ​

typescript
import { DefaultChatTransport } from 'ai';
import { prepareHelixChatRequest } from '@helix-agents/ai-sdk/client';

const transport = new DefaultChatTransport({
  api: '/api/chat/abc',
  prepareSendMessagesRequest: prepareHelixChatRequest({
    api: '/api/chat/abc',
    resumeFromSequence: snapshot.streamSequence,
    existingMessageId: lastAssistantId,
  }),
});

prepareHelixReconnectRequest ​

A PrepareReconnectToStreamRequest factory. The Helix chat handler reuses ONE path for both POST (new messages) and GET (stream resume), so the AI SDK's default reconnect URL — ${api}/${chatId}/stream — does not match the route. Without overriding the reconnect, page-refresh-during-stream silently 404s and any tool that hadn't completed stays pending in the UI forever.

Signature ​

typescript
function prepareHelixReconnectRequest(
  opts: PrepareHelixChatRequestOptions
): PrepareReconnectToStreamRequest;

Same options shape as prepareHelixChatRequest. Per-request headers win over the configured values, so chat.resumeStream({ headers: resumeHeaders(target) }) resumes at a re-anchor's cursor even when the configured values are the mount-time ones.

Example ​

The canonical client seeds the chat with snapshotResumeOptions(snapshot) and resumes only when the snapshot is live:

typescript
import { DefaultChatTransport } from 'ai';
import {
  prepareHelixChatRequest,
  prepareHelixReconnectRequest,
  snapshotResumeOptions,
} from '@helix-agents/ai-sdk/client';

const opts = snapshotResumeOptions(snapshot);

const transport = new DefaultChatTransport({
  api: '/api/chat/abc',
  prepareSendMessagesRequest: prepareHelixChatRequest({ api: '/api/chat/abc' }),
  prepareReconnectToStreamRequest: prepareHelixReconnectRequest({
    api: '/api/chat/abc',
    resumeFromSequence: opts.resumeFromSequence,
    existingMessageId: opts.existingMessageId,
    replayPrefix: opts.replayPrefix,
  }),
});
// useChat({ id, transport, messages: opts.messages, resume: opts.resume })

useResumeClientTools ​

React hook that auto-dispatches client-executed tool calls surfaced by AI SDK v6 useChat and reports the results back to the chat. Replaces ~280 LOC of consumer dispatcher boilerplate.

Signature ​

typescript
function useResumeClientTools(options: UseResumeClientToolsOptions): void;

UseResumeClientToolsOptions ​

typescript
interface UseResumeClientToolsOptions {
  chat: ResumeClientToolsChat; // Returned by useChat()
  toolHandlers: Record<string, ResumeClientToolHandler>;
  onError?: (err: unknown, ctx: { toolName: string; toolCallId: string }) => void;
  runLapsed?: boolean | ReadonlyArray<string>; // SDK-3: run lapsed calls anyway (all, or these tools)
}

type ResumeClientToolHandler = (
  input: unknown,
  ctx: {
    toolCallId: string;
    abortSignal: AbortSignal;
    deadlineAt?: number; // SDK-3: the call's deadline (epoch ms, the server's clock)
    lapsed?: boolean; // always set by the hook; true only under runLapsed
  }
) => Promise<unknown>;

Behavior ​

  • Watches chat.messages for tool parts in state: 'input-available' on the LAST assistant message.
  • Dispatches handlers in parallel; each call dispatches at most once per Chat instance.
  • On Chat-instance identity change (e.g. session switch), in-flight handlers are aborted and dispatch state is cleared.
  • Skips approval-requested parts (those route through chat.addToolApprovalResponse) and parts whose tool name has no registered handler.
  • Skips a lapsed call (SDK-3). A client call's part carries callProviderMetadata.helix { deadlineAt, serverNow } (both the server's clock: the deadline, and when the server wrote the frame or read the snapshot). The hook records when each pending part first arrived (receivedAt, the local clock) and estimates the server clock as serverNow + (Date.now() − receivedAt); the call is lapsed once that estimate passes deadlineAt + DEADLINE_MARGIN_MS (1 000 ms). The browser's clock is never compared with the server's, so a clock skew of any size never skips a valid call (transit and a replayed frame only make the estimate earlier). A lapsed call's handler does not run (the server already answered it client_tool_timeout) unless runLapsed covers its tool; then it runs with lapsed: true. A call without a deadline, or without serverNow, never lapses. useHelixChat forwards runLapsed. DEADLINE_MARGIN_MS, clientToolPartDeadline and isClientToolCallLapsed are exported for a custom dispatcher.
  • On success: chat.addToolOutput({ state: 'output-available', tool, toolCallId, output }).
  • On failure: chat.addToolOutput({ state: 'output-error', tool, toolCallId, errorText }) plus onError callback if provided.

Example ​

tsx
import { useChat } from '@ai-sdk/react';
import { useResumeClientTools } from '@helix-agents/ai-sdk/react';

const chat = useChat({ id: sessionId, transport });

useResumeClientTools({
  chat,
  toolHandlers: {
    editContent: async (input, { toolCallId, abortSignal }) => {
      const result = await runOnClient(input, abortSignal);
      return result;
    },
  },
  onError: (err, ctx) => console.error(`tool ${ctx.toolName} failed`, err),
});

extractResumeIntent ​

Parse incoming AI SDK v6 messages to determine which (if any) are resume signals — client-tool results or approval responses — for currently-pending tool calls.

Signature ​

typescript
function extractResumeIntent(
  messages: readonly UIMessage[],
  sessionState: SessionState | null,
  currentRunId: string | undefined
): ExtractResumeIntentResult;

Result types ​

typescript
interface ExtractResumeIntentResult {
  intents: ResumeIntent[];
  rejected: ResumeIntentRejection[];
  hasTrailingUserMessage: boolean;
}

type ResumeIntent =
  | {
      kind: 'client-tool-result';
      runId: string;
      toolCallId: string;
      output?: unknown;
      errorText?: string;
    }
  | {
      kind: 'approval-response';
      runId: string;
      toolCallId: string;
      approvalId: string;
      approved: boolean;
      reason?: string;
    };

type ResumeIntentRejection =
  | { reason: 'stale-run'; runId: string; toolCallId: string; currentRunId: string }
  // `cause: 'run_stopped'` (WA MR2 §7.1): the runtime answered the submit
  // `{ status: 'unknown_tool_call', reason: 'run_stopped' }` — the call's run was stopped.
  | { reason: 'unknown-tool-call'; toolCallId: string; cause?: UnknownToolCallReason }
  | { reason: 'malformed-approval-id'; approvalId: string; toolCallId: string };

handleChatStream's resume path submits each intent; a submit the runtime refused because the call's run was stopped ({ status: 'unknown_tool_call', reason: 'run_stopped' }, WA MR2 §7.1) becomes the { reason: 'unknown-tool-call', toolCallId, cause: 'run_stopped' } rejection, and no resume follows; any other unknown call is { reason: 'unknown-tool-call', toolCallId }. A call a sub-agent owns (the root's clientToolCallOwnership) is a valid answer, submitted like any other (the chat-route half of CP-10). CP-10 is still open: the answer is stored on the owning child, but the continuation resumes only the root, so the child does not continue with it yet. An answer whose call already drained (its completed marker, completedClientToolCalls) is submitted too: core answers it already_completed / consumed (CP-172). When no answer of the request applies, the request is a 409state_not_resumable whose cause (core SubmitRefusalCause) is run_stopped, stale_run or unknown_tool_call (400 validation_error for a malformed approval id). A rejected answer for a call that is still pending, beside a stored one, is case C (answer_not_stored, below); rejections of calls pending nowhere ride the response as data-resume-rejected events (CP-03). The executor's submit answer is Zod-parsed with core submitToolResultResponseSchema() first (as agent-server does): a malformed answer is a typed transport_error, answered as a typed HTTP error, never a per-intent rejection.

Behavior notes ​

  • Only the LAST assistant message is inspected (Mastra invariant — stale resumes from earlier turns are ignored).
  • Only output-available, output-error, and approval-responded part states qualify as resume signals. Other states are skipped silently.
  • runId priority: parsed from ${runId}::${toolCallId} for approvals, then callProviderMetadata.helix.runId for client-tool results, then currentRunId. Mismatches surface as 'stale-run' rejections.

handleChatStream calls this internally; consumers calling it directly are wiring their own custom server.

findExpiredPending ​

Sweep a pendingClientToolCalls map for entries past their deadline.

Signature ​

typescript
function findExpiredPending(
  pendingClientToolCalls: Record<string, PendingClientToolCall>
): string[];

Returns an array of toolCallIds whose deadlineAt has elapsed. Defensive against malformed entries: non-finite deadlineAt values are not treated as expired.

Example ​

typescript
import { findExpiredPending } from '@helix-agents/ai-sdk';

const expired = findExpiredPending(sessionState.pendingClientToolCalls);
for (const toolCallId of expired) {
  await executor.submitToolResult({
    sessionId,
    toolCallId,
    error: 'client_tool_timeout',
  });
}

handleChatStream calls this internally (the deadline-sweep step) for a request WITHOUT a new user message only — a resume or a reconnect — and answers each lapsed call client_tool_timeout, the code the runtimes' own expiry writes. It stops at the first answer whose continuation is a run the runtime started (new_run) or defers to (deferred): that run's drain answers the rest. A call the request itself answers (a client-tool output or an approval response it carries) is never swept, even past its deadline: its real answer is submitted instead, since the first stored answer wins. A request that carries answers is swept only after they are stored, and not at all when they started a run: a sweep submit can complete the batch (a lapsed deadline counts as answered), and on the CF DO its run's drain answers every lapsed call before the submit returns. A send never submits before it is routed (path 4 below).

createHelixChatTransport ​

A Vercel AI SDK v6 ChatTransport that talks directly to the HTTP endpoints exposed by @helix-agents/agent-server (/start, /resume, /submit-tool-result, /status, /sse). Use this when you don't want to implement a single chat route on top of handleChatStream.

Signature ​

typescript
function createHelixChatTransport(opts: HelixChatTransportOptions): HelixChatTransport;

HelixChatTransportOptions ​

typescript
interface HelixChatTransportOptions {
  /** Base URL for the agent-server, e.g. '/api/agent'. */
  endpoint: string;
  /** Session identifier. Stable across the chat lifetime. */
  sessionId: string;
  /** Agent type (required on first /start; ignored on /resume). */
  agentType?: string;
  /** Per-request auth headers. */
  getAuthHeaders?: () => Record<string, string>;
  /** Override fetch (for tests). */
  fetch?: typeof globalThis.fetch;
}

Returned HelixChatTransport ​

typescript
interface HelixChatTransport extends HelixStreamControl {
  sendMessages(options: {
    messages: UIMessage[];
    trigger: 'submit-message' | 'regenerate-message';
    chatId: string;
    messageId: string | undefined;
    abortSignal: AbortSignal | undefined;
    /** Per-call headers (`ChatRequestOptions.headers`), sent with the user message's `/start` / `/resume`. */
    headers?: Record<string, string> | Headers;
  }): Promise<ReadableStream<UIMessageChunk>>;
  reconnectToStream(options: {
    chatId: string;
    abortSignal?: AbortSignal;
  }): Promise<ReadableStream<UIMessageChunk> | null>;
  /**
   * Bypass AI SDK's `addToolOutput` auto-fire timing constraints to
   * submit a client-tool result while the SSE stream is still active.
   */
  submitToolResult(
    params: { sessionId?: string; toolCallId: string; result?: unknown; error?: string },
    abortSignal?: AbortSignal
  ): Promise<SubmitToolResultResponse>;
}

The transport owns every SSE connection's abort, keyed by chatId (see Stream control): a chat's new connection aborts its previous one, and abortActive(chatId) stops a resumed stream. Two chats sharing the transport never abort each other. submitToolResult throws a typed RunSupersededError (from @helix-agents/core) when the server answers 409 { code: 'state_run_superseded' }: the run the result was meant for was superseded by a newer entry. Drop the result and re-read the session. A 404 resolves with the parsed { status: 'unknown_tool_call', reason? } (core submitToolResultResponseSchema()): reason: 'run_stopped' (WA MR2 §7.1) means the call's run was stopped, so nothing was stored; send a new message instead of re-submitting.

The transport reads /sse (stream wire contract v2) through core's codec: end closes the reply with finish; fail becomes the reply's error chunk followed by { type: 'finish', finishReason: 'error' }; truncated errors the UI stream with StreamTruncatedError (refetch the snapshot); a malformed frame errors it with a retryable transport_error; and a body that ends with no terminal frame errors it with the retryable StreamDropError, never a clean finish, unless the transport's own stop aborted the read.

A send that agent-server refuses (/start or /resume answers non-2xx: a 409 state_already_running, a request-key 409, a 400) rejects sendMessages with its body as the message, readable by parseHelixChatError, and opens no /sse: the refused message never shows another run's text as its reply.

Request keys (D-2). A submit-message send's /start or /resume carries an X-Helix-Request-Key: the caller's own, when the per-call headers already hold one (useHelixChat sends one), else a key the transport mints once per user message (by chat id, message id and content; it remembers the last 256), so a retried sendMessages of the same message reuses it and an edited message gets a new key. agent-server answers a repeated send from the run that holds its key, whichever of /start or /resume either attempt took, instead of refusing the existing session (see agent-server).

Example ​

tsx
import { useChat } from '@ai-sdk/react';
import { lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
import { createHelixChatTransport } from '@helix-agents/ai-sdk';

const transport = createHelixChatTransport({
  endpoint: '/api/agent',
  sessionId: 'my-session',
  agentType: 'my-agent',
});

const { messages, addToolOutput } = useChat({
  transport,
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
  async onToolCall({ toolCall }) {
    if (toolCall.toolName === 'editContent') {
      const result = await runOnClient(toolCall.input);
      addToolOutput({
        tool: 'editContent',
        toolCallId: toolCall.toolCallId,
        output: result,
      });
    }
  },
});

This transport is separate from the prepareHelixChatRequest / prepareHelixReconnectRequest helpers, which are for DefaultChatTransport against the in-framework chat handler endpoints (those handlers already serve UIMessageChunks directly).

buildSnapshot ​

Standalone SSR-hydration and resume snapshot helper. Replaces the v7 FrontendHandler.getSnapshot method. A snapshot is read from one checkpoint (breaking; see the upgrade guide): its messages are the committed rows [0, checkpoint.messageCount), its streamSequence is that checkpoint's cursor and its state is the custom state at the cursor. Every commit writes rows and checkpoint atomically, so a client that renders messages and resumes the stream after streamSequence sees each committed chunk once. Content past the cursor (an in-flight step) arrives through the resumed stream, never as a partial message in the snapshot.

Signature ​

typescript
function buildSnapshot<TState>(
  deps: BuildSnapshotDeps,
  params: { sessionId: string } & Partial<SnapshotOptions>
): Promise<FrontendSnapshot<TState> | null>;

// The message-conversion options: generateId, includeReasoning (default true),
// includeToolResults, filterHidden, logger.
type SnapshotOptions = MessageConvertOptions;

Returns null only when the session does not exist. Throws a ConfigurationError when the store can neither read a view itself nor serve the generic reader, and a HelixError with code state_history_incomplete when the history read fails or never stabilizes (reason: short_read, corrupt_row, store_error, snapshot_unstable).

BuildSnapshotDeps ​

typescript
interface BuildSnapshotDeps {
  /**
   * Either reads the whole view itself (`readSnapshotView`, e.g. the DO's
   * `DOStateStoreClient`: one consistent read), or provides `getCurrentRun`
   * and `getMessageCount` for core's fenced generic reader (`readSnapshotView`).
   */
  stateStore: MinimalStateStore;
  streamManager: MinimalStreamManager;
  logger?: Logger;
  /** Accepted so one deps object serves `handleChatStream` too. Unused here. */
  transformerOptions?: StreamTransformerOptions;
  /**
   * W1 MR2b: an executor whose optional `reconcileSession` is called before the view
   * read (CF Workflows: settle a run whose instance closed, start an owed submit
   * continuation). A throw is permanent and rejects `buildSnapshot`.
   */
  executor?: MinimalExecutor | Pick<MinimalExecutor, 'reconcileSession'>;
  /**
   * Bound (ms, default 10 000) on one generic fenced snapshot read (core
   * `readSnapshotView`'s `deadlineMs`). A read pass has no bound of its own;
   * a read still running at the deadline issues no further store call and
   * fails with the retryable `state_history_incomplete` / `snapshot_unstable`.
   * Unused when the store reads the view itself (the CF DO).
   */
  snapshotReadDeadlineMs?: number;
}

BuildSnapshotDeps omits agent (it never routes by agent config). Pass executor to get the reconciliation in snapshot reads; one deps object can serve handleChatStream too. The whole MinimalExecutor is accepted because Pick<…, 'reconcileSession'> alone is a weak type that TypeScript refuses for an executor without the hook (the CF DO's DOFrontendExecutor).

MinimalExecutor (W1 MR2b additions) ​

typescript
interface MinimalExecutor {
  // execute, resume, submitToolResult, … as before. submitToolResult now
  // answers accepted / already_completed with a REQUIRED `continuation`.
  /** Takes a new message over pending client calls as ONE with_message + abandonPending entry (path 4). */
  readonly supportsAbandonOnResume?: true;
  /** D-2: records requestKey; handleChatStream forwards a key only when true (absent: sent keyless). */
  readonly supportsRequestKey?: boolean;
  /** CP-250 (c): `false` (Temporal) keeps a persistent companion's calls out of `FrontendSnapshot.pendingCalls`. */
  readonly supportsCompanionSelfWake?: boolean;
  /**
   * Reconcile the session before a chat read; `changed: true` means the caller re-reads.
   * A throw is permanent. Implemented by the CF Workflows executor.
   */
  reconcileSession?(sessionId: string): Promise<{ changed: boolean }>;
}

handleChatStream calls reconcileSession once, after its first reads, when the current run is live, and re-reads the session and run on changed; a throw is answered through the typed classifier. A custom executor that declares supportsAbandonOnResume must implement the one-entry semantics.

MinimalExecutionHandle.attached?: true (core AgentExecutionHandle.attached, D-2) marks a handle a keyed duplicate got: it names the run that holds the key (the session's current run), and nothing was started. handleChatStream streams it from that run's start. An executor that declares supportsRequestKey: true must plan keys with core planKeyedEntry and return such a handle for a duplicate. A wrapper around an executor should forward both flags.

FrontendSnapshot ​

typescript
interface FrontendSnapshot<TState = unknown> {
  sessionId: string;
  /** Custom state at the cursor (the anchoring checkpoint's state). */
  state: TState | null;
  /** Committed messages [0, checkpoint.messageCount), UI-converted; never partial, never capped. */
  messages: UIMessage[];
  /** The cursor: resume with chunks `seq > streamSequence`. */
  streamSequence: number;
  /** From the current run: a live run is 'active' / 'paused'. */
  status: 'active' | 'paused' | 'ended' | 'failed';
  checkpointId: string | null;
  stepCount: number;
  /** When live: the id of the UI message the run's response streams into. */
  resumeMessageId?: string;
  /** The session's latest `truncate` checkpoint id (`null`: never rewound). */
  latestTruncateCheckpointId?: string | null;
  /** Only when `status` is 'failed': the error that ended the turn (scrubbed). */
  errorDetail?: ErrorDetail;
  /** SDK-5: a submit continuation is owed but has not started yet (no run to resume). */
  continuationOwed?: boolean;
  /** CP-250 (c): calls awaiting the user, routed through this root, that `messages` does not show. */
  pendingCalls?: SnapshotPendingCall[];
}

interface SnapshotPendingCall {
  toolCallId: string;
  toolName: string;
  kind: 'client' | 'approval';
  input: unknown;
  approvalId?: string; // approval only
  ownerSessionId: string; // the session the call's pending entry lives on
  ownerRole: 'awaited_child' | 'companion';
  ownerName?: string; // its name in its parent (companion__spawnAgent's `name`)
  suspendedAt: number;
  deadlineAt?: number; // client only, with serverNow (SDK-3)
  serverNow?: number;
}
  • pendingCalls (CP-250 (c)): buildSnapshot always sets it, and a reader treats it absent as []. It is optional, as continuationOwed is, so a snapshot built elsewhere (a test literal, an older server) still type-checks. It lists the unanswered calls the user can answer through this root that messages does not show. They live on another session of the tree, which the root's clientToolCallOwnership routes their answers to: typically a persistent companion that paused on its own call after its root's turn ended. Its live frames to the ended root stream are dropped (CP-129), and messages is the root's committed prefix, so without this list a reloaded client never saw the call and the companion waited forever. Listed: an unanswered entry of the owner's current live run, owned by a sub-agent (awaited_child) or a persistent companion (companion, core classifySubmitOwner, the submit router's own rule). While the root's run is live, its local sub-agents' calls are rendered into messages instead (CP-186, below) and are not listed again. A companion's calls are not listed when the executor passed to buildSnapshot declares supportsCompanionSelfWake: false: Temporal, which refuses such a submit owner_not_continuable until its self-wake lands (CP-246, FU-TEMPORAL-SUBMIT-SELF-WAKE in the follow-ups). Pass your executor to buildSnapshot on Temporal; without one, such calls are listed and their answers refused. Render the list with pendingCallsMessage; snapshotResumeOptions already appends it to an ended or failed snapshot's seed. Answer a call through the root as usual: a companion's answer wakes the companion in its own run (the response re-anchors: data-tool-output-elsewhere stamped with the companion's run), and its outcome reaches the root's next turn. On CF Workflows a read of the root (reconcileSession) also starts a companion continuation the submit owed but could not start.

  • continuationOwed (SDK-5; buildSnapshot always sets it) is core isSubmitContinuationOwed: the session is paused behind a live run whose answered batch is ready (the rule the runtimes' own owed gates use). Right after a submit answered transport_continuation_pending, there is no run to resume yet: the re-attach controller (useHelixChat, useReanchorOnError) re-reads the snapshot with backoff (1 s, 2 s, 4 s, then every 8 s, at most 2 minutes) while it is true, resumes once the run is live, renders a terminal status, and past the bound reports the typed transport_continuation_pending (offer retry()). The 2 minutes are one deadline for the whole wait, reads included: a slow or hung snapshot read is aborted at it (its signal reaches fetchSnapshotWithRetry, so no retry starts after it). waitWhileContinuationOwed is exported for a custom client; its read gets that AbortSignal.

  • status follows the current run (core deriveSnapshotStatus). It is active / paused for a live run even in the run-start window, before the run has claimed its stream and while the stream still shows the previous turn's ended. Resume on active and paused only.

  • resumeMessageId is generateId(run.startUIMessageCount): the id the live stream's start carries and the id the response gets once committed. Pass it as existingMessageId.

  • latestTruncateCheckpointId lets a client skip a replayed rewind resync the snapshot already covers (resyncCoveredBySnapshot).

  • errorDetail (CP-225) is present only on a failed snapshot: the session's persisted errorDetail, with its free text (message, any cause text) scrubbed by core sanitizeErrorDetail, as in the 409 stream_failed body, and code, category, retryable and the cause structure kept. A reloaded page reads it to show which error ended the turn; the live stream gave a stock useChat only the error text (error.errorText) and a transient data-error-metadata part. It is an additive field: a client that does not read it is unaffected.

  • Pending calls. A live run's calls (active or paused; a run reads running until its suspended status lands, while onAgentSuspended runs) render from the session's pending entries:

    • an unanswered approval is approval-requested with its approval id, and the resumed stream re-sends its request once. An approval part (approval-requested, approval-responded) carries no providerExecuted, as on the live stream, where a server part is flagged only with its output: an approval is the client's to answer, and ai's stock sendAutomaticallyWhen predicates skip flagged parts (CP-182, CP-180). The persisted call's flag only identifies it as an approval;
    • an unanswered client-executed call stays input-available (the client runs it), with callProviderMetadata.helix { deadlineAt, serverNow } (serverNow = the snapshot's read time; none for a call with no timeout), as on the live stream (SDK-3);
    • an answer the server has stored but no run has consumed yet is history (CP-55): a client-tool result is output-available (output-error for a submitted error), providerExecuted: false, and an approval answer approval-responded (unflagged, above). The resumed stream never re-asks it. A reloaded client therefore never runs a handler or shows Approve / Deny again for a call it already answered; with useHelixChat, the seeded answers are history and are not re-sent.
    • a sub-agent's calls render the same way, each placed right after the root's sub-agent call that delegated it (else at the end of the last message):
      • a LOCAL (in-process) sub-agent's call, at any depth (CP-186). Its pending entry lives on the child's own session; the snapshot finds it through the root's clientToolCallOwnership, the map the submit router uses, so it renders a call exactly when the router can take its answer. It is an approval when its entry carries an approvalId, otherwise its kind is read from the child's persisted call (providerExecuted), as for the root's own calls; a call whose kind cannot be told is not rendered (logged snapshot.local_child_call_unclassified). Without this a reload of a runtime-js session left no part for the child's call (its frames precede the root's cursor): ai threw No tool invocation found for tool call ID on the re-sent approval request, and a client call never ran;
      • a REMOTE child's call the run is parked on (remotePendingCalls, CP-157).
  • Removed: timestamp, startSequence, startMessageCount, and the partial-content snapshot option.

  • Legacy mode. A session whose latest checkpoint predates this release (kind: null), or that has no checkpoint, is served with the complete live log and the stream head as cursor, until its first new commit.

Example ​

typescript
import { buildSnapshot } from '@helix-agents/ai-sdk';

const snapshot = await buildSnapshot<MyState>({ stateStore, streamManager }, { sessionId });
if (!snapshot) {
  // session does not exist: answer 404
}

getUIMessages ​

Standalone paginated message-history loader. Replaces the v7 FrontendHandler.getMessages method.

Signature ​

typescript
function getUIMessages(
  deps: GetUIMessagesDeps,
  params: GetUIMessagesParams
): Promise<GetUIMessagesResult>;

interface GetUIMessagesDeps {
  stateStore: MinimalStateStore;
  logger?: Logger;
}

interface GetUIMessagesParams {
  sessionId: string;
  offset?: number;
  limit?: number;
  includeThinking?: boolean;
  generateId?: (index: number, message: Message) => string;
}

Example ​

typescript
import { getUIMessages } from '@helix-agents/ai-sdk';

const { messages, hasMore } = await getUIMessages(
  { stateStore },
  { sessionId, offset: 0, limit: 50, includeThinking: true }
);

createCloudflareChatHandler ​

Factory wrapping handleChatStream / buildSnapshot / getUIMessages with Cloudflare Durable Object adapter clients. Imported from the @helix-agents/ai-sdk/cloudflare subpath.

typescript
import { createCloudflareChatHandler } from '@helix-agents/ai-sdk/cloudflare';

const chat = createCloudflareChatHandler({
  namespace: env.AGENTS,
  agentName: 'chat-agent',
});

// POST /chat
const response = await chat.handleChat({
  sessionId,
  messages,
  request,
});

// GET /snapshot
const snapshot = await chat.getSnapshot({ sessionId });

// GET /messages
const { messages, hasMore } = await chat.getMessages({ sessionId, offset: 0, limit: 50 });

// POST /chat/:id/stop
const res = await chat.stop({ sessionId, kind: 'interrupt', reason: 'user pressed stop' });

handleChat(params) is handleChatStream over the Durable Object clients. Its request is required on every call, sends included: handleChatStream reads the request key (X-Helix-Request-Key; a retried send attaches to its first attempt's run, and a malformed key on a send is a 400 validation_error), the resume headers and the client's abort signal from it. A route that left it out lost the key without an error, so it is now a type error. requestKey overrides the header (a caller whose key is not in a header). Only the request's headers and signal are read; its body may already be consumed.

stop({ sessionId, kind?, reason? }) stops the session's live run and returns the Durable Object's answer as a Response, status and JSON body unchanged. kind is 'interrupt' (the default; the run ends interrupted, resumable) or 'abort' (the run ends failed with framework_cancelled). The answers are 202 { status: 'stopping', runId, kind } (recorded and signalled; the run is still executing), 200 { status: 'interrupted' | 'failed', runId } (resolved by this call), 409 { error, code: 'state_not_stoppable', currentStatus } (no live run), 404 { error, code: 'state_session_not_found' }, and a typed 503. A failed call to the Durable Object is a typed, retryable 503transport_error. The answer does not carry the run's end: observe it on the chat stream (the client's own SSE reader ends with data-run-interrupted, or a failed end with framework_cancelled after an abort) or the snapshot. The snapshot's status reads ended for an interrupted run (its vocabulary has no interrupted), so a client discriminates on the stream's data-run-interrupted marker or the Durable Object's /status. An unknown kind or an empty sessionId is a typed non-retryable 400 validation_error, answered without calling the Durable Object. Authorise the caller in your route; stop does no admission.

telemetry (TelemetryOptions) decides where the handler's Durable Object hops are recorded: the CLIENT spans its three DO clients open per request (POST /start, GET /snapshot, GET /sse, …). By default they go to the global provider, as before. A Worker that registered a global provider for its own spans can turn them off or route them:

typescript
import { ProxyTracerProvider } from '@opentelemetry/api';

// Off: a never-delegated ProxyTracerProvider records nothing.
const chat = createCloudflareChatHandler({
  namespace: env.AGENTS,
  agentName: 'chat-agent',
  telemetry: { tracerProvider: new ProxyTracerProvider() },
});

// Routed: the hops go to a provider of your own.
const routed = createCloudflareChatHandler({
  namespace: env.AGENTS,
  agentName: 'chat-agent',
  telemetry: { tracerProvider: myProvider },
});

It decides spans only. With spans off, handleChat({ ..., telemetryParent }) still carries the request's trace in the traceparent of the executor's hops for that request: its entries (POST /start, /resume, a tool-result submit) and their handles' stream reads. So the Durable Object's run still joins the request's trace. The state store's and stream manager's reads (the snapshot, a reconnect's stream) carry the active context instead: the root context unless the app registered a context manager.

See CloudflareChatHandlerOptions / CloudflareChatHandler in @helix-agents/ai-sdk/cloudflare for the full type surface.

getSnapshot({ sessionId, includeReasoning }) forwards includeReasoning to buildSnapshot. The older includeThinking flag was silently ignored; it is now mapped to includeReasoning (deprecated). On the DO, DOStateStoreClient reads the snapshot view in one request (GET /snapshot); see runtime-cloudflare.

Route error helpers ​

@helix-agents/ai-sdk exports the typed wire errors for chat, snapshot and submit routes. They are plain web Responses, so they work on any host; @helix-agents/ai-sdk/cloudflare re-exports them. handleChatStream answers every HelixError itself and only rejects with a non-Helix error (AiSdkHandlerError included), so a chat route's catch sees only its own failures and those:

ExportReturns
snapshotNotFoundResponse()404 { error: 'Session not found', code: 'state_session_not_found' }. Use it when getSnapshot returned null.
snapshotErrorResponse(error){ error, code, cause? }. A HelixError (for example state_history_incomplete, or a DOPeerError) keeps its code, its reason becomes cause (corrupt_row, short_read, store_error, snapshot_unstable), and its status is core typedErrorResponse's: 503 when retryable (snapshot_unstable, store_error), 409 for a non-retryable state_* error (corrupt_row, short_read). Anything else is 500 transport_error with a generic message.
chatErrorResponse(error, { sessionId?, logger? })A chat route's own failure: an AiSdkHandlerError keeps its status and { error, code }; a HelixError is core typedErrorResponse's answer (its reason as cause; a missing session or checkpoint is 404, and a foreign checkpoint is answered as a missing one); anything else is 500 { error: 'Internal error', code: 'framework_internal_error' }, the error's text never echoed. sessionId becomes the X-Session-Id header; logger ({ warn }) receives a masked foreign checkpoint's detail (http.checkpoint_foreign_answered_not_found, both session ids).
submitToolResultErrorResponse(error)409 { error, code: 'state_run_superseded' } for a RunSupersededError; 400 for an invalid client-tool result (INVALID_RESULT, core's fixed INVALID_RESULT_MESSAGE, no Zod detail); a typed refusal of the submit itself (a validation HelixError: 413 validation_payload_too_large, 400 validation_error, DI-51) with core typedErrorResponse's status and body; undefined for any other error (rethrow it).
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
typescript
import { snapshotErrorResponse, snapshotNotFoundResponse } from '@helix-agents/ai-sdk';

try {
  const snapshot = await chat.getSnapshot({ sessionId });
  return snapshot ? Response.json(snapshot) : snapshotNotFoundResponse();
} catch (error) {
  return snapshotErrorResponse(error);
}

Fixed: dropped stream frames on re-attach and replay (RM-51)

Before @helix-agents/runtime-cloudflare 5.20.0, DOStreamManagerClient.createResumableReader yielded only the first SSE frame of each network read, so an attached, re-attached or replayed message could arrive truncated when the DO's writes were coalesced. This is fixed in 5.20.0; upgrade if you see truncated attached or replayed messages. See RM-51 in the conformance findings.

StreamTransformer ​

Transforms Helix stream chunks to AI SDK UI events.

typescript
import { StreamTransformer } from '@helix-agents/ai-sdk';

const transformer = new StreamTransformer({
  generateMessageId: (agentId) => `msg-${agentId}`,
  includeStepEvents: false,
  chunkFilter: (chunk) => chunk.type !== 'state_patch',
  logger: console,
  startMetadata: { requestId: 'req-123', source: 'web-ui' },
  finishMetadata: { model: 'claude-sonnet-5-5', totalTokens: 150 },
});

for await (const chunk of helixStream) {
  const { events, sequence } = transformer.transform(chunk);
  for (const event of events) yield { event, sequence };
}

// Always finalize.
const { events } = transformer.finalize();

StreamTransformerOptions ​

OptionTypeDescription
generateMessageId(agentId: string) => stringGenerate unique message IDs
includeStepEventsbooleanEmit bare start-step / finish-step UI chunks at LLM-step boundaries (default: false). Carries no stepId/usage/finishReason (ai's schema forbids them); only adapters that report steps produce them — llm-vercel does not today (RM-48)
chunkFilter(chunk: StreamChunk) => booleanFilter chunks before transformation
startMetadataRecord<string, unknown> | (agentId: string) => Record<string, unknown>Metadata for start event
finishMetadataRecord<string, unknown> | (agentId: string) => Record<string, unknown>Metadata for finish event
loggerLoggerOptional logger instance
continuedToolCallIdsreadonly string[]The calls of the message the client already holds and the response is appended to (a client-tool / approval continuation, a resume without the replayed prefix). A tool result is forwarded only for a call whose input this response emitted or that is listed here; any other becomes data-tool-output-elsewhere. Zod-checked (the constructor throws on a non-string-array); client-derived, so it only widens what is forwarded. handleChatStream sets it.

includeStepEvents depends on the adapter and runtime (RM-48, RM-49)

The option only forwards step chunks the runtime writes, and what gets written depends on the adapter and runtime:

  • With @helix-agents/llm-vercel on runtime-js (and the DO), Temporal or Cloudflare Workflows, no step chunks are written, so the option emits nothing and no per-step usage or finishReason reaches the stream (RM-48).
  • On DBOS, under every adapter (llm-vercel included), each step writes a step_start but never a step_end, so you get start-step without a matching finish-step (RM-49).
  • With an adapter that does report step boundaries, the chunks are emitted bare ({ type }), so every supported ai version accepts them (DI-20, fixed; see the UI-chunk wire contract).

See the conformance findings.

Event Mapping ​

See Frontend AI SDK guide for the full mapping table, and UI-chunk wire contract: every emitted event parses with ai's uiMessageChunkSchema on ai 6.0.0+, so Helix extras ride on data-* events or providerMetadata.helix.

Run, step and resync events ​

Helix chunkUI event
run_startedDropped (the transformer uses it to attribute later chunks to their run).
step_committeddata-step-committed { runId, stepId, checkpointId }
step_discardeddata-step-discarded { runId, stepId, reason }
run_terminateddata-run-terminated { runId, reason }; reason is owner_unknown_timeout or admin_terminate (a CF Workflows run the framework ended)
stream_resyncdata-stream-resync { checkpointId, stepCount, messageCount, fromSequence, reason, runId?, truncateCheckpointId? }
(an LLM chunk under a newer attempt)data-attempt-superseded { reason: 'attempt_superseded', runId, agentId, step, attemptId, supersededAttemptId } (a resync trigger)
(a replayed committed prefix)transient data-helix-replay { phase: 'begin' | 'end', messageId } around the replayed parts
(a result for a call an earlier message holds)data-tool-output-elsewhere { reason: 'tool_output_elsewhere', toolCallId, toolName, runId? } instead of the tool-output-* (a resync trigger)

Content events never carry the chunk's attemptId. data-stream-resync, data-attempt-superseded and data-tool-output-elsewhere are the resync triggers (AISDKResyncTriggerEvent). A tool result is forwarded only for a call the response message owns: its input was emitted in this response, or it is in the continuedToolCallIds transformer option (the client-held message a continuation is appended to; Zod-checked, it only widens what is forwarded). See Tool results for a call in an earlier message.

Message Converter ​

Convert Helix messages to AI SDK v6 UIMessage format.

typescript
import { convertToAISDKMessages } from '@helix-agents/ai-sdk';

const uiMessages = convertToAISDKMessages(helixMessages, {
  generateId: (index, msg) => `msg-${index}`,
  includeReasoning: true,
  mergeToolResults: true,
});

A tool part's output is the tool result decoded with core decodeToolResultContent: a result marked helixContentEncoding: 'text' is the tool's string verbatim (a tool that returned '42' or '{"a":1}' renders that string), and any other result is parsed JSON. A failed result's errorText is its { "error": … } message, sanitized with core sanitizeToolErrorText, the same preset the stream applies to the live tool_end.error, so a reload renders the same text as the live stream.

UIMessage shape (AI SDK v6) ​

typescript
interface UIMessage {
  id: string;
  role: 'user' | 'assistant' | 'system';
  parts: UIMessagePart[];
  metadata?: Record<string, unknown>;
}

type UIMessagePart = UIMessageTextPart | UIMessageReasoningPart | UIMessageToolInvocationPart;

interface UIMessageToolInvocationPart {
  type: `tool-${string}` | 'dynamic-tool';
  toolCallId: string;
  input: Record<string, unknown>;
  state: ToolInvocationState;
  output?: unknown;
  errorText?: string;
}

type ToolInvocationState =
  | 'input-streaming'
  | 'input-available'
  | 'output-available'
  | 'output-error'
  | 'approval-requested'
  | 'approval-responded'
  | 'output-denied';

metadata is the Helix message metadata minus the server-side prompt fingerprint (helixPrefix, stripped; metadata left empty is omitted). An assistant message whose answer arrived as native_output carries helixNativeCompletion: true. A native_output miss (an answer that did not parse, a max_tokens cut) is a hidden row (metadata.hidden), so the default filterHidden: true drops it.

Store Utilities ​

loadUIMessages ​

Paginated loading.

typescript
import { loadUIMessages } from '@helix-agents/ai-sdk';

const { messages, hasMore } = await loadUIMessages(stateStore, sessionId, {
  offset: 0,
  limit: 50,
  includeReasoning: true,
  includeToolResults: true,
  generateId: (index, msg) => `msg-${index}`,
});

Note: Paginated loading may not correctly merge tool results when a tool call and its result span different pages. Use loadAllUIMessages for guaranteed merging.

loadAllUIMessages ​

Load all messages (auto-paginates).

typescript
import { loadAllUIMessages } from '@helix-agents/ai-sdk';

const allMessages = await loadAllUIMessages(stateStore, sessionId, {
  includeReasoning: true,
  includeToolResults: true,
});

LoadUIMessagesOptions ​

typescript
interface LoadUIMessagesOptions {
  offset?: number;
  limit?: number;
  includeReasoning?: boolean;
  includeToolResults?: boolean;
  generateId?: (index: number, message: Message) => string;
}

createUIMessageStore ​

Wrapper for repeated access.

typescript
import { createUIMessageStore } from '@helix-agents/ai-sdk';

const uiStore = createUIMessageStore(stateStore);
const { messages, hasMore } = await uiStore.getUIMessages(sessionId);
const all = await uiStore.getAllUIMessages(sessionId);

State Mapping ​

typescript
import { mapToolStateToAISDK } from '@helix-agents/ai-sdk';

mapToolStateToAISDK('pending'); // 'input-available'
mapToolStateToAISDK('executing'); // 'input-available'
mapToolStateToAISDK('completed'); // 'output-available'
mapToolStateToAISDK('error'); // 'output-error'

RemotePatchFilter ​

Consumer-side helper implementing rules 3–4 of the §7 consumer reconstruction contract: agentId filtering, provenance dedup, and unpinned-baseline / foreign-provenance suppression for a parent stream that multiplexes root and forwarded remote-child data-state-patch parts. Rules 1–2 (get a baseline via getSnapshot; attach the stream at that baseline's own streamSequence) remain the caller's responsibility — the filter cannot detect a misaligned attach from the wire.

Signatures ​

typescript
class RemotePatchFilter {
  constructor(options: RemotePatchFilterOptions);
  /** Whether an incoming `data-state-patch` part should be applied locally. */
  shouldApply(part: StatePatchPart): boolean;
  /** Seed/update the checkpoint-pinned baseline for one child document. */
  setChildBaseline(
    childSessionId: string,
    baseline: { streamId: string; streamSequence: number }
  ): void;
  /** Clear dedup keys and latched unalignable-baseline signals. */
  reset(): void;
}

function createRemotePatchFilter(options: RemotePatchFilterOptions): RemotePatchFilter;

function isStatePatchPart(item: unknown): item is StatePatchPart;

interface StatePatchPart {
  type: 'data-state-patch';
  data: StatePatchPartData;
}

interface StatePatchPartData {
  patches: StatePatchChunk['patches'];
  /** The session (root or sub-agent) this patch belongs to. */
  agentId: string;
  /** Present only on chunks forwarded from a remote child. */
  remoteSource?: { childSessionId: string; childSequence: number; streamId: string };
}

type UnalignableBaselineReason = 'unpinned_baseline' | 'nested_descendant';

One-line semantics ​

ExportSemantics
RemotePatchFilterStateful per-document patch filter. shouldApply() drops patches for a different document, dedups forwarded child patches on (childSessionId, childSequence), and suppresses patches it cannot align (unpinned baseline, or provenance naming another session), firing onUnalignableBaseline when it does.
createRemotePatchFilter()Factory function; equivalent to new RemotePatchFilter(options).
isStatePatchPart()Type guard narrowing an arbitrary UI message part to StatePatchPart (type === 'data-state-patch' with an agentId-bearing data).
StatePatchPartThe typed shape of a data-state-patch UI message part.
StatePatchPartDataThe part's data: { patches, agentId, remoteSource? } — remoteSource is present only on chunks proxied from a remote child.
UnalignableBaselineReason'unpinned_baseline' (no stream position to align against — the -1 sentinel) or 'nested_descendant' (the patch's provenance names a session other than this document's, commonly a grandchild).

RemotePatchFilterOptions (constructor input): { documentAgentId: string; childBaselines?: Map<string, { streamId: string; streamSequence: number }>; onStreamIdChange?: (childSessionId: string) => void; onUnalignableBaseline?: (sessionId: string, reason: UnalignableBaselineReason) => void; logger?: Logger }.

Client: snapshot, resume and resync ​

Framework-agnostic helpers, exported from @helix-agents/ai-sdk/client and re-exported from @helix-agents/ai-sdk/react. withHelixStreamControl and parseHelixChatError are also exported from the root.

snapshotResumeOptions ​

typescript
function snapshotResumeOptions(
  snapshot: FrontendSnapshot<unknown>,
  options?: { replayPrefix?: boolean } // default true
): {
  resume: boolean;
  resumeFromSequence?: number;
  existingMessageId?: string;
  replayPrefix?: boolean;
  messages: UIMessage[];
};

How to seed and resume a chat from a snapshot, identically on every ai version:

  • active / paused: resume: true, resumeFromSequence: snapshot.streamSequence, existingMessageId: snapshot.resumeMessageId, and messages WITHOUT the resumeMessageId message (replayPrefix: true). The resumed stream replays that message's committed prefix, then its tail.
  • ended / failed: { resume: false, messages }: snapshot.messages, then pendingCallsMessage(snapshot) when the snapshot lists calls messages does not show (pendingCalls, CP-250 (c)), so the seed shows them once.

Pass { replayPrefix: false } for a client that cannot suppress replayed client tools (a plain or pre-built Chat). It then gets the whole snapshot and resumes the tail, which loses the in-flight message's committed steps on ai 6.0.281. Always send replayPrefix and the seed together. A whole-snapshot seed carries the in-flight message's answered client tools: useHelixChat never re-sends them, and a pre-built Chat passes the seed as createHelixSendAutomaticallyWhen({ history }) (CP-107, below).

pendingCallsMessage ​

typescript
function pendingCallsMessage(
  snapshot: Pick<FrontendSnapshot<unknown>, 'messages' | 'pendingCalls'>,
  options?: { exclude?: readonly UIMessage[] }
): UIMessage | null;

CP-250 (c): one assistant message with a part per snapshot.pendingCalls entry, or null when there is none to add. Each part is the one the live stream would have built: a dynamic-tool part, a client call input-available (providerExecuted: false, its deadline in callProviderMetadata.helix { deadlineAt, serverNow }), an approval approval-requested with approval: { id }. A call whose toolCallId already has a part in snapshot.messages or in exclude is skipped, so it is never shown twice. The id (helix-pending-<toolCallIds>) is the same for the same calls on every read. snapshotResumeOptions and every useHelixChat re-anchor use it; a stock useChat app that seeds from snapshot.messages appends it itself:

typescript
const pending = pendingCallsMessage(snapshot, { exclude: messages });
if (pending) setMessages([...messages, pending]);

Answer the part with stock addToolResult / addToolApprovalResponse; the request goes through the root.

fetchSnapshotWithRetry ​

typescript
function fetchSnapshotWithRetry<TState = unknown>(
  url: string,
  opts?: FetchSnapshotOptions
): Promise<FrontendSnapshot<TState> | null>;

interface FetchSnapshotOptions {
  fetch?: typeof fetch;
  /** Sleeps between attempts (default [250, 500, 1000]: 4 fetches in total). */
  backoffMs?: number[];
  sleep?: (ms: number) => Promise<void>;
  /** Abort the fetch and any pending retry (rejects with an `AbortError`, never retried). */
  signal?: AbortSignal;
}

Retries a 5xx, a 429 or a network failure. Returns null only on 404. Throws SnapshotUnavailableError after exhaustion, at once on any other 4xx, and at once on a 200 whose body is not JSON (reason: 'invalid_json') or not a snapshot (reason: 'invalid_body').

SnapshotUnavailableError extends HelixError (retryable: true) and carries the server's typed code (default transport_error), the last HTTP status (undefined for a network failure) and the server's reason (cause on the wire, e.g. snapshot_unstable). Render an explicit error state with a retry control, never an empty conversation.

resumeHeaders / ResumeTarget ​

typescript
interface ResumeTarget {
  resumeFromSequence: number;
  existingMessageId?: string;
  /** The chat was seeded WITHOUT the existingMessageId message. */
  replayPrefix?: boolean;
}

function resumeHeaders(target: ResumeTarget): Record<string, string>;

The headers a resume carries: X-Resume-From-Sequence, X-Existing-Message-Id, and X-Helix-Replay-Prefix: 1 only when replayPrefix is set and there is an existingMessageId. Set replayPrefix exactly when the chat was seeded without the resume message; with a whole-snapshot seed the replayed prefix duplicates the message on ai ≤ 6.0.230.

Stream control ​

typescript
function withHelixStreamControl<UI_MESSAGE extends UIMessage = UIMessage>(
  inner: ChatTransport<UI_MESSAGE>
): HelixControlledTransport<UI_MESSAGE>;

interface HelixStreamControl {
  /** Abort chat `chatId`'s open connection; its stream errors with an `AbortError`. */
  abortActive(chatId: string): void;
  /** Whether a connection is open for `chatId` (for any chat when omitted). */
  hasActiveConnection(chatId?: string): boolean;
  /** `'send' | 'resume'` for `chatId`'s open connection, `undefined` when none. */
  activeKind(chatId: string): 'send' | 'resume' | undefined;
  /** The chat's recent connections (the last 8, open or settled), oldest first. */
  connections(chatId: string): readonly ConnectionRecord[];
}

interface ConnectionRecord {
  readonly seq: number; // increasing per transport
  readonly kind: 'send' | 'resume';
  readonly chunks: number; // chunks read, other than an in-band `error` (the connection's progress)
  readonly outcome: ConnectionOutcome;
}

// How a connection ended, classified by what happened and by error class, never by message text.
type ConnectionOutcome =
  | { kind: 'open' }
  | { kind: 'refused'; error: unknown } // the open was rejected (a non-2xx)
  | { kind: 'unreachable'; error: TypeError } // fetch itself failed at open
  | { kind: 'dropped'; error: unknown } // a body read failed after the open
  | { kind: 'malformed'; error: unknown } // ai's JSONParseError / TypeValidationError
  | { kind: 'server_error'; errorText: string } // the server's in-band `error` chunk
  | { kind: 'ended' }
  | { kind: 'nothing_to_resume' } // a reconnect answered "no stream"
  | { kind: 'aborted' }
  | { kind: 'cancelled' }; // the consumer stopped reading (e.g. the app's own onToolCall throw)

type HelixControlledTransport<UI_MESSAGE extends UIMessage = UIMessage> =
  ChatTransport<UI_MESSAGE> & HelixStreamControl;

function isHelixControlledTransport(transport: ChatTransport | undefined): boolean;

ai 6.0.0–6.0.230 call reconnectToStream without an abortSignal, so chat.stop() alone cannot stop a resumed stream. A wrapped transport gives each connection its own AbortController, keyed by chatId: a chat's new connection aborts its previous one, and two chats sharing the transport never abort each other. A reconnect the transport aborts before its response arrives resolves null. withHelixStreamControl is idempotent. useHelixChat wraps its transport automatically; createHelixChatTransport is controlled already. With a plain useChat, call transport.abortActive(chatId) before chat.stop().

reanchorOnSnapshot ​

typescript
function reanchorOnSnapshot<TState = unknown>(
  options: ReanchorOptions
): Promise<FrontendSnapshot<TState> | 'superseded'>;

interface ReanchorOptions {
  snapshotUrl: string;
  control: HelixResyncControl;
  setMessages: (messages: UIMessage[]) => void;
  fetchOptions?: Omit<FetchSnapshotOptions, 'signal'>;
  /** Aborts the snapshot fetch (unmount, session switch, a newer re-anchor). */
  signal?: AbortSignal;
  /** Checked after every await: a stale re-anchor never sets messages or resumes. */
  isCurrent?: () => boolean;
  /** A resume that fails after the re-anchor returned. */
  onResumeError?: (error: unknown) => void;
}

interface HelixResyncControl {
  /** Stop the current stream, resolving once it has stopped. */
  stop(): void | Promise<void>;
  /** Resume from `target` (not awaited to completion). */
  resume(target: ResumeTarget): void | Promise<void>;
  /** Whether the chat suppresses replayed client tools (seed without the resume message). */
  replayPrefix?: boolean;
  /** Serialize against other work on this chat (a send waits for a re-anchor). */
  exclusive?<T>(fn: () => Promise<T>): Promise<T>;
}

A re-anchor stops the stream, fetches the LATEST snapshot (fetchSnapshotWithRetry), sets its messages as they are (the snapshot is already UI-shaped), and, when the snapshot is live, resumes from its cursor under resumeMessageId. It seeds without the resume message and asks for the replayed prefix only when control.replayPrefix is true. It returns 'superseded' when a newer re-anchor, an abort or isCurrent() took over, and throws the fetch's SnapshotUnavailableError, or SnapshotNotFoundError (state_session_not_found) for a 404.

ExportSemantics
chatResyncControl(chat, transport?)A HelixResyncControl from a ResyncableChat ({ id?, stop, resumeStream }): stop aborts the chat's connection on transport (when given) and calls chat.stop(); resume is chat.resumeStream({ headers: resumeHeaders(target) }). Without transport, stop is chat.stop() alone, which cannot stop a resumed stream on ai ≤ 6.0.230 unless it already aborts the connection (as useHelixChat's does). replayPrefix is true only for a useHelixChat result.
collectResyncTriggers(messages)The data-stream-resync, data-attempt-superseded and data-tool-output-elsewhere parts in messages, in order. Feed them to useAutoResync / useResumableChat / a ResyncTracker.
resyncCoveredBySnapshot(event, snapshot)true when event is a data-stream-resync whose truncateCheckpointId equals snapshot.latestTruncateCheckpointId: the snapshot already shows the rewound log, so no re-anchor is needed.
missingResyncControlError()The typed ConfigurationError (missing: ['stop', 'resume']) a resync surfaces when it has no stream control.
isResyncTriggerEvent / isAttemptSupersededEvent / isToolOutputElsewhereEventType guards (root and /react exports, next to isStreamResyncEvent).

ResyncTracker dedups a stream resync by (runId, checkpointId) and an attempt switch by (runId, agentId, step, attemptId), and rescans an array that shrank or was replaced.

parseHelixChatError ​

typescript
function parseHelixChatError(error: unknown): HelixChatErrorBody | undefined;

interface HelixChatErrorBody {
  /** The user-safe sentence for `code` (render this). */
  error?: string;
  code: string;
  /** The developer text (log it; never render it). SDK-5. */
  detail?: string;
  retryable?: boolean;
  cause?: string;
  /** The submit-size refusal's numbers (`validation_payload_too_large`). */
  limitBytes?: number;
  sizeBytes?: number;
  /** The calls a refused submit names (`cause: 'answer_not_stored'`): they still await an answer. */
  toolCallIds?: string[];
}

A stock DefaultChatTransport throws new Error(<response text>) for a non-2xx POST. parseHelixChatError(chat.error) recovers the typed body (for example the send-conflict 409 state_already_running, or the 400 framework_completion_strategy_unavailable / framework_completion_schema_unsupported of a send, resume or submit refused for completion support), or undefined when the error carries none. Render its error (the user-safe sentence for code) and log its detail (SDK-5); never render chat.error.message, which is the raw body.

React Hooks Reference ​

Import from @helix-agents/ai-sdk/react:

HookSignaturePurpose
useHelixChat(options: UseHelixChatOptions) => UseHelixChatResultuseChat + client tools + resync in one hook
useResumeClientTools(options: UseResumeClientToolsOptions) => voidAuto-dispatch client-executed tools
useStreamResync(data, options: UseStreamResyncOptions) => voidManual resync handling via callback
useAutoResync(data, options: UseAutoResyncOptions) => UseAutoResyncResultRe-anchor on the snapshot on a resync trigger
useCheckpointSnapshot(options) => UseCheckpointSnapshotResult<TState>Load a snapshot (optionally by checkpointId)
useResyncState(data) => UseResyncStateResultTrack resync count without auto-handling
useResumableChat(data, options) => UseResumableChatResult<TState>Turnkey snapshot + resync solution

data is the resync trigger parts: collectResyncTriggers(chat.messages). See Recovery hooks for usage examples.

useHelixChat ​

Composes useChat, useResumeClientTools and the Helix sendAutomaticallyWhen. Options are ChatInit fields plus chat? (a pre-built Chat), toolHandlers?, onClientToolError?, experimental_throttle?, resume? and:

typescript
resync?: {
  snapshotUrl: string;
  /** The snapshot the chat was seeded from: a replayed rewind resync it covers is skipped. */
  snapshot?: Pick<FrontendSnapshot<unknown>, 'latestTruncateCheckpointId'> | null;
  snapshotFetch?: SnapshotFetchOptions; // FetchSnapshotOptions without `signal`
  onResync?: (event: AISDKResyncTriggerEvent) => void;
  onError?: (error: Error) => void;
};

It returns UseHelixChatResult (breaking: it was useChat's helpers): useChat's helpers plus resyncError: Error | null. On the ChatInit path the hook wraps transport with withHelixStreamControl, and the returned stop aborts the transport connection and resolves once the chat is idle (after 5 s it rejects with a StreamStopTimeoutError: code: 'transport_error', retryable: true; the class is not exported, so match on error.name). sendMessage stops an open resume first, and a send waits for a re-anchor in flight.

Stopping the run on the server (CP-12). serverStop?: ServerStopOptions makes the returned stop() also stop the run on the server: it POSTs the host's chat stop route, then stops the local stream as above.

typescript
interface ServerStopOptions {
  url: string | ((chatId: string) => string); // the host's stop route (a function gets the chat id)
  headers?: () => Record<string, string> | Promise<Record<string, string>>; // e.g. auth
  fetch?: typeof globalThis.fetch;
  kind?: 'interrupt' | 'abort'; // default 'interrupt' (resumable); 'abort' only if the host allows it
}
  • Only the explicit stop() posts. An unmount, a page refresh, a navigation, a resync re-anchor and a send over an open resume stay local-only: a refresh must resume the run, never kill it. A bare useChat().stop() never posts.
  • The POST is fire-and-track: the UI never waits on it. With resync, once the server took the stop the hook re-anchors, so the committed (interrupted) turn shows.
  • stopError: ServerStopError | null is the last refusal (a 404, a 503, a transport failure), with status, code and retryable. A 409 state_not_stoppable (nothing left to stop) is not an error.
  • The target is the session's CURRENT live run. The client cannot name a run, so a stop racing another tab's newer turn stops that turn (FU-CLIENT-STOP-RUN-TARGET).
  • Without useHelixChat, call requestServerStop(options, { chatId, reason? }). It never throws: it answers { kind: 'stopped', answer }, { kind: 'nothing_to_stop' } or { kind: 'error', error: ServerStopError }. Exported from the root, /client and /react.

A send refused while a run is live (CP-13, ruling O-71). A send never attaches to a live run: the chat route answers it 409 state_already_running. useHelixChat then records it in refusedSend: RefusedSend | null ({ message, text, error, key? }, the type exported from @helix-agents/ai-sdk/react) and, with resync, re-anchors at once, so the live run keeps rendering. The re-anchor drops the never-persisted message from messages; its text stays in refusedSend for the app to offer again. The next send clears it, and so does a chat id change (a new conversation in the same mounted hook: the refusal belongs to the conversation it was refused in, so retry() never sends it into another one). retry() after a refusal follows the run while it is live (it never re-sends into the same 409) and sends the refused message once the run has ended.

A user message refused as too large (MA-31). When the store refuses a send's user message at the entry's run-start commit, the route answers 413 { error, code: 'state_size_exceeded', retryable: false, cause: 'append', limitBytes, sizeBytes }. The input was not committed. Measured against the real DefaultChatTransport and AbstractChat of every pinned ai version (6.0.0, 6.0.49, 6.0.91, 6.0.230, 6.0.281) and against useHelixChat: the chat ends in status: 'error' and error is set; the user's message stays in messages (it is not removed); it is not re-sent (exactly one request, even with sendAutomaticallyWhen: () => true); and refusedSend stays null, since it holds only a state_already_running refusal. parseHelixChatError(error) gives code: 'state_size_exceeded' with limitBytes and sizeBytes, so the app can tell the user the message is too large. The input box is the app's own state: keep or clear it as suits the UI.

A send whose stream drops. A request that fails while the run goes on leaves the chat 'error'. With resync, useHelixChat runs the re-attach controller (createReattachController, below). A send re-attaches after a dropped, server_error or unreachable outcome when the server holds its turn: the snapshot's user turn at the chat's last user position has the same text, which is the check retry() makes. It re-anchors on the snapshot, which resumes the run from its cursor while it is live. A send the server does not hold keeps its message and error for retry(). These stay chat.error:

  • a refusal answered before any stream (any non-2xx; a 409 state_already_running is the refused send above);
  • a chunk ai could not parse;
  • a stop;
  • the app's own throw from onToolCall / onData.

The controller's failures (a snapshot it could not read, a resume it started that was refused, ReanchorExhaustedError) surface as resyncError and resync.onError.

On a plain stock useChat, useReanchorOnError(chat, { snapshotUrl, transport, snapshotFetch?, onError?, enabled? }) (from @helix-agents/ai-sdk/react) runs the same controller:

  • transport is required. It must be the withHelixStreamControl(...) instance the same useChat uses (typed HelixControlledTransport); any other is reported as a ConfigurationError.
  • It returns { reanchorError } (Error | null; UseReanchorOnErrorResult).
  • The options type is UseReanchorOnErrorOptions. ReanchorableChat is the slice of a stock useChat result it reads: id, status, error, messages, setMessages, clearError, stop, resumeStream.

The re-attach controller. createReattachController({ chat, transport, control, snapshotUrl, snapshotFetch?, restoreAfterFailedResume?, onReport }) (from @helix-agents/ai-sdk/client) is the framework-agnostic core of both hooks. Feed it the chat's status and error on every change (observe), and dispose() it with the chat.

OptionWhat it is
chat{ id, isCurrentChat?, messages(), setMessages, clearError }, bound to ONE chat
transportthe chat's controlled transport (its connections)
controlcontrolChat(...) or chatResyncControl(chat, transport)
snapshotFetcha getter, read at each fetch
restoreAfterFailedResumeuseHelixChat: a refused or malformed resume it did not start re-anchors too

The budget. It re-attaches at most MAX_CONSECUTIVE_REATTACHES (5) times in a row, and never after a re-attach whose resumed stream delivered nothing. A resume that never reached the server, or that was refused with a typed retryable body, is not "delivered nothing", but it still counts toward the 5. The budget resets on a clean end or a send.

What it reports (onReport(error | null)):

  • a failed snapshot read (SnapshotUnavailableError);
  • a resume it started that was refused;
  • ReanchorExhaustedError: a HelixError with code transport_error, not retryable, carrying attempts and the last failure as cause;
  • a ConfigurationError when the chat errors on a request its transport never saw.

A 404 snapshot (SnapshotNotFoundError) means the server does not hold the turn, so nothing is reported. A run that ended gets clearError(). An attempt writes nothing once the controller is disposed, once isCurrentChat() is false, or once a newer send has begun.

Request keys (D-2). Every sendMessage carries an X-Helix-Request-Key per-call header: a fresh key per send (ai's generateId, which needs no secure context), or the caller's own when the send's headers already hold one. retry() re-sends the same request with the same key: after a refusal from refusedSend.key, after a failed send through a regenerate under the send's key. So a retry whose first attempt did commit attaches to that run instead of appending the message twice. The returned regenerate() is a deliberate new request and mints a new key. One exception (#177, U9): a send whose run failed state_entry_patch_refused never wrote its message, so retry() re-sends it under a fresh key (the same key would replay the failed entry and write nothing). The hook reads that code from the transient data-error-metadata part (chat.error is a plain Error), which the app's own onData still receives. A pre-built Chat gets that code through the handle it was built with: const helix = createHelixOnData({ onData }), new Chat({ ..., onData: helix.onData }), useHelixChat({ chat, helixData: helix }) (each handle holds its own state; without it the code is never seen and retry() re-sends under the same key). On a resumed stream there is no message to re-send (the snapshot never held it): retry() re-anchors, and the app shows the code. Tool calls inside a replayed prefix (between data-helix-replay markers) are never handed to onToolCall or toolHandlers and never auto-sent; a replayed tool that is still pending is dispatched once after the replay. A pre-built Chat cannot suppress replayed tools, so its resyncs re-anchor on the whole snapshot (dev warning).

The seed is server truth (CP-107). The messages the hook creates the Chat with are a snapshot: a client-tool part is output-available / output-error there only once its result is committed. Those answers (and any approval-responded) are history: they are kept out of sendAutomaticallyWhen (the app's own predicate or the Helix default) and never auto-sent; an answer the client produces after mount is sent as before. Without this, a whole-snapshot seed POSTed them again once the resumed continuation ended (ai evaluates sendAutomaticallyWhen after a resume), and the server refused them as unknown calls. Seed from a snapshot: an app that seeds from its own local copy holding an answer the server never received does not re-send that answer, and the run waits until the next entry. Read when useChat creates the Chat (on mount and on an id change). A pre-built Chat configures it on its own predicate (createHelixSendAutomaticallyWhen({ history })); one that resync re-anchors on a whole snapshot is not covered yet (FU-AISDK-PREBUILT-CHAT-REANCHOR-RESENDS).

createHelixSendAutomaticallyWhen / useHelixSendAutomaticallyWhen ​

typescript
interface HelixSendAutomaticallyWhenOptions {
  /** The messages the chat is seeded with from a snapshot: their tool answers are never sent. */
  history?: readonly UIMessage[];
}
function createHelixSendAutomaticallyWhen(
  options?: HelixSendAutomaticallyWhenOptions
): (args: { messages: UIMessage[] }) => boolean;
function useHelixSendAutomaticallyWhen(
  chatId?: string,
  options?: HelixSendAutomaticallyWhenOptions
): (args: { messages: UIMessage[] }) => boolean;

The Helix sendAutomaticallyWhen predicate: true once for each new client-tool answer in the last assistant message (output-available, output-error, any approval-responded; a server-executed result is skipped), never again for the same toolCallId (a FIFO set of 1000). history pre-marks the answers a snapshot seed already shows (CP-107): pass the same messages a pre-built Chat is created with, e.g. new Chat({ messages: seed, sendAutomaticallyWhen: createHelixSendAutomaticallyWhen({ history: seed }) }). The hook form reads options only when it creates the predicate (on mount and on a chatId change). The factory and HelixSendAutomaticallyWhenOptions are also exported from the package root.

Stream control for the resync hooks ​

useAutoResync and useResumableChat take ResyncControlOptions:

typescript
interface ResyncControlOptions {
  stop?: () => void | Promise<void>;
  resume?: (target: ResumeTarget) => void | Promise<void>;
  chat?: ResyncableChat;
  chatId?: string;
  control?: HelixResyncControl; // wins over the others
}

The control comes from ONE source: control, else stop + resume together, else chat, else the mounted useHelixChat whose id is chatId (or, without chatId, whose setMessages is the hook's setMessages). Breaking: with none, a resync surfaces a typed ConfigurationError (missing: ['stop', 'resume']) through resyncError / onError and leaves the messages alone; a lone stop or resume is a ConfigurationError too. A stop that does not abort the transport (a bare chat.stop) logs a development warning. setMessages is typed (messages: UIMessage[]) => void (it was (messages: unknown[]) => void). The snapshot fetch retries a 5xx (snapshotFetch options) and surfaces SnapshotUnavailableError after exhaustion. An unmount or a snapshotUrl change aborts a fetch in flight, and only the latest re-anchor lands.

useResumableChat also returns status, streamSequence, resumeMessageId, shouldResume, messages (the seed, from snapshotResumeOptions) and replayPrefix. Both hooks skip a replayed rewind resync that the anchored snapshot already covers (resyncCoveredBySnapshot): useAutoResync takes that snapshot as its snapshot option, useResumableChat uses the one it loaded, and each re-anchor's snapshot replaces it.

SSE Response Builder ​

typescript
import { createSSEStream, createSSEHeaders, buildSSEResponse } from '@helix-agents/ai-sdk';

const response = buildSSEResponse(eventsGenerator, {
  headers: { 'X-Custom': 'value' },
});

SSE Format ​

id: 1
data: {"type":"text-delta","id":"block-1","delta":"Hello"}

id: 2
data: {"type":"text-delta","id":"block-1","delta":" world"}

data: {"type":"finish"}

A reply that forwarded a run error ends with the error chunk, then finish with finishReason: 'error':

data: {"type":"error","errorText":"..."}

data: {"type":"finish","finishReason":"error"}

If the event source throws, createSSEStream logs the raw message, writes one error event whose errorText is sanitized with core sanitizeErrorMessage, then [DONE].

Header Utilities ​

typescript
import {
  AI_SDK_UI_HEADER, // 'X-AI-SDK-UI'
  AI_SDK_UI_HEADER_VALUE, // 'vercel-ai-sdk-ui'
  extractResumePosition,
} from '@helix-agents/ai-sdk';

// Extract resume position from request headers
const headers = Object.fromEntries(request.headers.entries());
const resumeAt = extractResumePosition(headers);
// Reads (in priority order):
//   X-Resume-From-Sequence
//   Last-Event-ID
//   X-Resume-At

Errors ​

typescript
import {
  FrontendHandlerError,
  ValidationError, // 400
  StreamNotFoundError, // 404
  StreamReaderError, // 500
  StreamFailedError, // 410
  ConfigurationError, // 501
  ExecutionError, // 500
  StreamCreationError, // 500
  HelixStreamError,
} from '@helix-agents/ai-sdk';

FrontendHandlerError ​

typescript
interface FrontendHandlerError extends Error {
  code: string; // e.g., 'VALIDATION_ERROR'
  statusCode: number; // HTTP status
}

The Express adapter's sendErrorToExpress (@helix-agents/ai-sdk/adapters/express) answers one with its statusCode and { error, code }, the error sanitized with core sanitizeErrorMessage.

HelixStreamError ​

Typed error class for reconstructing stream errors on the client. Provides code and retryable for programmatic error handling.

typescript
const error = HelixStreamError.fromEvent({
  errorText: 'Provider overloaded',
  code: 'provider_overloaded',
  recoverable: true,
});

error.retryable; // true (maps from 'recoverable' on the wire)
PropertyTypeDescription
messagestringError description
codestring | undefinedError code from backend ErrorCode taxonomy
retryablebooleanWhether the operation can be safely retried

Types ​

Configuration Types ​

typescript
import type {
  HandleChatStreamDeps,
  HandleChatStreamParams,
  ExtractResumeIntentResult,
  ResumeIntent,
  ResumeIntentRejection,
  PrepareHelixChatRequestOptions,
  StreamTransformerOptions,
  BuildSnapshotDeps,
  GetUIMessagesDeps,
  GetUIMessagesParams,
  GetUIMessagesResult,
  FrontendResponse,
  FrontendSnapshot,
  SnapshotOptions,
  ResumableStreamStatus,
  TransformResult,
  SequencedEvent,
  MessageConvertOptions,
  // Replay types (for stream resumption)
  ReplayContent,
  ReplayToolCall,
  ReplayToolState,
  // Minimal interfaces (adapter implementations)
  MinimalExecutor,
  MinimalExecutionHandle,
  MinimalStreamManager,
  MinimalStateStore,
} from '@helix-agents/ai-sdk';

Event Types ​

typescript
import type {
  AISDKUIEvent,
  AISDKStartEvent,
  AISDKFinishEvent, // { type: 'finish', finishReason?, messageMetadata? }; finishReason 'error' only after a forwarded run error
  AISDKMessageMetadataEvent,
  AISDKTextStartEvent,
  AISDKTextDeltaEvent,
  AISDKTextEndEvent,
  AISDKReasoningStartEvent,
  AISDKReasoningDeltaEvent,
  AISDKReasoningEndEvent,
  AISDKToolInputAvailableEvent,
  AISDKToolOutputAvailableEvent,
  AISDKFileEvent, // filename in providerMetadata.helix.filename
  AISDKStartStepEvent, // bare { type: 'start-step' }
  AISDKFinishStepEvent, // bare { type: 'finish-step' }
  AISDKDataEvent,
  AISDKErrorEvent,
  AISDKStreamResyncEvent, // data-stream-resync
  AISDKAttemptSupersededEvent, // data-attempt-superseded
  AISDKToolOutputElsewhereEvent, // data-tool-output-elsewhere
  AISDKResyncTriggerEvent, // AISDKStreamResyncEvent | AISDKAttemptSupersededEvent | AISDKToolOutputElsewhereEvent
} from '@helix-agents/ai-sdk';

Client and React Types ​

typescript
import type {
  FetchSnapshotOptions,
  HelixResyncControl,
  ResyncableChat,
  ResumeTarget,
  ReanchorOptions,
  HelixStreamControl,
  HelixControlledTransport,
  HelixChatErrorBody,
} from '@helix-agents/ai-sdk/client';

import type {
  UseHelixChatOptions,
  UseHelixChatResult,
  ResyncControlOptions,
  SnapshotFetchOptions,
  UseAutoResyncOptions,
  UseAutoResyncResult,
  UseResumableChatOptions,
  UseResumableChatResult,
} from '@helix-agents/ai-sdk/react';

See Also ​

Released under the MIT License.