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) | SSR / useChat({ messages }) snapshot helper — content-replay aware |
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 |
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, thenexecutor.resume() - Abandonment recovery → fail every pending tool, then path 2
- Active-stream attach → passive subscriber on a running run
- Already-completed retry → terminal stream replay
- Stale runId rejection → SSE response with
data-resume-rejected
The function returns a web Response with an SSE body.
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,
contentReplayEnabled: true,
} 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';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;
/** Explicit override for X-Resume-From-Sequence. */
resumeFromSequence?: number;
/** Explicit override for X-Existing-Message-Id. */
existingMessageId?: string;
}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';
export async function GET(_req: Request, { params }: { params: { sessionId: string } }) {
const snapshot = await getSnapshot(params.sessionId);
if (!snapshot) return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json(snapshot);
}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.prepareHelixReconnectRequest— same headers, but rewrites the AI SDK's reconnect URL (${api}/${chatId}/stream) to the Helix single-path layout.
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 } 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) {
const shouldResume = initialSnapshot.status === 'active' || initialSnapshot.status === 'paused';
// Pull the most recent assistant message id so the resume request
// continues that message instead of opening a duplicate bubble.
const existingMessageId = useMemo(() => {
for (let i = initialSnapshot.messages.length - 1; i >= 0; i--) {
const m = initialSnapshot.messages[i];
if (m?.role === 'assistant') return m.id;
}
return undefined;
}, [initialSnapshot.messages]);
const transport = useMemo(() => {
const api = `/api/chat/${sessionId}`;
const helixOptions = {
api,
resumeFromSequence: shouldResume ? initialSnapshot.streamSequence : undefined,
existingMessageId,
};
return new DefaultChatTransport({
api,
prepareSendMessagesRequest: prepareHelixChatRequest(helixOptions),
prepareReconnectToStreamRequest: prepareHelixReconnectRequest(helixOptions),
});
}, [sessionId, shouldResume, initialSnapshot.streamSequence, existingMessageId]);
// useHelixChat composes useChat + useResumeClientTools + correct
// sendAutomaticallyWhen so client-tool outputs reliably reach the server.
const chat = useHelixChat({
id: sessionId,
transport,
messages: initialSnapshot.messages,
resume: shouldResume,
toolHandlers: {
// editContent: async (input, { abortSignal }) => { ... },
},
});
return <MessageList messages={chat.messages} />;
}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 number to resume from (X-Resume-From-Sequence). */
resumeFromSequence?: number;
/** Assistant message id to continue (X-Existing-Message-Id). */
existingMessageId?: string;
/** Static body fields merged underneath the AI SDK fields. */
body?: Record<string, unknown>;
}Same options shape applies to both prepareHelixChatRequest and prepareHelixReconnectRequest.
Loading message history
Two paths to populate useChat({ messages }):
buildSnapshot() (recommended)
The snapshot is the canonical SSR / hydrate surface. It loads state, computes the resume sequence, applies content replay (so partial mid-stream content isn't double-rendered), and returns the typed FrontendSnapshot.
import { buildSnapshot } from '@helix-agents/ai-sdk';
const snapshot = await buildSnapshot(
{ executor, stateStore, streamManager, agent: MyAgent, contentReplayEnabled: true },
{ sessionId }
);
// snapshot contains:
// - state: AgentState | null
// - messages: UIMessage[] (use as `useChat({ messages })`)
// - streamSequence: number (resume position)
// - status: 'active' | 'paused' | 'ended' | 'failed'
// - checkpointId: string | null
// - timestamp: numberloadAllUIMessages (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;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 |
subagent_start | data-subagent-start |
subagent_end | data-subagent-end |
custom | data-{eventName} |
state_patch | data-state-patch |
error | error |
output | data-output |
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 | tool-input-available |
tool_input_error | tool-input-error |
tool_output_error | tool-output-error |
Approval-gated tool events (v7)
Approval-gated tools transition through state: 'approval-pending' → 'approval-resolved' → 'output-available':
| Helix Chunk | AI SDK Event mapping (state on tool part) |
|---|---|
tool_approval_request | approval-pending |
tool_approval_response | approval-resolved |
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 |
step_discarded | data-step-discarded |
stream_resync | data-stream-resync |
executor_superseded | data-executor-superseded |
Important: All tool events include
dynamic: truebecause Helix tools are defined at runtime. Server-executed tools also receiveproviderExecuted: trueontool-input-availableso edge environments don't re-fire the tool from the client.
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 → surface as `data-resume-rejected` SSE 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_deadline_exceeded',
});
}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)
__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,
} 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 shouldResume =
initialSnapshot.status === 'active' || initialSnapshot.status === 'paused';
const existingMessageId = useMemo(() => {
for (let i = initialSnapshot.messages.length - 1; i >= 0; i--) {
const m = initialSnapshot.messages[i];
if (m?.role === 'assistant') return m.id;
}
return undefined;
}, [initialSnapshot.messages]);
const transport = useMemo(() => {
const api = `/api/chat/${sessionId}`;
const helixOptions = {
api,
resumeFromSequence: shouldResume ? initialSnapshot.streamSequence : undefined,
existingMessageId,
};
return new DefaultChatTransport({
api,
prepareSendMessagesRequest: prepareHelixChatRequest(helixOptions),
prepareReconnectToStreamRequest: prepareHelixReconnectRequest(helixOptions),
});
}, [sessionId, shouldResume, initialSnapshot.streamSequence, existingMessageId]);
// useHelixChat composes useChat + useResumeClientTools + correct
// sendAutomaticallyWhen wiring for reliable tool-output delivery.
const chat = useHelixChat({
id: sessionId,
transport,
messages: initialSnapshot.messages,
resume: shouldResume,
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,
});
}// app/api/chat/[sessionId]/snapshot/route.ts
import { getSnapshot } from '@/lib/handler';
export async function GET(_req: Request, { params }: { params: { sessionId: string } }) {
const snapshot = await getSnapshot(params.sessionId);
if (!snapshot) return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json(snapshot);
}Why this works
- No duplicate data transfer — messages loaded once via snapshot.
- No race conditions — sequence number precisely coordinates state.
- Page-refresh-during-stream is seamless —
prepareHelixReconnectRequestre-attaches to the live SSE stream (path 5) or replays the terminal stream (path 6). - 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 auto-continue after a tool submit (Cloudflare DO and DBOS) the run is already advancing server-side and the SDK re-attaches to the live stream; on the others (JS / Temporal / Cloudflare Workflows) the same request drives resume(). 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
data-stream-resync is a recovery-only signal (crash recovery, interrupt cleanup, retry, rollback/branch). It is not emitted on the client-tool happy path. If you wire useAutoResync, expect it to fire rarely — it is not part of the normal per-turn flow.
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.
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
Turnkey hook combining snapshot loading with resync handling. Recommended for production chat interfaces.
import { useChat } from '@ai-sdk/react';
import { useResumableChat } from '@helix-agents/ai-sdk/react';
function ChatPage({ sessionId }: { sessionId: string }) {
const { messages, setMessages, data } = useChat({ id: sessionId, transport });
const { snapshot, isLoading, hasResynced, resyncCount } = useResumableChat(data, {
snapshotUrl: `/api/chat/${sessionId}/snapshot`,
setMessages,
onSnapshotLoaded: (snap) => setMessages(snap.messages),
onResync: (event) => toast.info(`Recovered from ${event.data.reason}`),
});
if (isLoading) return <Loading />;
return (
<div>
{hasResynced && (
<div className="text-sm text-gray-500">Recovered ({resyncCount} resyncs)</div>
)}
<Messages messages={messages} />
</div>
);
}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 |
useStreamResync | Manual resync handling via callback | No | No |
useAutoResync | Auto-handle resyncs from 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 | Stream is running | Set resume: true in useChat |
paused | Stream is paused (HITL) | Set resume: true to reattach |
ended | Stream completed successfully | No SSE connection needed |
failed | Stream failed | Handle error state |
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 — initial messages are already on the page. useChatmounts withresume: true, triggeringreconnectToStream. WithprepareHelixReconnectRequestwired, the AI SDK GETs/api/chat/[sessionId](not the default/api/chat/[sessionId]/stream).handleChatStreammatches active-stream-attach (path 5) when the run is in flight, or already-completed retry (path 6) when it finished while the browser was reconnecting. Both replay from the resume position, reusingexistingMessageIdso the UI doesn't render a duplicate bubble.
buildSnapshot excludes partial mid-stream content from messages by default and replays it as stream events on resume — preventing duplicate text that would otherwise occur with text-start re-firing.
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, the useMemo value above (the last assistant id) 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. You do not need to gate existingMessageId on shouldResume — but doing so is also fine. (SDK versions before this fix reused the supplied id on the fresh-turn path, which made useChat dedup the new assistant bubble away. See the "Message-id stability" note below.)
Message-id stability across SSR / live / commit
In-progress (partial) assistant messages in an SSR snapshot are assigned a deterministic id (msg-<startUIMessageCount>) that matches both the id the live resume stream emits and the id the converter assigns once the message is committed. This three-way agreement is what keeps useChat from rendering a duplicate (or dropping the in-progress bubble) across the refresh → resume → commit lifecycle. buildSnapshot seeds this id from the run's startUIMessageCount, not from the post-merge message count — the latter can collide with a surviving id when consecutive assistant messages are merged.
For complete details on the streaming architecture, see Mid-Stream Page Refresh.
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
All HTTP-level errors extend FrontendHandlerError:
import {
FrontendHandlerError,
ValidationError,
StreamNotFoundError,
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 |
StreamFailedError | STREAM_FAILED | 410 | Stream has failed |
ConfigurationError | CONFIGURATION_ERROR | 501 | Missing configuration |
ExecutionError | EXECUTION_ERROR | 500 | Agent execution failed |
StreamCreationError | STREAM_CREATION_ERROR | 500 | Stream creation failed |
HelixStreamError is for agent-execution errors that arrive via the SSE stream (LLM failures, tool errors). Reconstruct from stream error events:
const error = HelixStreamError.fromEvent({
errorText: 'Provider overloaded',
code: 'provider_overloaded',
recoverable: true,
});
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.
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-4o') },
});
const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const executor = new JSAgentExecutor(stateStore, streamManager, new VercelAIAdapter());
const deps = {
executor,
stateStore,
streamManager,
agent: ChatAgent,
contentReplayEnabled: true,
} 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 { FrontendHandlerError } 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) {
if (error instanceof FrontendHandlerError) {
return c.json({ error: error.message, code: error.code }, error.statusCode);
}
throw error;
}
});
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) => {
const snapshot = await getSnapshot(c.req.param('sessionId'));
if (!snapshot) return c.json({ error: 'Not found' }, 404);
return c.json(snapshot);
});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