Skip to content

AI SDK Package

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

Installation

bash
npm install @helix-agents/ai-sdk

v7 surface at a glance

PieceWhere it livesWhat it does
handleChatStream@helix-agents/ai-sdk (server)Single entry point implementing the seven-path HITL chat orchestrator
prepareHelixChatRequest@helix-agents/ai-sdk/clientPrepareSendMessagesRequest factory for DefaultChatTransport
prepareHelixReconnectRequest@helix-agents/ai-sdk/clientPrepareReconnectToStreamRequest factory — required for HITL UX
useHelixChat@helix-agents/ai-sdk/reactRecommended — composes useChat + useResumeClientTools + correct sendAutomaticallyWhen
useHelixSendAutomaticallyWhen@helix-agents/ai-sdk/reactReact hook returning a stable Helix sendAutomaticallyWhen predicate
createHelixSendAutomaticallyWhen@helix-agents/ai-sdk/react (also root)Vanilla factory for non-React lifecycles / manual Chat construction
useResumeClientTools@helix-agents/ai-sdk/reactLower-level React hook auto-dispatching client-executed tool calls
extractResumeIntent@helix-agents/ai-sdk (server)Parse resume signals from incoming AI SDK v6 messages
findExpiredPending@helix-agents/ai-sdk (server)Surface client-tool calls past their deadline
createHelixChatTransport@helix-agents/ai-sdk (client)Direct-to-agent-server transport (alternative wiring)
buildSnapshot@helix-agents/ai-sdk (server)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:

  1. Fresh new sessionexecutor.execute()
  2. Continuing session, new user messageexecutor.execute() with sessionId
  3. Resume after tool / approval submitsubmitToolResult × N, then executor.resume()
  4. Abandonment recovery → fail every pending tool, then path 2
  5. Active-stream attach → passive subscriber on a running run
  6. Already-completed retry → terminal stream replay
  7. 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).

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

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

// Shared deps for chat / snapshot / message-history helpers.
const deps = {
  executor,
  stateStore,
  streamManager,
  agent: MyAgent,
  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:

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

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

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

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

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

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

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

HandleChatStreamParams reference

typescript
interface HandleChatStreamParams {
  sessionId: string;
  /** Incoming AI SDK v6 messages. Only the tail is inspected. */
  messages: readonly UIMessage[];
  /** Forwarded to executor.execute as `userId` for attribution. */
  userId?: string;
  /** Forwarded to executor.execute / resume as metadata. */
  metadata?: Record<string, string>;
  /**
   * Optional incoming Request. When provided, the orchestrator extracts
   * X-Resume-From-Sequence / Last-Event-ID / X-Existing-Message-Id from
   * its headers — required for path 5 (active-stream attach) and path 6
   * (already-completed retry) to skip already-delivered chunks and reuse
   * the in-flight assistant message id.
   */
  request?: Request;
  /** 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:

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

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

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

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

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

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

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

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

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

Next.js App Router route handlers

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

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

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

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 — packs id / messages / trigger / messageId onto the body and stamps X-Resume-From-Sequence / X-Existing-Message-Id headers.
  • 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.

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

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

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

Don't use AI SDK's stock lastAssistantMessageIsCompleteWithToolCalls

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

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

PrepareHelixChatRequestOptions

typescript
interface PrepareHelixChatRequestOptions {
  /** API endpoint path. Mirrors DefaultChatTransport's `api` field. */
  api: string;
  /** Sequence 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 }):

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.

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

loadAllUIMessages (no snapshot)

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

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

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

Paginated variant:

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

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

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

createUIMessageStore wrapper

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

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

StreamTransformer

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

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

const transformer = new StreamTransformer({
  generateMessageId: (agentId) => `msg-${agentId}`,
  includeStepEvents: false,
  chunkFilter: (chunk) => chunk.type !== 'state_patch',
  logger: console,
});

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

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

Event Mapping

Helix ChunkAI SDK Events
text_deltatext-start (once), text-delta
thinkingreasoning-start (once), reasoning-delta, reasoning-end (if complete)
tool_starttext-end (if text open), tool-input-available
tool_endtool-output-available
subagent_startdata-subagent-start
subagent_enddata-subagent-end
customdata-{eventName}
state_patchdata-state-patch
errorerror
outputdata-output

Tool Argument Streaming

Helix ChunkAI SDK Event
tool_arg_stream_starttool-input-start
tool_arg_stream_deltatool-input-delta
tool_arg_stream_endtool-input-available
tool_input_errortool-input-error
tool_output_errortool-output-error

Approval-gated tool events (v7)

Approval-gated tools transition through state: 'approval-pending' → 'approval-resolved' → 'output-available':

Helix ChunkAI SDK Event mapping (state on tool part)
tool_approval_requestapproval-pending
tool_approval_responseapproval-resolved

Control Flow Events

Helix ChunkAI SDK Event
run_interrupteddata-run-interrupted
run_resumeddata-run-resumed
run_pauseddata-run-paused
checkpoint_createddata-checkpoint-created
step_committeddata-step-committed
step_discardeddata-step-discarded
stream_resyncdata-stream-resync
executor_supersededdata-executor-superseded

Important: All tool events include dynamic: true because Helix tools are defined at runtime. Server-executed tools also receive providerExecuted: true on tool-input-available so 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.

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

findExpiredPending

Sweep for pending client-tool calls past their deadline.

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

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

Message Converter

Converts Helix internal messages to AI SDK v6 UIMessage format:

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

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

AI SDK v6 Format

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

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

Conversion Rules

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

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

State Mapping

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

For complete documentation, see UI Messages Guide.

Server-Side Rendering with Next.js

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

Server Component

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

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

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

Client Component

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

import { DefaultChatTransport } from 'ai';
import {
  prepareHelixChatRequest,
  prepareHelixReconnectRequest,
} 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

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

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

export async function GET(req: Request, { params }: { params: { sessionId: string } }) {
  return dispatchChat({
    sessionId: params.sessionId,
    messages: [],
    request: req,
  });
}
typescript
// 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

  1. No duplicate data transfer — messages loaded once via snapshot.
  2. No race conditions — sequence number precisely coordinates state.
  3. Page-refresh-during-stream is seamlessprepareHelixReconnectRequest re-attaches to the live SSE stream (path 5) or replays the terminal stream (path 6).
  4. SSR-friendlyFrontendSnapshot is JSON-serializable.
  5. Framework-agnostic — works with any SSR solution, not just Next.js.

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

Multi-Turn Conversations

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

Frontend Tracking

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

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

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

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

Recovery hooks

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

useHelixChat works the same on every runtime — including Cloudflare DO

The recommended setup (useHelixChat, which wires helixSAW via useHelixSendAutomaticallyWhen) behaves identically across all runtimes: JS, Temporal, DBOS, Cloudflare Workflows, and Cloudflare Durable Objects. The sendAutomaticallyWhen-driven follow-up request opens an SSE stream so the client reads the continuation as it happens. On runtimes that 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.

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

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

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

See Client-Executed Tools for the full setup guide.

useResumeClientTools (lower-level building block)

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

Use useHelixChat unless you need custom useChat config

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

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

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

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

useResumableChat

Turnkey hook combining snapshot loading with resync handling. Recommended for production chat interfaces.

tsx
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

HookUse CaseAuto-SnapshotAuto-Resync
useHelixChatRecommended — client tools + correct SAW wiringn/an/a
useHelixSendAutomaticallyWhenCorrect SAW predicate for custom useChat confign/an/a
useStreamResyncManual resync handling via callbackNoNo
useAutoResyncAuto-handle resyncs from snapshot URLNoYes
useCheckpointSnapshotLoad a specific checkpointYesNo
useResyncStateTrack resync count without handlingNoNo
useResumableChatFull snapshot + resync solutionYesYes
useResumeClientToolsLower-level: auto-dispatch client-executed toolsn/an/a

Snapshot status

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

StatusDescriptionClient Action
activeStream is runningSet resume: true in useChat
pausedStream is paused (HITL)Set resume: true to reattach
endedStream completed successfullyNo SSE connection needed
failedStream failedHandle error state

Mid-Stream Page Refresh

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

  1. The SSR snapshot (via buildSnapshot) is server-rendered — initial messages are already on the page.
  2. useChat mounts with resume: true, triggering reconnectToStream. With prepareHelixReconnectRequest wired, the AI SDK GETs /api/chat/[sessionId] (not the default /api/chat/[sessionId]/stream).
  3. handleChatStream matches 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, reusing existingMessageId so 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):

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

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

SSE Format

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

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

data: {"type":"finish"}

The id: field enables stream resumability.

Header Utilities

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

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

handleChatStream calls this internally on the request you pass in.

Typed Errors

All HTTP-level errors extend FrontendHandlerError:

typescript
import {
  FrontendHandlerError,
  ValidationError,
  StreamNotFoundError,
  StreamFailedError,
  ConfigurationError,
  ExecutionError,
  StreamCreationError,
  HelixStreamError,
} from '@helix-agents/ai-sdk';
ErrorCodeStatusWhen
ValidationErrorVALIDATION_ERROR400Missing/invalid request params
StreamNotFoundErrorSTREAM_NOT_FOUND404Stream doesn't exist
StreamFailedErrorSTREAM_FAILED410Stream has failed
ConfigurationErrorCONFIGURATION_ERROR501Missing configuration
ExecutionErrorEXECUTION_ERROR500Agent execution failed
StreamCreationErrorSTREAM_CREATION_ERROR500Stream creation failed

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

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

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

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

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

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

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

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

Common Pitfalls

1. Importing HelixChatTransport (deleted in v7)

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

Use DefaultChatTransport plus prepareHelixChatRequest / prepareHelixReconnectRequest instead.

2. Forgetting prepareHelixReconnectRequest

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

3. Forgetting to call finalize()

If using StreamTransformer directly:

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

handleChatStream and buildSnapshot handle this automatically.

4. Looking for tool results in content

Tool results live in message parts, not content:

typescript
// Wrong
const result = message.content;

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

Complete Example

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

const ChatAgent = defineAgent({
  name: 'chat',
  systemPrompt: 'You are a helpful assistant.',
  outputSchema: z.object({ response: z.string() }),
  llmConfig: { model: openai('gpt-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 });
typescript
// 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

Released under the MIT License.