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
npm install @helix-agents/ai-sdkv7 surface at a glance
| Piece | Where it lives | What it does |
|---|---|---|
handleChatStream | @helix-agents/ai-sdk (server) | Single entry point implementing the seven-path HITL chat orchestrator |
prepareHelixChatRequest | @helix-agents/ai-sdk/client | PrepareSendMessagesRequest factory for DefaultChatTransport |
prepareHelixReconnectRequest | @helix-agents/ai-sdk/client | PrepareReconnectToStreamRequest factory — required for HITL UX |
useHelixChat | @helix-agents/ai-sdk/react | Recommended — composes useChat + useResumeClientTools + correct sendAutomaticallyWhen |
useHelixSendAutomaticallyWhen | @helix-agents/ai-sdk/react | React 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/react | Lower-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:
- Fresh new session →
executor.execute() - Continuing session, new user message →
executor.execute()withsessionId - Resume after tool / approval submit →
submitToolResult× N, then act on the submit'scontinuation(executor.resume()only forcaller_resumes) - Abandonment recovery → ONE
resume({ mode: 'with_message', message, abandonPending: true })entry on an executor that declaressupportsAbandonOnResume; otherwise fail every pending tool, then path 2 - Active-stream attach → passive subscriber on a running run (a request with no new user message only)
- Already-completed retry → terminal stream replay
- Stale runId rejection →
409state_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) anddeferred(the CF DO, when a drive or run already carries the answer): noresume()call; the response attaches to the session stream fromnew_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-retryable500(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 ownrun_startedis 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 (awith_messagesend from another tab consumed the answer), another run took the session over, or it cannot be decided, nothing is streamed: the response isstart(the trailing message's id),start-step, any gathereddata-resume-rejectedevents, onedata-tool-output-elsewhereper submitted call (stamped with the consuming run's id),finish-stepandfinish, souseHelixChat({ resync })/useAutoResyncre-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 beforefinish), never streamed into the old message. Streaming it would either paint the new turn into the old bubble or makeai'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 callsexecutor.resume(). A typed refusal is answered withinputCommitted(never503).awaiting_batch(the batch is still partial) andconsumed(an earlier continuation took the answer, or a re-sent answer for a call that already drained, CP-172): a200that ends the client's tool step (start,start-step, anydata-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:
| Predicate | Answering the client call | Approving |
|---|---|---|
lastAssistantMessageIsCompleteWithApprovalResponses | nothing is sent | both answers are sent; the run continues |
| tool-calls OR approval predicate | nothing is sent | both answers are sent; the run continues (then one consumed re-send, case B) |
lastAssistantMessageIsCompleteWithToolCalls alone | nothing is sent | nothing 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).
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:
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:
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
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(itsrequest.signalis used) orsignal: request.signal. - Express:
createWebResponseExpressMiddlewarepasses your handler{ request, signal }:requestis a webRequestwith the incoming URL, method and headers whose signal aborts when the response closes; forward it asrequest(it also carries the request key and the resume headers).pipeWebResponseToExpressalso cancels the body when the client disconnects. - Cloudflare Workers: the
enable_request_signalcompatibility flag is required (Cloudflare compatibility flags). It has no default enable date, so nocompatibility_dateimplies it: list it incompatibility_flags. Without it Cloudflare never reports a disconnect to your Worker: on the edge (measured 2026-10-08, 5 runs each) neitherrequest.signalnor 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/sserequest learns of it within about 0.1 s of the disconnect (upper bounds, observed from the client). (Localwrangler dev/ Miniflare report it only at the response's next write.)handleChatStreamlogs one warning per isolate when the runtime reports the flag off (corerequestSignalSupport(),REQUEST_SIGNAL_DISABLED_WARNING), through yourlogger, elseconsole.warn.
// wrangler.jsonc
{
"compatibility_flags": ["nodejs_compat", "enable_request_signal"],
}HandleChatStreamDeps reference
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:
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
// 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,
});
}// 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— packsid/messages/trigger/messageIdonto the body and stampsX-Resume-From-Sequence/X-Existing-Message-Idheaders when configured.prepareHelixReconnectRequest— points the AI SDK's reconnect (${api}/${chatId}/streamby default) at the Helix single-path route, as a GET withX-Resume-From-Sequence,X-Existing-Message-Idand, withreplayPrefix: 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.
'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, andmessageswithout theresumeMessageIdmessage;ended/failed:resume: falseand 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:
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:
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
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 }):
buildSnapshot() (recommended)
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).
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:
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:
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:
import { loadAllUIMessages } from '@helix-agents/ai-sdk';
const messages = await loadAllUIMessages(stateStore, sessionId, {
includeReasoning: true,
includeToolResults: true,
});Paginated variant:
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
loadAllUIMessagesfor guaranteed merging.
createUIMessageStore wrapper
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.
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 Chunk | AI SDK Events |
|---|---|
text_delta | text-start (once), text-delta |
thinking | reasoning-start (once), reasoning-delta, reasoning-end (if complete) |
tool_start | text-end (if text open), tool-input-available |
tool_end | tool-output-available (a result); tool-output-error (a failure: errorText, as on reload) |
subagent_start | data-subagent-start |
subagent_end | data-subagent-end |
custom | data-{eventName} |
state_patch | data-state-patch |
error | data-error-metadata (transient; code, recoverable, detail), then error (errorText = userMessageFor(code)) |
output | data-output |
source_url | source-url |
source_document | source-document |
file | file (filename rides in providerMetadata.helix.filename) |
step_start | bare start-step (only with includeStepEvents: true) |
step_end | open blocks closed, then bare finish-step (only with includeStepEvents) |
Tool Argument Streaming
| Helix Chunk | AI SDK Event |
|---|---|
tool_arg_stream_start | tool-input-start |
tool_arg_stream_delta | tool-input-delta |
tool_arg_stream_end | nothing (see below) |
tool_input_error | tool-input-error |
tool_output_error | tool-output-error |
Approval-gated tool events (v7)
| Helix Chunk | AI SDK Event |
|---|---|
tool_approval_request | tool-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 Chunk | AI SDK Event |
|---|---|
run_interrupted | data-run-interrupted |
run_resumed | data-run-resumed |
run_paused | data-run-paused |
checkpoint_created¹ | data-checkpoint-created |
step_committed | data-step-committed { runId, stepId, checkpointId } |
step_discarded | data-step-discarded { runId, stepId, reason } |
stream_resync | data-stream-resync (carries runId and, after a truncating rewind, truncateCheckpointId) |
run_terminated | data-run-terminated { runId, reason } |
run_started | nothing (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: truebecause Helix tools are defined at runtime (the replayedtool-output-deniedis bare:ai's schema allows only{ type, toolCallId }there). Server-executed tools also receiveproviderExecuted: trueontool-input-availableso edge environments don't re-fire the tool from the client.A call gets ONE
tool-input-available, from whichever chunk announces it first: itstool_start, atool_approval_requestfor an approval-gated call that was not started, or the end of the stream for a streamed call that got neither (marked_incompletewhen its arguments never finished). A streamed call'stool_arg_stream_endends its argument text but does not announce it:aivalidates a tool call's input through the tool's schema (defaults applied, unknown keys stripped), andtool_startcarries that validated input, so the part,onToolCalland the reloaded snapshot all see the same arguments. Every path carries the sameproviderExecutedandproviderMetadata.helix.runId, so stocklastAssistantMessageIsCompleteWithToolCallsnever reads an answered server call as an answered client call. A client call'stool_startalso carries its deadline: the event'sproviderMetadata.helixgainsdeadlineAtandserverNow(the chunk's write time), whichaikeeps on the part ascallProviderMetadata.helix, so the dispatcher can skip a call that lapsed while the page was away (SDK-3; a stockuseChatsees 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 asai's owntoUIMessageStreamemits them. Per-stepstepId/usage/finishReasonare not on the UI stream; read usage server-side (the corestep_endchunk, run/session usage, or step hooks).stepIdis still ondata-step-committed/data-step-discarded. - Helix-specific data travels on
data-*events (e.g.data-error-metadata) or insideproviderMetadata.helix(e.g.runIdon tool events,filenameonfile).
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-stepalways arrives; whetherfinish-stepdoes depends on your LLM adapter. See theincludeStepEventswarning 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.
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 abandonmentfindExpiredPending
Sweep for pending client-tool calls past their deadline.
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:
import { convertToAISDKMessages } from '@helix-agents/ai-sdk';
const uiMessages = convertToAISDKMessages(helixMessages, {
generateId: (index, msg) => `msg-${index}`,
includeReasoning: true,
mergeToolResults: true,
});AI SDK v6 Format
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
- System messages → Single text part
- User messages → Single text part (or text + file parts)
- Assistant messages → Text, reasoning, and tool parts
- Tool result messages → Merged into assistant's tool parts (not separate messages). The part's
outputis decoded with coredecodeToolResultContent: a string result (markedhelixContentEncoding: 'text') stays that exact string, anything else is parsed JSON __finish__tool dropped →convertToAISDKMessagesdrops 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.
// 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 State | AI SDK State | Description |
|---|---|---|
| pending | input-available | Awaiting execution |
| executing | input-available | Currently running |
| completed | output-available | Finished successfully |
| error | output-error | Execution 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
// 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
// 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
// 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
- No duplicate data transfer — messages loaded once via snapshot.
- 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.
- Page-refresh-during-stream is seamless —
prepareHelixReconnectRequestre-attaches to the live SSE stream (path 5) or replays the terminal stream (path 6), replaying the in-flight message's committed prefix first. - SSR-friendly —
FrontendSnapshotis JSON-serializable. - 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:
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:
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.
useHelixChat (recommended for client-executed tools)
The top-level convenience hook for client-executed tools. Composes useChat + useResumeClientTools + the correct sendAutomaticallyWhen predicate so tool outputs reliably reach the server:
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 transientdata-helix-late-answerpart,{ toolCallId }), so the app can reconcile, e.g. undo an edit the model believes never happened. A stockuseChatreads the same part throughonData.- 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. Withresync, the hook re-reads the snapshot while it sayscontinuationOwedand 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.
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.
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.
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 failedfetch(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 (stocksetMessages+resumeStream). It never sends. - Left as stock
useChatleaves it: a refusal answered before any stream (any non-2xx, such as a409state_already_running), a chunkaicould not parse, a stop, the app's own throw fromonToolCall/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 oneReanchorExhaustedError(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 toonError) is the last failure it reports: a snapshot it could not read (SnapshotUnavailableError), a resume it started that the server refused,ReanchorExhaustedError, or aConfigurationErrorwhen the transport is not the chat's own controlled one. It is cleared when a re-attach succeeds or the app sends. A404snapshot 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
ai6.0.230 and older.
The rules are shared with useHelixChat (client/reattach.ts). See When a send's stream drops.
Other recovery hooks
| Hook | Use Case | Auto-Snapshot | Auto-Resync |
|---|---|---|---|
useHelixChat | Recommended — client tools + correct SAW wiring | n/a | n/a |
useHelixSendAutomaticallyWhen | Correct SAW predicate for custom useChat config | n/a | n/a |
useReanchorOnError | Re-attach a stock useChat after a dropped send | Yes | n/a |
useStreamResync | Manual resync handling via callback | No | No |
useAutoResync | Re-anchor on resync triggers from a snapshot URL | No | Yes |
useCheckpointSnapshot | Load a specific checkpoint | Yes | No |
useResyncState | Track resync count without handling | No | No |
useResumableChat | Full snapshot + resync solution | Yes | Yes |
useResumeClientTools | Lower-level: auto-dispatch client-executed tools | n/a | n/a |
Snapshot status
The status field on FrontendSnapshot tells the client whether to attempt stream resumption:
| Status | Description | Client Action |
|---|---|---|
active | The current run is live and running | Resume from the cursor |
paused | The current run is live and suspended (HITL) | Resume from the cursor |
ended | No live run; the last one did not fail | No SSE connection needed |
failed | No live run; the current run or the stream failed | Render 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:
- The SSR snapshot (via
buildSnapshot) is server-rendered. Its messages are the committed rows up to its cursor; itsstatusisactiveorpausedbecause the run is live. useHelixChatmounts withresume: trueandsnapshotResumeOptions(snapshot).messages, the snapshot without the in-flightresumeMessageIdmessage. The AI SDK'sreconnectToStreamGETs/api/chat/[sessionId](throughprepareHelixReconnectRequest; not the default/api/chat/[sessionId]/stream) withX-Resume-From-Sequence,X-Existing-Message-IdandX-Helix-Replay-Prefix: 1.handleChatStreammatches 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 transientdata-helix-replaymarkers), 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):
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
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):
import {
AiSdkHandlerError,
ValidationError,
StreamNotFoundError,
StreamReaderError,
StreamFailedError,
ConfigurationError,
ExecutionError,
StreamCreationError,
HelixStreamError,
} from '@helix-agents/ai-sdk';| Error | Code | Status | When |
|---|---|---|---|
ValidationError | VALIDATION_ERROR | 400 | Missing/invalid request params |
StreamNotFoundError | STREAM_NOT_FOUND | 404 | Stream doesn't exist |
StreamReaderError | STREAM_READER_ERROR | 404 | A reader could not be created |
StreamFailedError | STREAM_FAILED | 410 | Stream has failed |
ConfigurationError | CONFIGURATION_ERROR | 501 | Missing configuration (missing: string[]), e.g. a resync with no stream control |
ExecutionError | EXECUTION_ERROR | 500 | Agent execution failed |
StreamCreationError | STREAM_CREATION_ERROR | 500 | Stream 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:
// 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:
// `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.
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.
| Export | Root | /client | /react | What it does |
|---|---|---|---|---|
prepareHelixChatRequest, prepareHelixReconnectRequest | yes | yes | Request preparers for DefaultChatTransport | |
snapshotResumeOptions | yes | yes | What to seed and how to resume, from a snapshot | |
fetchSnapshotWithRetry, SnapshotUnavailableError | yes | yes | Snapshot fetch: 4 fetches, 250 / 500 / 1000 ms backoff on a 5xx / 429 / network failure, then the typed error | |
resumeHeaders, ResumeTarget | yes | yes | The resume headers for chat.resumeStream({ headers }) | |
reanchorOnSnapshot, ReanchorOptions | yes | yes | One re-anchor: stop, fetch the latest snapshot, set messages, resume when live | |
chatResyncControl, HelixResyncControl, ResyncableChat | yes | yes | Build the stop / resume control a re-anchor needs from a chat (and, optionally, a controlled transport) | |
collectResyncTriggers | yes | yes | The data-stream-resync / data-attempt-superseded parts of messages, in order | |
resyncCoveredBySnapshot | yes | Whether a seed snapshot already covers a replayed rewind resync | ||
missingResyncControlError | yes | The ConfigurationError a re-anchor without stream control raises | ||
withHelixStreamControl, isHelixControlledTransport, HelixStreamControl, HelixControlledTransport | yes | yes | yes | Per-chat connection control: abortActive(chatId), hasActiveConnection(chatId?), activeKind(chatId) |
parseHelixChatError, HelixChatErrorBody | yes | yes | yes | Read a chat route's typed error body from chat.error |
ResyncTracker, createResyncTracker, isStreamResyncEvent, isAttemptSupersededEvent, isResyncTriggerEvent | yes | yes | Dedup resync triggers (a stream resync by (runId, checkpointId)) and type guards | |
useHelixChat, UseHelixChatResult, useAutoResync, useResumableChat, useCheckpointSnapshot, useStreamResync, useResyncState | yes | The React hooks | ||
AISDKResyncTriggerEvent, AISDKStreamResyncEvent, AISDKAttemptSupersededEvent | yes (types) | yes (types) | The resync trigger event types | |
ConfigurationError | yes | The 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)
// 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:
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:
// Wrong
const result = message.content;
// Correct
const toolParts = message.parts?.filter(
(p) => p.type.startsWith('tool-') || p.type === 'dynamic-tool'
);Complete Example
// 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 });// 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
- React Integration — Building React chat UIs
- Framework Examples — Express, Hono setup
- Streaming Guide — Helix streaming deep dive
- Resumable Streams Example — Canonical v7 wiring