Skip to content

AI SDK Package ​

The @helix-agents/ai-sdk package bridges Helix Agents with Vercel AI SDK frontend hooks. It transforms Helix's internal streaming protocol to the AI SDK UI Data Stream format.

Installation ​

bash
npm install @helix-agents/ai-sdk

v7 surface at a glance ​

PieceWhere it livesWhat it does
handleChatStream@helix-agents/ai-sdk (server)Single entry point implementing the seven-path HITL chat orchestrator
prepareHelixChatRequest@helix-agents/ai-sdk/clientPrepareSendMessagesRequest factory for DefaultChatTransport
prepareHelixReconnectRequest@helix-agents/ai-sdk/clientPrepareReconnectToStreamRequest factory — required for HITL UX
useHelixChat@helix-agents/ai-sdk/reactRecommended — composes useChat + useResumeClientTools + correct sendAutomaticallyWhen
useHelixSendAutomaticallyWhen@helix-agents/ai-sdk/reactReact hook returning a stable Helix sendAutomaticallyWhen predicate
createHelixSendAutomaticallyWhen@helix-agents/ai-sdk/react (also root)Vanilla factory for non-React lifecycles / manual Chat construction
useResumeClientTools@helix-agents/ai-sdk/reactLower-level React hook auto-dispatching client-executed tool calls
extractResumeIntent@helix-agents/ai-sdk (server)Parse resume signals from incoming AI SDK v6 messages
findExpiredPending@helix-agents/ai-sdk (server)Surface client-tool calls past their deadline
createHelixChatTransport@helix-agents/ai-sdk (client)Direct-to-agent-server transport (alternative wiring)
buildSnapshot@helix-agents/ai-sdk (server)Checkpoint-anchored snapshot for SSR / useChat({ messages }) and resume
getUIMessages@helix-agents/ai-sdk (server)Standalone paginated message-history loader
createCloudflareChatHandler@helix-agents/ai-sdk/cloudflare (server)Factory wiring handleChatStream to Cloudflare Durable Objects
snapshotResumeOptions@helix-agents/ai-sdk/client (and /react)What to seed and how to resume, from a snapshot
fetchSnapshotWithRetry@helix-agents/ai-sdk/client (and /react)Browser snapshot fetch with 5xx retries and a typed SnapshotUnavailableError
parseHelixChatError@helix-agents/ai-sdk/client (also root, /react)Read a chat route's typed error body back from chat.error

The full list of client exports, and where each is exported, is in Client helpers.

v7 migration

v6's HelixChatTransport class was deleted. new HelixChatTransport(...) will throw TypeError. Migrate to DefaultChatTransport plus prepareHelixChatRequest and prepareHelixReconnectRequest. The v6 → v7 upgrade guide has a copy-paste before/after.

Server: handleChatStream ​

handleChatStream is the canonical v7 server surface. It accepts the parsed { sessionId, messages } body (plus the incoming Request) and dispatches one of seven 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 submit's continuation (executor.resume() only for caller_resumes)
  4. Abandonment recovery → ONE resume({ mode: 'with_message', message, abandonPending: true }) entry on an executor that declares supportsAbandonOnResume; otherwise fail every pending tool, then path 2
  5. Active-stream attach → passive subscriber on a running run (a request with no new user message only)
  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)

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 the new run's chunks (it reads after the session stream's head as it stood before execute()). 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 }. That includes a send while a run is live and streaming (CP-13): the request alone decides send vs attach, so a trailing user message is never an attach. On the CF DO a chat send is refused even under concurrentEntry: 'interrupt_previous' (see Concurrent entries). 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 chat.regenerate(). Requests without a new user message (a reconnect, a passive attach) and tool-result resumes still attach to the active run.

A send, or a resume carrying client-tool or approval results, whose entry fails with a typed HelixError (for example a completion-support refusal, framework_completion_strategy_unavailable, when the agent's model can no longer finish its outputSchema) answers that error as JSON { error, code } with its HTTP status (400 for a refusal) before any stream opens. The chat ends in status 'error' and parseHelixChatError(chat.error) returns the code. See the status table and the upgrade guide.

The submit POST streams its continuation. Every submit answers with the continuation the runtime decided (core SubmitContinuation; see the continuation model), and the resume path acts on the last answered one instead of re-reading the session:

  • new_run (DBOS's in-workflow continuation, CF Workflows' __submit__ instance, the CF DO's wake) and deferred (the CF DO, when a drive or run already carries the answer): no resume() call; the response attaches to the session stream from new_run.startSequence, else the stream head read before the submits, and waits for the run to go live. A live-wait timeout there is a NON-retryable 500 (transport_stream_drop, cause: 'run_not_live'): the answer is stored, so the client re-anchors through the snapshot (useHelixChat().retry()) and never re-submits. The attach streams into the request's trailing assistant message, pinned to the run carrying the continuation: a run that has not committed its run start yet (a CF Workflows __submit__ instance, a CF DO drive) is waited for within the same live-wait bound. The attach is live once that run's own run_started is read on the stream, never on an earlier chunk of another run (DBOS's suspended run writes before its continuation commits); another run's chunks before it are never painted into the message (a tool output for a call the message holds still forwards to it). When that run started a NEW UI message (a with_message send from another tab consumed the answer), another run took the session over, or it cannot be decided, nothing is streamed: the response is start (the trailing message's id), start-step, any gathered data-resume-rejected events, one data-tool-output-elsewhere per submitted call (stamped with the consuming run's id), finish-step and finish, so useHelixChat({ resync }) / useAutoResync re-anchor on the snapshot and resume that turn in its own message. A newer run that takes the stream over mid-attach ends the response the same way (its triggers before finish), never streamed into the old message. Streaming it would either paint the new turn into the old bubble or make ai's Chat copy the old bubble into a duplicate.
  • caller_resumes (runtime-js, Temporal; the CF DO when nothing else will continue): the same request calls executor.resume(). A typed refusal is answered with inputCommitted (never 503).
  • awaiting_batch (the batch is still partial) and consumed (an earlier continuation took the answer, or a re-sent answer for a call that already drained, CP-172): a 200 that ends the client's tool step (start, start-step, any data-resume-rejected, finish-step, finish).

A stock useChat auto-send sends an answer once (CP-180). ai's sendAutomaticallyWhen predicates (lastAssistantMessageIsCompleteWithToolCalls, lastAssistantMessageIsCompleteWithApprovalResponses) read only the parts after the trailing message's last step-start, and ai asks them again after every non-error finish. So every 200 on which nothing continues for the client (the two cases above, and the re-anchor answer) opens and closes an empty step: the predicates then read no tool part and are false. On a stock app, onFinish fires once for such an answer and the trailing message gains that empty step (client-side only: the snapshot never has it, so a reload rebuilds the message without it).

A reloaded page auto-sends like the live page (CP-182). The snapshot renders a pending approval (and a stored approval answer) without providerExecuted, as the live stream does until the tool's output arrives, so the stock predicates count it. With a client call and a pending approval in one step:

PredicateAnswering the client callApproving
lastAssistantMessageIsCompleteWithApprovalResponsesnothing is sentboth answers are sent; the run continues
tool-calls OR approval predicatenothing is sentboth answers are sent; the run continues (then one consumed re-send, case B)
lastAssistantMessageIsCompleteWithToolCalls alonenothing is sentnothing is sent: it counts only output-* parts, live or reloaded

An app with approval-gated tools needs the approval predicate (alone, or OR-ed with the tool-calls predicate); useHelixChat's predicate covers both. Before CP-182 the reloaded approval part was flagged: the tool-calls predicate sent the client answer while the approval still waited (CP-180's reload loop), and ai 6.0.0 to 6.0.91's approval predicate, which skips flagged parts, never sent the approval.

An answer that did not land is an error (CP-179, CP-171). When an answer for a call the server still awaits was not stored (it names another run, its approval id is malformed, or its submit failed transiently) while a sibling answer was, the request is 409 { code: 'state_not_resumable', cause: 'answer_not_stored', toolCallIds }. The chat ends 'error' (no auto-send after an error). Render it as "this answer did not reach the server" and re-read the snapshot (useHelixChat().retry()), where toolCallIds still await their answers. Every answer of the request is submitted first; a submit refused as sent keeps its own code (for example 400 validation_error). See the upgrade guide.

A head read that fails is never masked into an attach from sequence 0: the request answers 500 transport_error (the answer is stored; re-read the snapshot).

Path 4 is one entry on CF Workflows, runtime-js and the CF DO (MinimalExecutor.supportsAbandonOnResume): a new message over pending client calls sends one resume({ mode: 'with_message', message, abandonPending: true }), the unanswered calls are answered client_tool_abandoned, and the reply streams from that entry run's own start. Temporal and DBOS keep the per-call abandonment submits followed by execute().

Reader reconciliation. An executor may implement the optional MinimalExecutor.reconcileSession(sessionId) (CF Workflows does). handleChatStream calls it once, after its first reads, when the current run is live, and re-reads on changed; buildSnapshot calls it before its view read when you pass the executor (BuildSnapshotDeps.executor). It settles a run whose owner closed without its terminal commit and starts a continuation a stored answer is still owed, so a chat-only host never strands a turn.

The function returns a web Response with an SSE body (or the JSON error above).

Direct mode (in-process executor) ​

Use direct mode when your API routes run in the same process as the agent executor.

┌─────────────────────────────────────────┐
│           Your Server                    │
│                                          │
│  Route handler → handleChatStream        │
│                ↓                         │
│           AgentExecutor (JS / Temporal)  │
│                ↓                         │
│      StateStore + StreamManager          │
│        (Redis, Memory, etc.)             │
└─────────────────────────────────────────┘

Use with: JS Runtime, Temporal Runtime, Cloudflare Workflows (same worker).

typescript
import {
  handleChatStream,
  buildSnapshot,
  getUIMessages,
  type HandleChatStreamParams,
} from '@helix-agents/ai-sdk';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { RedisStateStore, RedisStreamManager } from '@helix-agents/store-redis';

const stateStore = new RedisStateStore(redis);
const streamManager = new RedisStreamManager(redis, subscriberRedis);
const executor = new JSAgentExecutor(stateStore, streamManager, llmAdapter);

// Shared deps for chat / snapshot / message-history helpers.
const deps = {
  executor,
  stateStore,
  streamManager,
  agent: MyAgent,
} as const;

// Drive every chat POST/GET through the seven-path orchestrator.
export function dispatchChat(params: HandleChatStreamParams): Promise<Response> {
  return handleChatStream(deps, params);
}

// SSR snapshot surface for `useChat({ messages })`.
export const getSnapshot = (sessionId: string) => buildSnapshot(deps, { sessionId });

// Optional paginated message-history loader.
export const getMessages = (sessionId: string, opts: { offset?: number; limit?: number } = {}) =>
  getUIMessages({ stateStore }, { sessionId, ...opts });

Cloudflare Durable Objects mode ​

For Cloudflare deployments, use the DO-backed executor and store clients:

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

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

// POST/GET chat — returns a web Response with an SSE body.
export const dispatchChat = (params: Parameters<typeof chat.handleChat>[0]) =>
  chat.handleChat(params);

// SSR snapshot surface.
export const getSnapshot = (sessionId: string) => chat.getSnapshot({ sessionId });

// Paginated history.
export const getMessages = (sessionId: string, opts: { offset?: number; limit?: number } = {}) =>
  chat.getMessages({ sessionId, ...opts });

The factory constructs the DO-backed executor, stateStore, and streamManager internally and exposes handleChat / getSnapshot / getMessages on a single object. If you need direct access to the DO clients (e.g., for executor.submitToolResult), import them from @helix-agents/ai-sdk/cloudflare:

typescript
import {
  DOFrontendExecutor,
  DOStateStoreClient,
  DOStreamManagerClient,
} from '@helix-agents/ai-sdk/cloudflare';

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

HandleChatStreamParams reference ​

typescript
interface HandleChatStreamParams {
  sessionId: string;
  /** Incoming AI SDK v6 messages. Only the tail is inspected. */
  messages: readonly UIMessage[];
  /** Forwarded to executor.execute as `userId` for attribution. */
  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) to skip already-delivered chunks and reuse
   * the in-flight assistant message id.
   */
  request?: Request;
  /**
   * The client's abort signal; defaults to `request.signal`. When it aborts
   * (the client disconnected), 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. Pass it when you call the
   * handler without a `Request` (Express: `createWebResponseExpressMiddleware`
   * hands your handler a `request` carrying it; pass that). On Cloudflare Workers the incoming
   * `request.signal` fires only with the `enable_request_signal`
   * compatibility flag; see "Client disconnects" below.
   */
  signal?: AbortSignal;
  /** Explicit override for X-Resume-From-Sequence. */
  resumeFromSequence?: number;
  /** Explicit override for X-Existing-Message-Id. */
  existingMessageId?: string;
  /**
   * C-1 (`X-Helix-Replay-Prefix: 1`): the client removed the existingMessageId
   * message from its seed; replay its committed prefix, then the tail.
   */
  replayPrefix?: boolean;
  /** Fork the session from a checkpoint or a prior turn (`BranchOptions`). */
  branch?: BranchOptions;
  /** Initial custom state for a fresh session (BYO-state SSR); ignored, with a warn, once the session has messages. */
  initialState?: unknown;
  /** History override for a fresh session (BYO-history SSR). */
  history?: readonly Message[];
}

Client disconnects ​

When a client goes away mid-stream (a page reload, a closed tab, a dropped network), the handler stops reading the run's stream for that response: the runtime's upstream reader (a Durable Object /sse connection, a Redis subscription, an in-memory reader) is closed and nothing more is written. The run is not interrupted; its hooks fire once, and a reconnect resumes from the client's cursor exactly as before.

The handler learns about the disconnect from an AbortSignal:

  • Fetch-style servers (Hono, Next.js route handlers, Workers): pass the incoming request (its request.signal is used) or signal: request.signal.
  • Express: createWebResponseExpressMiddleware passes your handler { request, signal }: request is a web Request with the incoming URL, method and headers whose signal aborts when the response closes; forward it as request (it also carries the request key and the resume headers). pipeWebResponseToExpress also cancels the body when the client disconnects.
  • Cloudflare Workers: the enable_request_signal compatibility flag is required (Cloudflare compatibility flags). It has no default enable date, so no compatibility_date implies it: list it in compatibility_flags. Without it Cloudflare never reports a disconnect to your Worker: on the edge (measured 2026-10-08, 5 runs each) neither request.signal nor the body's cancel fired within 30 s, even on a stream writing every 50 ms, so the Durable Object keeps streaming to nobody until the run ends. With it, the edge reports a client that left a quiet stream to the Worker within about 0.3 s, and the Durable Object's /sse request learns of it within about 0.1 s of the disconnect (upper bounds, observed from the client). (Local wrangler dev / Miniflare report it only at the response's next write.) handleChatStream logs one warning per isolate when the runtime reports the flag off (core requestSignalSupport(), REQUEST_SIGNAL_DISABLED_WARNING), through your logger, else console.warn.
jsonc
// wrangler.jsonc
{
  "compatibility_flags": ["nodejs_compat", "enable_request_signal"],
}

HandleChatStreamDeps reference ​

typescript
interface HandleChatStreamDeps {
  executor: MinimalExecutor;
  stateStore: MinimalStateStore;
  streamManager: MinimalStreamManager;
  agent: AnyAgentConfig | MinimalAgentConfig;
  logger?: Logger;
  /** Forwarded to the StreamTransformer. */
  transformerOptions?: StreamTransformerOptions;
  /** Content-Length limit (default 10 MB); larger requests get 413. */
  maxRequestBytes?: number;
  /**
   * Fail-safe bound on waiting for a live run's stream to go live (default
   * 30 000 ms); past it a resume answers 503 `transport_stream_drop` /
   * `run_not_live`.
   */
  liveWaitTimeoutMs?: number;
  /**
   * The options your snapshot route passes to `buildSnapshot` (`generateId`,
   * `includeReasoning`, `filterHidden`, `includeToolResults`). The C-1
   * replay-prefix read uses them, so they must match.
   */
  snapshotOptions?: Partial<SnapshotOptions>;
  /**
   * Bound on the replay-prefix read's generic fenced snapshot read (default
   * 10 000 ms); pass your snapshot route's `BuildSnapshotDeps` value.
   */
  snapshotReadDeadlineMs?: number;
}

createCloudflareChatHandler takes namespace, agentName, logger, transformerOptions and maxRequestBytes; it does not take liveWaitTimeoutMs or snapshotOptions, so the defaults apply there. Its snapshots are the Durable Object's one-transaction read, so snapshotReadDeadlineMs does not apply to it.

buildSnapshot(deps, params) takes the same snapshotReadDeadlineMs on its deps: the whole-read deadline of core's fenced generic reader (default 10 000 ms; a read still running then fails with the retryable snapshot_unstable). A read pass has no time bound of its own, so raise it only when one whole read on your store can take longer.

Multi-message trailing-user run ​

handleChatStream walks back from the end of messages and forwards the entire run of consecutive trailing user messages to the executor — not just the last one. Any leading non-user message (typically an assistant) terminates the run; the trailing user messages between it and the array end are all included.

This is the canonical mechanism for injecting per-turn hidden context the LLM should see but the chat UI should hide:

typescript
import { COMMON_METADATA_KEYS } from '@helix-agents/core';

// app/api/chat/[sessionId]/route.ts
export async function POST(req: Request, { params }: { params: { sessionId: string } }) {
  const body = await req.json();
  const incoming = body.messages ?? [];
  const draft = await getDraft(params.sessionId);

  // Find the trailing user position and prepend a hidden context message.
  let insertAt = incoming.length;
  for (let i = incoming.length - 1; i >= 0; i--) {
    if (incoming[i].role === 'user') {
      insertAt = i;
      break;
    }
  }

  const hiddenDraft = {
    id: `draft-${Date.now()}`,
    role: 'user' as const,
    parts: [{ type: 'text', text: `<draft>${draft}</draft>` }],
    metadata: { [COMMON_METADATA_KEYS.HIDDEN]: true },
  };

  const messages = [...incoming.slice(0, insertAt), hiddenDraft, ...incoming.slice(insertAt)];

  return dispatchChat({ sessionId: params.sessionId, messages, request: req });
}

The executor receives [hiddenDraft, userTypedMessage] as a two-element UserInputMessage[]. Both persist as full user messages with their metadata. On reload, convertToUIMessages (default filterHidden: true) drops the hidden one — the chat UI shows only the user's typed text.

Per-message preservation: each trailing user message keeps its own content, files, and metadata. Session-level params.metadata falls back per-message when a UIMessage lacks its own metadata. The 64 KB per-message metadata budget applies per-message — oversized metadata on one trailing message is dropped without poisoning the rest of the run.

See UI Messages › Hidden Messages for the full lifecycle including the client-side useChat.setMessages injection pattern (and the live-session UI-leak caveat).

Next.js App Router route handlers ​

typescript
// app/api/chat/[sessionId]/route.ts
import { dispatchChat } from '@/lib/handler';

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

// GET — used by AI SDK's reconnectToStream after a page refresh.
export async function GET(req: Request, { params }: { params: { sessionId: string } }) {
  return dispatchChat({
    sessionId: params.sessionId,
    messages: [],
    request: req,
  });
}
typescript
// app/api/chat/[sessionId]/snapshot/route.ts
import { getSnapshot } from '@/lib/handler';
import { snapshotErrorResponse, snapshotNotFoundResponse } from '@helix-agents/ai-sdk';

export async function GET(_req: Request, { params }: { params: { sessionId: string } }) {
  try {
    const snapshot = await getSnapshot(params.sessionId);
    // 404 {error, code: 'state_session_not_found'} only for a missing session.
    return snapshot ? Response.json(snapshot) : snapshotNotFoundResponse();
  } catch (error) {
    // A failed read is a typed 503 (retried) or 409 {error, code, cause?}.
    return snapshotErrorResponse(error);
  }
}

snapshotNotFoundResponse / snapshotErrorResponse are exported from @helix-agents/ai-sdk (and re-exported from @helix-agents/ai-sdk/cloudflare). See Wire errors for every typed response.

Client: DefaultChatTransport + prepareHelixChatRequest ​

The v7 client side uses the AI SDK's stock DefaultChatTransport plus two request preparers from @helix-agents/ai-sdk/client:

  • prepareHelixChatRequest — packs id / messages / trigger / messageId onto the body and stamps X-Resume-From-Sequence / X-Existing-Message-Id headers when configured.
  • prepareHelixReconnectRequest — points the AI SDK's reconnect (${api}/${chatId}/stream by default) at the Helix single-path route, as a GET with X-Resume-From-Sequence, X-Existing-Message-Id and, with replayPrefix: true, X-Helix-Replay-Prefix: 1. Per-request headers (chat.resumeStream({ headers })) win over the configured values.

prepareHelixReconnectRequest is required for HITL UX

Without prepareHelixReconnectRequest, a page refresh during an in-flight stream silently 404s. The live SSE stream goes unread, so any tool that hadn't completed yet stays in pending state forever in the UI. Always wire both preparers when using HITL or client-executed tools.

tsx
'use client';

import { DefaultChatTransport } from 'ai';
import {
  prepareHelixChatRequest,
  prepareHelixReconnectRequest,
  snapshotResumeOptions,
} from '@helix-agents/ai-sdk/client';
import { useHelixChat } from '@helix-agents/ai-sdk/react';
import { useMemo } from 'react';
import type { FrontendSnapshot } from '@helix-agents/ai-sdk';

interface Props {
  sessionId: string;
  initialSnapshot: FrontendSnapshot<MyState>;
}

export function ChatClient({ sessionId, initialSnapshot }: Props) {
  // What to seed and how to resume. While the snapshot is live (active /
  // paused) the seed leaves out the in-flight message (resumeMessageId) and the
  // resume asks the server to replay its committed prefix (C-1).
  const resumeOptions = useMemo(() => snapshotResumeOptions(initialSnapshot), [initialSnapshot]);

  const transport = useMemo(() => {
    const api = `/api/chat/${sessionId}`;
    return new DefaultChatTransport({
      api,
      prepareSendMessagesRequest: prepareHelixChatRequest({ api }),
      prepareReconnectToStreamRequest: prepareHelixReconnectRequest({
        api,
        resumeFromSequence: resumeOptions.resumeFromSequence,
        existingMessageId: resumeOptions.existingMessageId,
        replayPrefix: resumeOptions.replayPrefix,
      }),
    });
  }, [sessionId, resumeOptions]);

  // useHelixChat composes useChat + useResumeClientTools + correct
  // sendAutomaticallyWhen, never re-runs a replayed tool call, and with
  // `resync` re-anchors on the snapshot after a rewind or a retried attempt.
  const chat = useHelixChat({
    id: sessionId,
    transport,
    messages: resumeOptions.messages,
    resume: resumeOptions.resume,
    resync: { snapshotUrl: `/api/chat/${sessionId}/snapshot`, snapshot: initialSnapshot },
    toolHandlers: {
      // editContent: async (input, { abortSignal }) => { ... },
    },
  });

  if (chat.resyncError) return <RetryBanner error={chat.resyncError} />;
  return <MessageList messages={chat.messages} />;
}

snapshotResumeOptions(snapshot, { replayPrefix? }) returns { resume, resumeFromSequence?, existingMessageId?, replayPrefix?, messages }:

  • active / paused: resume: true, the snapshot cursor, existingMessageId: snapshot.resumeMessageId, replayPrefix: true, and messages without the resumeMessageId message;
  • ended / failed: resume: false and the whole snapshot.

Seed the chat with messages and send replayPrefix on the reconnect: the flag and the seed always travel together. ai 6.0.281 starts a resumed response from an empty message that replaces the last one, while ai ≤ 6.0.230 continue it, so a kept message would lose its committed steps on one and be duplicated by the replay on the other. A client that cannot suppress replayed client tools (a plain useChat, or useHelixChat with a pre-built Chat) passes { replayPrefix: false }: it seeds the whole snapshot and resumes the tail, which is lossy on ai 6.0.281. Its predicate then takes the seed as history (useHelixSendAutomaticallyWhen(id, { history: seed }) or createHelixSendAutomaticallyWhen({ history: seed })), so the seed's answered client tools are never sent again (CP-107); useHelixChat does this for its own seed. The full contract is in Resumable Streams.

A send refused because a run is already active answers 409 state_already_running. The chat ends with status 'error'; parseHelixChatError(chat.error) returns { error?, code, retryable?, cause? } (or undefined for an untyped error), and chat.regenerate() re-sends the same message:

tsx
import { parseHelixChatError } from '@helix-agents/ai-sdk/client';

const typed = parseHelixChatError(chat.error);
if (typed?.retryable) return <button onClick={() => void chat.regenerate()}>Retry</button>;

For finer control, drop down to the building blocks directly:

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

const sendAutomaticallyWhen = useHelixSendAutomaticallyWhen(sessionId);
const chat = useChat({ transport, sendAutomaticallyWhen });
useResumeClientTools({ chat, toolHandlers });

Don't use AI SDK's stock lastAssistantMessageIsCompleteWithToolCalls

That helper is stateless: once a terminal tool part appears, every subsequent sendAutomaticallyWhen evaluation returns true, which causes AI SDK to fire an infinite chain of POSTs (490 POSTs in 58s observed in production). Use useHelixSendAutomaticallyWhen or useHelixChat instead.

See the @helix-agents/ai-sdk README — composability ladder for the full three-layer breakdown including createHelixSendAutomaticallyWhen for non-React lifecycles.

PrepareHelixChatRequestOptions ​

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

The same options shape applies to both prepareHelixChatRequest and prepareHelixReconnectRequest. A fresh send ignores X-Existing-Message-Id on the server (a new turn always gets its own id), so a getter that always returns the last assistant id is harmless there.

Loading message history ​

Two paths to populate useChat({ messages }):

The snapshot is the canonical SSR / hydrate surface. It is read from one checkpoint: the committed messages up to a cursor, and the state at that cursor. It never carries in-flight content; that arrives through the resumed stream. The content-replay and partial-content options are removed (breaking; see the upgrade guide).

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

// Options: generateId, includeReasoning, filterHidden, includeToolResults.
// Pass `executor` to reconcile the session first (CF Workflows' reconcileSession).
const snapshot = await buildSnapshot({ stateStore, streamManager, executor }, { sessionId });

// null: the session does not exist. A failed read throws state_history_incomplete.
// snapshot contains:
// - sessionId: string
// - messages: UIMessage[]        the committed rows [0, checkpoint.messageCount), never capped
// - streamSequence: number       the cursor: resume with chunks seq > streamSequence
// - state: TState | null         the custom state at the cursor
// - status: 'active' | 'paused' | 'ended' | 'failed'   decided by the current run
// - checkpointId: string | null, stepCount: number
// - resumeMessageId?: string     when live: the message the run's response streams into
// - latestTruncateCheckpointId?: string | null
// - errorDetail?: ErrorDetail     only when status is 'failed': the error that ended the turn (scrubbed)
// - pendingCalls?: SnapshotPendingCall[]   calls awaiting the user that `messages` does not show (CP-250 (c))

pendingCalls lists the calls the user can answer through this root that live on another session of the tree: typically a persistent companion that paused on its own call after the root's turn ended. Its frames never reached the root's ended stream, so only the snapshot shows it. snapshotResumeOptions(snapshot) (and so useHelixChat) appends them to the seed as one assistant message. A stock useChat app that seeds from snapshot.messages appends it itself; the helper skips any call the messages already show:

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

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

Answer it with addToolResult / addToolApprovalResponse as usual: the answer goes through the root and wakes the companion, and its outcome reaches the root's next turn. On Temporal, pass the executor: it declares supportsCompanionSelfWake: false (such a submit is refused until CP-246), and a companion's calls are then not listed.

A reloaded page shows a failed turn's error from the snapshot it seeds the chat with, keyed on the typed code:

typescript
if (snapshot.status === 'failed' && snapshot.errorDetail) {
  showBanner(snapshot.errorDetail.code ?? 'error', snapshot.errorDetail.message);
}

status is active / paused whenever the current run is live, including the window after its run-start commit and before its stream is claimed. See Snapshots for the read, its fence and legacy mode.

loadAllUIMessages (no snapshot) ​

For backends that don't run live streams, just load history:

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

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

Paginated variant:

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

const { messages, hasMore } = await loadUIMessages(stateStore, sessionId, {
  offset: 0,
  limit: 50,
  includeReasoning: true,
  includeToolResults: true,
});

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

createUIMessageStore wrapper ​

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);

StreamTransformer ​

handleChatStream and buildSnapshot both use StreamTransformer internally to convert Helix chunks to AI SDK Data Stream events. You only construct it directly when wiring a custom server.

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,
});

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

// Always finalize to close blocks and emit finish.
const { events } = transformer.finalize();
for (const event of events) yield event;

includeStepEvents: true (RM-48, RM-49)

Every runtime writes step_start as each LLM attempt's first chunk, so start-step always arrives. finish-step needs the adapter to report the step's end: @helix-agents/llm-vercel does not yet (RM-48), so with it you get start-step without finish-step on every runtime. DBOS now writes step_end when the adapter reports it, like the other runtimes (its half of RM-49). With an adapter that does report step boundaries, the chunks are emitted bare and every supported ai version accepts them (DI-20, fixed; see UI-chunk wire contract). See the conformance findings.

Event Mapping ​

Helix ChunkAI SDK Events
text_deltatext-start (once), text-delta
thinkingreasoning-start (once), reasoning-delta, reasoning-end (if complete)
tool_starttext-end (if text open), tool-input-available
tool_endtool-output-available (a result); tool-output-error (a failure: errorText, as on reload)
subagent_startdata-subagent-start
subagent_enddata-subagent-end
customdata-{eventName}
state_patchdata-state-patch
errordata-error-metadata (transient; code, recoverable, detail), then error (errorText = userMessageFor(code))
outputdata-output
source_urlsource-url
source_documentsource-document
filefile (filename rides in providerMetadata.helix.filename)
step_startbare start-step (only with includeStepEvents: true)
step_endopen blocks closed, then bare finish-step (only with includeStepEvents)

Tool Argument Streaming ​

Helix ChunkAI SDK Event
tool_arg_stream_starttool-input-start
tool_arg_stream_deltatool-input-delta
tool_arg_stream_endnothing (see below)
tool_input_errortool-input-error
tool_output_errortool-output-error

Approval-gated tool events (v7) ​

Helix ChunkAI SDK Event
tool_approval_requesttool-approval-request { approvalId, toolCallId } (part state approval-requested)
tool_approval_response (approved)nothing: the decision is client-side part state (addToolApprovalResponse)
tool_approval_response (denied)tool-output-available with the deny string Tool call was not approved by the user

ai v6 has no tool-approval-response UI chunk, and the approval decision lives in the tool part's client-side state, so Helix never sends one (see UI-chunk wire contract).

Control Flow Events ​

Helix ChunkAI SDK Event
run_interrupteddata-run-interrupted
run_resumeddata-run-resumed
run_pauseddata-run-paused
checkpoint_created¹data-checkpoint-created
step_committeddata-step-committed { runId, stepId, checkpointId }
step_discardeddata-step-discarded { runId, stepId, reason }
stream_resyncdata-stream-resync (carries runId and, after a truncating rewind, truncateCheckpointId)
run_terminateddata-run-terminated { runId, reason }
run_startednothing (dropped; it only attributes chunks to runs)
(retried attempt)data-attempt-superseded { reason: 'attempt_superseded', runId, agentId, step, attemptId, supersededAttemptId }
(result, earlier call)data-tool-output-elsewhere { reason: 'tool_output_elsewhere', toolCallId, toolName, runId? } instead of the tool-output-*
(replayed prefix)transient data-helix-replay { phase: 'begin' | 'end', messageId } around a C-1 replay

¹ Forwarded if present; no runtime writes checkpoint_created today.

attemptId is never forwarded on content events, and step_committed.committedAttemptId is not added to data-step-committed. data-attempt-superseded is emitted when a step's current attempt switches live (a newer attempt started, or a commit named another attempt), and once after start on a resume whose cursor is past superseded content the client was already sent; the handler drops a superseded attempt's content from what it replays. See Retried attempts.

A tool_end / tool_output_error / denied approval becomes a tool-output-* only for a call the response message holds; for a call an earlier message holds (ai would throw No tool invocation found) the response carries data-tool-output-elsewhere instead. See Tool results for a call in an earlier message.

Important: Tool input/output events include dynamic: true because Helix tools are defined at runtime (the replayed tool-output-denied is bare: ai's schema allows only { type, toolCallId } there). Server-executed tools also receive providerExecuted: true on tool-input-available so edge environments don't re-fire the tool from the client.

A call gets ONE tool-input-available, from whichever chunk announces it first: its tool_start, a tool_approval_request for an approval-gated call that was not started, or the end of the stream for a streamed call that got neither (marked _incomplete when its arguments never finished). A streamed call's tool_arg_stream_end ends its argument text but does not announce it: ai validates a tool call's input through the tool's schema (defaults applied, unknown keys stripped), and tool_start carries that validated input, so the part, onToolCall and the reloaded snapshot all see the same arguments. Every path carries the same providerExecuted and providerMetadata.helix.runId, so stock lastAssistantMessageIsCompleteWithToolCalls never reads an answered server call as an answered client call. A client call's tool_start also carries its deadline: the event's providerMetadata.helix gains deadlineAt and serverNow (the chunk's write time), which ai keeps on the part as callProviderMetadata.helix, so the dispatcher can skip a call that lapsed while the page was away (SDK-3; a stock useChat sees only the metadata).

UI-chunk wire contract ​

Every event the transformer emits is serialized verbatim onto the SSE stream. A stock ai client (useChat / Chat with DefaultChatTransport) parses each chunk with ai's uiMessageChunkSchema and fails the whole response on the first chunk that does not parse (status 'error', "Type validation failed"). In ai 6.0.0–6.0.230 every chunk schema is strict (an extra key is fatal); 6.0.231+ tolerates extra keys but still rejects unknown chunk types.

So Helix emits only chunk types and fields that ai 6.0.0 (the peer-range floor) declares:

  • Step events are bare { type: 'start-step' } / { type: 'finish-step' }, exactly as ai's own toUIMessageStream emits them. Per-step stepId/usage/finishReason are not on the UI stream; read usage server-side (the core step_end chunk, run/session usage, or step hooks). stepId is still on data-step-committed / data-step-discarded.
  • Helix-specific data travels on data-* events (e.g. data-error-metadata) or inside providerMetadata.helix (e.g. runId on tool events, filename on file).

This contract is enforced by packages/ai-sdk/src/__tests__/ui-chunk-schema-contract.test.ts against pinned ai 6.0.0 / 6.0.49 / 6.0.91 / 6.0.230 / 6.0.281 (including a compile-time exact-keys check against ai 6.0.0's UIMessageChunk type). The runtime conformance suite also proves it end to end: its stream.ui-chunk-schema (host) and stream.ui-chunk-schema-client (client) cells run the real chat routes on a Cloudflare DO and a JS host with includeStepEvents: true. The host cell parses every exchange through ai's strict pipeline. The client cell uses the real useHelixChat hooks, and its text, client-tool, paused and reloaded runs complete without a client error. If you add events in a custom server, keep them to the same shapes.

With includeStepEvents, start-step always arrives; whether finish-step does depends on your LLM adapter. See the includeStepEvents warning above (RM-48, RM-49).

Stateless resume helpers ​

handleChatStream calls these helpers internally; they're exported individually for custom server wiring.

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.

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

const { intents, rejected, hasTrailingUserMessage } = extractResumeIntent(
  messages,
  sessionState,
  currentRunId
);
// intents      → submit each via executor.submitToolResult()
// rejected     → no intent applies: a typed 409; a rejected answer for a still-pending call beside a stored
//                one: 409 `answer_not_stored`; else `data-resume-rejected` events
// hasTrailingUserMessage → if true, may be a fresh-turn or abandonment

findExpiredPending ​

Sweep for pending client-tool calls past their deadline.

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',
  });
}

Message Converter ​

Converts Helix internal 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,
});

AI SDK v6 Format ​

typescript
interface UIMessage {
  id: string;
  role: 'user' | 'assistant' | 'system';
  parts: UIMessagePart[]; // v6: parts is the source of truth
}

type UIMessagePart =
  | { type: 'text'; text: string }
  | { type: 'reasoning'; text: string }
  | {
      type: `tool-${string}` | 'dynamic-tool';
      toolCallId: string;
      input: Record<string, unknown>;
      state: ToolInvocationState;
      output?: unknown;
    };

Conversion Rules ​

  1. System messages → Single text part
  2. User messages → Single text part (or text + file parts)
  3. Assistant messages → Text, reasoning, and tool parts
  4. Tool result messages → Merged into assistant's tool parts (not separate messages). The part's output is decoded with core decodeToolResultContent: a string result (marked helixContentEncoding: 'text') stays that exact string, anything else is parsed JSON
  5. __finish__ tool dropped → convertToAISDKMessages drops the auto-injected __finish__ tool call and its synthetic { acknowledged: true } tool result, so a structured-output agent's persisted history does not render a spurious internal tool card on reload. This is parity with the live stream transformer (which already filters __finish__). Now that every structured-output session persists a synthetic __finish__ tool result (all runtimes), this filter keeps history projections clean.
typescript
// Helix messages
[
  { role: 'user', content: 'Hello' },
  { role: 'assistant', content: 'Let me search...', toolCalls: [...] },
  { role: 'tool', toolCallId: 'tc1', content: '{"result": "..."}' },
]

// Converted to UI messages (v6 format)
[
  { id: 'msg-0', role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
  {
    id: 'msg-1',
    role: 'assistant',
    parts: [
      { type: 'text', text: 'Let me search...' },
      { type: 'tool-search', toolCallId: 'tc1', input: {...}, state: 'output-available', output: {...} }
    ]
  },
]

State Mapping ​

Core StateAI SDK StateDescription
pendinginput-availableAwaiting execution
executinginput-availableCurrently running
completedoutput-availableFinished successfully
erroroutput-errorExecution failed

For complete documentation, see UI Messages Guide.

Server-Side Rendering with Next.js ​

The seven-path orchestrator + buildSnapshot work seamlessly with Next.js App Router for SSR.

Server Component ​

typescript
// app/chat/[sessionId]/page.tsx
import { getSnapshot } from '@/lib/handler';
import { ChatClient } from './ChatClient';
import { notFound } from 'next/navigation';

export default async function ChatPage({
  params,
}: {
  params: { sessionId: string };
}) {
  // Server-side snapshot — no API call needed.
  const snapshot = await getSnapshot(params.sessionId);
  if (!snapshot) notFound();

  return (
    <div className="container mx-auto p-4">
      <h1>Chat Session</h1>
      <p className="text-gray-600">
        Status: {snapshot.status} | Sequence: {snapshot.streamSequence}
      </p>
      <ChatClient sessionId={params.sessionId} initialSnapshot={snapshot} />
    </div>
  );
}

Client Component ​

typescript
// app/chat/[sessionId]/ChatClient.tsx
'use client';

import { DefaultChatTransport } from 'ai';
import {
  prepareHelixChatRequest,
  prepareHelixReconnectRequest,
  snapshotResumeOptions,
} from '@helix-agents/ai-sdk/client';
import { useHelixChat } from '@helix-agents/ai-sdk/react';
import { useMemo, useState } from 'react';
import type { FrontendSnapshot } from '@helix-agents/ai-sdk';

interface Props {
  sessionId: string;
  initialSnapshot: FrontendSnapshot<MyState>;
}

export function ChatClient({ sessionId, initialSnapshot }: Props) {
  const [input, setInput] = useState('');
  const resumeOptions = useMemo(() => snapshotResumeOptions(initialSnapshot), [initialSnapshot]);

  const transport = useMemo(() => {
    const api = `/api/chat/${sessionId}`;
    return new DefaultChatTransport({
      api,
      prepareSendMessagesRequest: prepareHelixChatRequest({ api }),
      prepareReconnectToStreamRequest: prepareHelixReconnectRequest({
        api,
        resumeFromSequence: resumeOptions.resumeFromSequence,
        existingMessageId: resumeOptions.existingMessageId,
        replayPrefix: resumeOptions.replayPrefix,
      }),
    });
  }, [sessionId, resumeOptions]);

  const chat = useHelixChat({
    id: sessionId,
    transport,
    messages: resumeOptions.messages,
    resume: resumeOptions.resume,
    resync: { snapshotUrl: `/api/chat/${sessionId}/snapshot`, snapshot: initialSnapshot },
    toolHandlers: {},
  });

  return (
    <div className="flex flex-col gap-4">
      <div className="flex-1 overflow-y-auto">
      {chat.messages.map((m) => (
          <div key={m.id} className={m.role === 'user' ? 'bg-blue-100' : 'bg-gray-100'}>
            <strong>{m.role}:</strong>
            {m.parts.map((p, i) =>
              p.type === 'text' ? <span key={i}>{p.text}</span> : null,
            )}
          </div>
        ))}
      </div>

      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (!input.trim()) return;
          chat.sendMessage({ text: input });
          setInput('');
        }}
        className="flex gap-2"
      >
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask a question..."
          className="flex-1 border rounded p-2"
          disabled={chat.status === 'streaming'}
        />
        <button type="submit" disabled={chat.status === 'streaming'}>
          Send
        </button>
      </form>
    </div>
  );
}

API Routes for Next.js App Router ​

typescript
// app/api/chat/[sessionId]/route.ts
import { dispatchChat } from '@/lib/handler';

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

export async function GET(req: Request, { params }: { params: { sessionId: string } }) {
  return dispatchChat({
    sessionId: params.sessionId,
    messages: [],
    request: req,
  });
}

The snapshot route is the one shown in Next.js App Router route handlers: 404 only for a missing session, a typed 503 (retryable) or 409 for a failed read.

The server component should not treat a failed read as an empty conversation either. The examples retry the server-side read (250 / 500 / 1000 ms) and, after exhaustion, render an explicit error state whose Retry control reads the snapshot from the browser with fetchSnapshotWithRetry (see SnapshotGate in examples/resumable-streams-nextjs).

Why this works ​

  1. No duplicate data transfer — messages loaded once via snapshot.
  2. No race conditions — the snapshot's messages and its cursor come from one checkpoint, so the stream after the cursor holds exactly what the messages do not.
  3. Page-refresh-during-stream is seamless — prepareHelixReconnectRequest re-attaches to the live SSE stream (path 5) or replays the terminal stream (path 6), replaying the in-flight message's committed prefix first.
  4. SSR-friendly — FrontendSnapshot is JSON-serializable.
  5. Framework-agnostic — works with any SSR solution, not just Next.js.

For a complete working demo, see the Resumable Streams Example.

Multi-Turn Conversations ​

Sessions persist all conversation state. Multiple runs can occur within a session (after interrupts, resumes, follow-up messages). With handleChatStream, you don't manage sessionId / messages / state explicitly — the orchestrator reads them from the request body and resolves continuation automatically.

Frontend Tracking ​

Track the sessionId in your URL or React state and pass it to the route:

typescript
const [sessionId] = useState<string>(() => crypto.randomUUID());

const transport = new DefaultChatTransport({
  api: `/api/chat/${sessionId}`,
  prepareSendMessagesRequest: prepareHelixChatRequest({ api: `/api/chat/${sessionId}` }),
  prepareReconnectToStreamRequest: prepareHelixReconnectRequest({ api: `/api/chat/${sessionId}` }),
});

The handler returns the sessionId in the X-Session-Id response header.

Recovery hooks ​

The @helix-agents/ai-sdk/react package provides hooks for handling stream recovery scenarios — crashes, rollbacks, branching, and the v7 client-executed-tool dispatcher.

useHelixChat works the same on every runtime — including Cloudflare DO

The recommended setup (useHelixChat, which wires helixSAW via useHelixSendAutomaticallyWhen) behaves identically across all runtimes: JS, Temporal, DBOS, Cloudflare Workflows, and Cloudflare Durable Objects. The sendAutomaticallyWhen-driven follow-up request opens an SSE stream so the client reads the continuation as it happens. On runtimes that continue the turn themselves after a tool submit (Cloudflare Workflows, Cloudflare DO and DBOS) the run is already advancing server-side and the server attaches the request to the live stream; on the others (JS / Temporal) the same request drives resume(). The submit's continuation decides it, never a guess. You do not need runtime-specific carve-outs, and you do not need to disable helixSAW on DO. See the continuation model.

useAutoResync fires only on real recovery

The resync triggers are recovery signals: data-stream-resync (a retry(), a from_checkpoint rewind, a resume that removed a crashed step's orphan chunks), data-attempt-superseded (a retried attempt replaced content the client saw) and data-tool-output-elsewhere (a tool result for a call an earlier message holds, e.g. a follow-up message sent while client-tool calls were pending). None is emitted on the client-tool happy path.

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 it is live, resumes from its cursor under resumeMessageId. useHelixChat({ resync: { snapshotUrl } }) wires all of it:

tsx
const chat = useHelixChat({
  id: sessionId,
  transport,
  messages: resumeOptions.messages,
  resume: resumeOptions.resume,
  resync: {
    snapshotUrl: `/api/chat/${sessionId}/snapshot`,
    snapshot: initialSnapshot, // skip a replayed rewind this snapshot already covers
    snapshotFetch: { fetch: authedFetch }, // fetchSnapshotWithRetry options
    onResync: (event) => {}, // AISDKResyncTriggerEvent
    onError: (error) => {},
  },
});
if (chat.resyncError) return <RetryBanner error={chat.resyncError} />;

useHelixChat returns UseHelixChatResult: useChat's UseChatHelpers plus resyncError: Error | null (the last re-anchor or re-attach error, cleared by the next success, a re-attach error also by a new send or a retry() whose re-anchor succeeds; always null without resync; it can be a ReanchorExhaustedError). With a transport (the ChatInit path), its stop aborts the transport connection (on ai < 6.0.281 chat.stop() alone cannot stop a resumed stream) and resolves once the chat is idle, its sendMessage stops an open resume first. Its addToolOutput / addToolApprovalResponse release a replayed tool call that the client answers after the replay, so that answer is sent (a replayed call's committed result never is).

Breaking: the hook used to return UseChatHelpers directly; onResync now receives AISDKResyncTriggerEvent (AISDKStreamResyncEvent | AISDKAttemptSupersededEvent; narrow on event.type before reading checkpointId); a resync hook with no stream control surfaces a typed ConfigurationError instead of replacing messages; and setMessages in the hook options is typed (messages: UIMessage[]) => void. See the upgrade guide.

A rewind (resume({ mode: 'from_checkpoint' }) or a truncating retry) writes exactly one data-stream-resync (after the run's start, before its content), carrying the truncateCheckpointId its run-start commit wrote. A page refreshed after the rewind started already shows the rewound log and replays that resync from its snapshot cursor: pass the seed snapshot (useHelixChat({ resync: { snapshotUrl, snapshot } }) or useAutoResync(data, { snapshot, ... })) and the hook skips a resync the snapshot covers (snapshot.latestTruncateCheckpointId, resyncCoveredBySnapshot). Without it, the page re-anchors once more, which is harmless.

The top-level convenience hook for client-executed tools. Composes useChat + useResumeClientTools + the correct sendAutomaticallyWhen predicate so tool outputs reliably reach the server:

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

const chat = useHelixChat({
  id: sessionId,
  transport,
  toolHandlers: {
    editContent: async (input, { abortSignal }) => {
      return await runOnClient(input, abortSignal);
    },
  },
  onClientToolError: (err, ctx) => {
    console.error(`tool ${ctx.toolName} failed`, err);
  },
});

See Client-Executed Tools for the full setup guide.

  • onLateAnswer(toolCallId) (SDK-5): a real client-tool answer arrived after its call's timeout was already consumed. The server records it and tells the model in a hidden notice; the hook calls this once per late answer (from the transient data-helix-late-answer part, { toolCallId }), so the app can reconcile, e.g. undo an edit the model believes never happened. A stock useChat reads the same part through onData.
  • A transient submit answer re-anchors (SDK-5): a submit answered transport_continuation_pending (its answer is stored; the continuation it owes did not start yet and is retried on its own) shows no error. With resync, the hook re-reads the snapshot while it says continuationOwed and follows the run once it starts.

useResumeClientTools (lower-level building block) ​

Auto-dispatch client-executed tool calls. The hook subscribes to chat.messages, picks up tool parts in state: 'input-available', dispatches handlers in parallel, aborts on session switch, and posts results back via chat.addToolOutput. Replaces ~280 LOC of consumer dispatcher boilerplate.

Use useHelixChat unless you need custom useChat config

useResumeClientTools requires sendAutomaticallyWhen to be wired on the chat instance for tool outputs to reach the server. useHelixChat handles this automatically. Drop down to useResumeClientTools only when you need control over the useChat options that useHelixChat doesn't expose.

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

// useHelixSendAutomaticallyWhen avoids the useMemo footgun
// (a fresh closure each render = a fresh Set = broken dedup).
const sendAutomaticallyWhen = useHelixSendAutomaticallyWhen(sessionId);
const chat = useChat({ transport, sendAutomaticallyWhen });

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

useResumableChat ​

Fetches the snapshot itself (with retries) and re-anchors on resync triggers. Use it when the page has no server-rendered snapshot.

tsx
import {
  collectResyncTriggers,
  resumeHeaders,
  snapshotResumeOptions,
  useHelixChat,
  useResumableChat,
} from '@helix-agents/ai-sdk/react';

function ChatPage({ sessionId }: { sessionId: string }) {
  const chat = useHelixChat({ id: sessionId, transport });

  const { isLoading, error, hasResynced, resyncCount, resyncError } = useResumableChat(
    collectResyncTriggers(chat.messages),
    {
      snapshotUrl: `/api/chat/${sessionId}/snapshot`,
      setMessages: chat.setMessages,
      chat, // stream control for the re-anchor
      onSnapshotLoaded: (snap) => {
        const opts = snapshotResumeOptions(snap);
        chat.setMessages(opts.messages);
        if (opts.resume) {
          void chat.resumeStream({
            headers: resumeHeaders({
              resumeFromSequence: opts.resumeFromSequence!,
              existingMessageId: opts.existingMessageId,
              replayPrefix: opts.replayPrefix,
            }),
          });
        }
      },
      onResync: (event) => toast.info(`Recovered (${event.data.reason})`),
    }
  );

  if (isLoading) return <Loading />;
  if (error || resyncError) return <RetryBanner error={error ?? resyncError} />;

  return (
    <div>
      {hasResynced && (
        <div className="text-sm text-gray-500">Recovered ({resyncCount} resyncs)</div>
      )}
      <Messages messages={chat.messages} />
    </div>
  );
}

It also returns snapshot, status, streamSequence, resumeMessageId, shouldResume, messages (the seed, from snapshotResumeOptions), replayPrefix, checkpoint and refetch.

Stream control for the resync hooks ​

useAutoResync and useResumableChat take the trigger parts (collectResyncTriggers(messages)) and need stream control for each re-anchor, resolved from exactly one source: control (a complete HelixResyncControl), else stop and resume together, else chat, else the mounted useHelixChat whose id is chatId (or, without chatId, whose setMessages is the hook's setMessages). With none, or with only one of stop / resume, a resync surfaces a typed ConfigurationError (missing: ['stop', 'resume']) through resyncError / onError and leaves the messages alone. A stop that does not abort the transport (a bare chat.stop) logs a development warning. Details: Resync.

A send whose stream drops, on a stock useChat: useReanchorOnError ​

A send's reply streams back on the send's own response. When that request fails while the run goes on (the browser's connection dropped, TypeError: network error; the server's in-band error event; or fetch itself failed), the chat ends in status: 'error'. useHelixChat with resync re-attaches on its own. On a plain stock useChat (no Helix chat hook), add the same re-attach with one hook. It needs the transport the chat uses, wrapped with withHelixStreamControl: the SAME instance goes to both.

tsx
import { useMemo } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useReanchorOnError, withHelixStreamControl } from '@helix-agents/ai-sdk/react';

const transport = useMemo(
  () => withHelixStreamControl(new DefaultChatTransport({ api: `/api/chat/${sessionId}` })),
  [sessionId]
);
const chat = useChat({ id: sessionId, transport });
const { reanchorError } = useReanchorOnError(chat, {
  snapshotUrl: `/api/chat/${sessionId}/snapshot`,
  transport, // the same instance the chat uses
  // snapshotFetch?, onError?, enabled? (default true)
});

Both hooks run one re-attach controller (createReattachController). It decides from what the transport recorded about the failed request (transport.connections(chatId), a ConnectionOutcome per request) and from the latest snapshot, never from the rendered status or an error's name or text:

  • A send re-attaches after a drop (dropped), the server's in-band error (server_error) or a failed fetch (unreachable), and only when the server holds that turn: the snapshot has the chat's user turns and the same text at the chat's last user turn. It replaces the messages with the snapshot's and, while the run is live, resumes from its cursor (stock setMessages + resumeStream). It never sends.
  • Left as stock useChat leaves it: a refusal answered before any stream (any non-2xx, such as a 409state_already_running), a chunk ai could not parse, a stop, the app's own throw from onToolCall / onData, and a send the server never committed (re-send it).
  • A run that ended before the re-attach read its snapshot is drawn from it and the chat's error is cleared (status: 'ready'). A failed run keeps an error.
  • A budget: at most MAX_CONSECUTIVE_REATTACHES (5) in a row, and none after a re-attach whose resumed stream delivered nothing. It resets when a request ends cleanly or the app sends. Exhausted, it reports one ReanchorExhaustedError (not retryable: offer a reload or a retry) and stops.
  • It stops writing once the app sends again, the chat id changes or the component unmounts, so a re-attach never wipes a new message or writes one chat's snapshot into another.
  • reanchorError (Error | null, also passed to onError) is the last failure it reports: a snapshot it could not read (SnapshotUnavailableError), a resume it started that the server refused, ReanchorExhaustedError, or a ConfigurationError when the transport is not the chat's own controlled one. It is cleared when a re-attach succeeds or the app sends. A 404 snapshot means the session does not exist (the server does not hold the turn): nothing is reported.
  • Because the transport is controlled, the hook's stop also aborts a resumed stream on ai 6.0.230 and older.

The rules are shared with useHelixChat (client/reattach.ts). See When a send's stream drops.

Other recovery hooks ​

HookUse CaseAuto-SnapshotAuto-Resync
useHelixChatRecommended — client tools + correct SAW wiringn/an/a
useHelixSendAutomaticallyWhenCorrect SAW predicate for custom useChat confign/an/a
useReanchorOnErrorRe-attach a stock useChat after a dropped sendYesn/a
useStreamResyncManual resync handling via callbackNoNo
useAutoResyncRe-anchor on resync triggers from a snapshot URLNoYes
useCheckpointSnapshotLoad a specific checkpointYesNo
useResyncStateTrack resync count without handlingNoNo
useResumableChatFull snapshot + resync solutionYesYes
useResumeClientToolsLower-level: auto-dispatch client-executed toolsn/an/a

Snapshot status ​

The status field on FrontendSnapshot tells the client whether to attempt stream resumption:

StatusDescriptionClient Action
activeThe current run is live and runningResume from the cursor
pausedThe current run is live and suspended (HITL)Resume from the cursor
endedNo live run; the last one did not failNo SSE connection needed
failedNo live run; the current run or the stream failedRender the error state

The run decides, not the stream alone: a run is live from its run-start commit, so a snapshot read before its stream is claimed is already active. snapshotResumeOptions(snapshot).resume is true exactly for active and paused.

Mid-Stream Page Refresh ​

When the user refreshes the page during active streaming, the v7 stack handles it through three coordinated pieces:

  1. The SSR snapshot (via buildSnapshot) is server-rendered. Its messages are the committed rows up to its cursor; its status is active or paused because the run is live.
  2. useHelixChat mounts with resume: true and snapshotResumeOptions(snapshot).messages, the snapshot without the in-flight resumeMessageId message. The AI SDK's reconnectToStream GETs /api/chat/[sessionId] (through prepareHelixReconnectRequest; not the default /api/chat/[sessionId]/stream) with X-Resume-From-Sequence, X-Existing-Message-Id and X-Helix-Replay-Prefix: 1.
  3. handleChatStream matches active-stream attach (path 5) while the run is in flight, or already-completed replay (path 6) when it finished while the browser was reconnecting. It reads one snapshot, replays the in-flight message's committed prefix under the same id (between transient data-helix-replay markers), then streams the tail after that snapshot's cursor. Replayed tool calls are never executed again.

A resume in the window after the run's start commit and before its stream goes live waits for it (event-driven, bounded by HandleChatStreamDeps.liveWaitTimeoutMs, 503 transport_stream_drop / run_not_live past the bound). A replay-prefix id the snapshot does not hold answers 409 state_not_resumable / resume_message_unknown. After a failed resume the in-flight message stays hidden until the client re-anchors; see When a resume fails.

existingMessageId is a continuation hint, not a skip-cursor

existingMessageId (the X-Existing-Message-Id header) identifies the in-flight assistant message to continue — it is honored only on continuation paths: active-stream attach (path 5), HITL/tool-result resume (path 3), and already-completed replay (path 6).

Because prepareHelixChatRequest runs on every outbound POST, a configured existingMessageId also rides along on a brand-new turn (trigger: 'submit-message'). That is harmless: a genuine fresh turn always mints its own assistant id server-side and ignores any supplied X-Existing-Message-Id, so the new bubble can never collide with a prior turn.

Message-id stability across SSR / live / commit

The run's response streams into generateId(run.startUIMessageCount) (msg-<n> by default): the first-row index of the UI message that holds it. The snapshot's resumeMessageId, the live stream's start event and the converter's id for the committed message all agree, which keeps useChat from rendering a duplicate (or dropping the in-progress bubble) across the refresh → resume → commit lifecycle. Ids are not contiguous; see UI Messages.

Run boundaries (RM-43, RM-44, RM-45)

The id is only as good as the run's recorded startUIMessageCount. Every runtime now records it inside the run's run-start commit, computed over the session's rows plus the run's input rows (runBoundaryFrom), in the same write that appends those rows. RM-43 (runtime-js with_message resumes and Cloudflare Workflows continued turns), RM-44 (Temporal continuations) and RM-45 (DBOS resumes) described boundaries read outside that write. Their records in the conformance findings stay open until the conformance suite re-judges them.

For complete details on the streaming architecture, see Mid-Stream Page Refresh and Resumable Streams.

SSE Response Builder ​

For custom server wiring (you don't need this when using handleChatStream):

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

const response = buildSSEResponse(eventsGenerator, {
  headers: { 'X-Custom-Header': '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"}

The id: field enables stream resumability.

Header Utilities ​

typescript
import { extractResumePosition, AI_SDK_UI_HEADER } from '@helix-agents/ai-sdk';

// From Last-Event-ID, X-Resume-From-Sequence, or X-Resume-At headers
const headers = Object.fromEntries(request.headers.entries());
const resumeAt = extractResumePosition(headers);

handleChatStream calls this internally on the request you pass in.

Typed Errors ​

The handler-side error classes extend AiSdkHandlerError (code, statusCode):

typescript
import {
  AiSdkHandlerError,
  ValidationError,
  StreamNotFoundError,
  StreamReaderError,
  StreamFailedError,
  ConfigurationError,
  ExecutionError,
  StreamCreationError,
  HelixStreamError,
} from '@helix-agents/ai-sdk';
ErrorCodeStatusWhen
ValidationErrorVALIDATION_ERROR400Missing/invalid request params
StreamNotFoundErrorSTREAM_NOT_FOUND404Stream doesn't exist
StreamReaderErrorSTREAM_READER_ERROR404A reader could not be created
StreamFailedErrorSTREAM_FAILED410Stream has failed
ConfigurationErrorCONFIGURATION_ERROR501Missing configuration (missing: string[]), e.g. a resync with no stream control
ExecutionErrorEXECUTION_ERROR500Agent execution failed
StreamCreationErrorSTREAM_CREATION_ERROR500Stream creation failed

handleChatStream itself answers every HelixError as a typed JSON response rather than throwing: its refusals (409 state_already_running, 409 state_run_start_conflict, 409 state_not_resumable, 503 transport_stream_drop) and any failed read (core typedErrorResponse's status: 404 for a missing session or checkpoint, 503 when retryable, 409 for a non-retryable state_* error, never 503 once the request's input is committed); see Wire errors. Only a non-Helix error rejects, AiSdkHandlerError included: answer it in the route with chatErrorResponse(error, { sessionId, logger }). On the client, parseHelixChatError(chat.error) reads the bodies back.

User-safe errors (SDK-5). Every typed body is { error, code, detail, … }: error is the user-safe sentence for code (core userMessageFor), and detail is the developer text (the error's own message, scrubbed). A stream's error event carries the same sentence as errorText (stock useChat shows it as chat.error.message), and the run's message rides the transient data-error-metadata part as detail. Render error / errorText; log detail. A stock app renders parseHelixChatError(chat.error)?.error ?? <its own fallback>, never chat.error.message for an HTTP refusal (it is the raw JSON body, detail included).

For a stream error (a provider failure included), chat.error.message is the user sentence; the provider's own text is the error metadata's detail. Capture the part in onData to log it:

tsx
// A ref, so the metadata survives the re-render that surfaces chat.error.
// An explicit initial value: React 19's types require one.
const errorMetadata = useRef<{ code?: string; recoverable?: boolean; detail?: string } | undefined>(
  undefined
);
const chat = useChat({
  transport,
  onData: (part) => {
    if (part.type === 'data-error-metadata') {
      errorMetadata.current = part.data as typeof errorMetadata.current;
      console.error('stream error detail:', errorMetadata.current?.detail); // the provider's text
    }
  },
});
// Render chat.error.message (the sentence); errorMetadata.current?.detail is the developer text.

HelixStreamError is for agent-execution errors that arrive via the SSE stream (LLM failures, tool errors). Reconstruct from stream error events:

typescript
// `metadata` is the `data-error-metadata` part's data, read in `onData`:
// `{ code, recoverable, detail }`. The error's message is `detail` (SDK-5).
const error = HelixStreamError.fromEvent({ errorText: chat.error.message }, metadata);

if (error.retryable) {
  await new Promise((r) => setTimeout(r, 2000));
  // retry...
}

createHelixChatTransport (alternative wiring) ​

For apps that talk directly to @helix-agents/agent-server (rather than implementing a single chat route on top of the chat handler), use createHelixChatTransport instead of DefaultChatTransport + the prepare helpers.

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

const transport = createHelixChatTransport({
  endpoint: '/api/agent', // base URL of the agent-server routes
  sessionId: 'my-session',
  agentType: 'my-agent',
});

const chat = useHelixChat({
  transport,
  toolHandlers: {
    editContent: async (input, { abortSignal }) => {
      return await runOnClient(input, abortSignal);
    },
  },
});

Don't use AI SDK's stock lastAssistantMessageIsCompleteWithToolCalls with this transport

That helper is stateless: once a terminal tool part appears, every subsequent sendAutomaticallyWhen evaluation returns true, which causes AI SDK to fire an infinite chain of POSTs (490 POSTs in 58s observed in production). useHelixChat automatically wires the correct Helix-flavored predicate. If you use useChat directly, pass useHelixSendAutomaticallyWhen(sessionId) as sendAutomaticallyWhen instead.

This transport speaks to agent-server's /start, /resume, /submit-tool-result, /status, and /sse endpoints. See docs/guide/client-executed-tools.md.

Client helpers ​

The snapshot, resume and resync helpers, and the entry points that export them. /client has no React dependency.

ExportRoot/client/reactWhat it does
prepareHelixChatRequest, prepareHelixReconnectRequestyesyesRequest preparers for DefaultChatTransport
snapshotResumeOptionsyesyesWhat to seed and how to resume, from a snapshot
fetchSnapshotWithRetry, SnapshotUnavailableErroryesyesSnapshot fetch: 4 fetches, 250 / 500 / 1000 ms backoff on a 5xx / 429 / network failure, then the typed error
resumeHeaders, ResumeTargetyesyesThe resume headers for chat.resumeStream({ headers })
reanchorOnSnapshot, ReanchorOptionsyesyesOne re-anchor: stop, fetch the latest snapshot, set messages, resume when live
chatResyncControl, HelixResyncControl, ResyncableChatyesyesBuild the stop / resume control a re-anchor needs from a chat (and, optionally, a controlled transport)
collectResyncTriggersyesyesThe data-stream-resync / data-attempt-superseded parts of messages, in order
resyncCoveredBySnapshotyesWhether a seed snapshot already covers a replayed rewind resync
missingResyncControlErroryesThe ConfigurationError a re-anchor without stream control raises
withHelixStreamControl, isHelixControlledTransport, HelixStreamControl, HelixControlledTransportyesyesyesPer-chat connection control: abortActive(chatId), hasActiveConnection(chatId?), activeKind(chatId)
parseHelixChatError, HelixChatErrorBodyyesyesyesRead a chat route's typed error body from chat.error
ResyncTracker, createResyncTracker, isStreamResyncEvent, isAttemptSupersededEvent, isResyncTriggerEventyesyesDedup resync triggers (a stream resync by (runId, checkpointId)) and type guards
useHelixChat, UseHelixChatResult, useAutoResync, useResumableChat, useCheckpointSnapshot, useStreamResync, useResyncStateyesThe React hooks
AISDKResyncTriggerEvent, AISDKStreamResyncEvent, AISDKAttemptSupersededEventyes (types)yes (types)The resync trigger event types
ConfigurationErroryesThe typed error a resync without stream control raises

useHelixChat builds its stream control with controlChat (an abort-aware, bounded stop that rejects with StreamStopTimeoutError, a resume with resumeHeaders, and a sendMessage serialized after an open resume). controlChat and StreamStopTimeoutError are not exported from a public entry point today; use useHelixChat, or chatResyncControl(chat, withHelixStreamControl(transport)) outside it.

@helix-agents/ai-sdk/cloudflare adds the route helpers snapshotNotFoundResponse(), snapshotErrorResponse(error) and submitToolResultErrorResponse(error).

Common Pitfalls ​

1. Importing HelixChatTransport (deleted in v7) ​

typescript
// Will throw `TypeError: HelixChatTransport is not a constructor`
import { HelixChatTransport } from '@helix-agents/ai-sdk/client';
const transport = new HelixChatTransport({ ... });

Use DefaultChatTransport plus prepareHelixChatRequest / prepareHelixReconnectRequest instead.

2. Forgetting prepareHelixReconnectRequest ​

Without it, page refresh during a stream silently 404s (the AI SDK's default reconnect URL doesn't match the Helix layout). Tools that hadn't completed stay in pending forever.

3. Forgetting to call finalize() ​

If using StreamTransformer directly:

typescript
const transformer = new StreamTransformer();
for await (const chunk of stream) {
  yield * transformer.transform(chunk).events;
}
yield * transformer.finalize().events; // don't forget this!

handleChatStream and buildSnapshot handle this automatically.

4. Looking for tool results in content ​

Tool results live in message parts, not content:

typescript
// Wrong
const result = message.content;

// Correct
const toolParts = message.parts?.filter(
  (p) => p.type.startsWith('tool-') || p.type === 'dynamic-tool'
);

Complete Example ​

typescript
// lib/handler.ts
import { handleChatStream, buildSnapshot, type HandleChatStreamParams } from '@helix-agents/ai-sdk';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { defineAgent } from '@helix-agents/core';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

const ChatAgent = defineAgent({
  name: 'chat',
  systemPrompt: 'You are a helpful assistant.',
  outputSchema: z.object({ response: z.string() }),
  llmConfig: { model: openai('gpt-6-luna') },
});

const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const executor = new JSAgentExecutor(stateStore, streamManager, new VercelAIAdapter());

const deps = {
  executor,
  stateStore,
  streamManager,
  agent: ChatAgent,
} as const;

export function dispatchChat(params: HandleChatStreamParams): Promise<Response> {
  return handleChatStream(deps, params);
}

export const getSnapshot = (sessionId: string) => buildSnapshot(deps, { sessionId });
typescript
// Hono example
import { Hono } from 'hono';
import { dispatchChat, getSnapshot } from './lib/handler.js';
import {
  chatErrorResponse,
  snapshotErrorResponse,
  snapshotNotFoundResponse,
} from '@helix-agents/ai-sdk';

const app = new Hono();

app.post('/api/chat/:sessionId', async (c) => {
  try {
    const body = await c.req.json();
    const response = await dispatchChat({
      sessionId: c.req.param('sessionId'),
      messages: body.messages ?? [],
      request: c.req.raw,
    });
    return response;
  } catch (error) {
    // `handleChatStream` answers every HelixError itself; what reaches here is
    // the route's own failure (body parsing) or an AiSdkHandlerError.
    return chatErrorResponse(error, { sessionId: c.req.param('sessionId'), logger: console });
  }
});

app.get('/api/chat/:sessionId', async (c) => {
  return dispatchChat({
    sessionId: c.req.param('sessionId'),
    messages: [],
    request: c.req.raw,
  });
});

app.get('/api/chat/:sessionId/snapshot', async (c) => {
  try {
    const snapshot = await getSnapshot(c.req.param('sessionId'));
    return snapshot ? c.json(snapshot) : snapshotNotFoundResponse(); // 404 state_session_not_found
  } catch (error) {
    return snapshotErrorResponse(error); // typed 503 / 409 {error, code, cause?}
  }
});

Next Steps ​

Released under the MIT License.