Framework Examples
Web Response everywhere
v7+ routes call handleChatStream from @helix-agents/ai-sdk directly — it returns a web Response (status + headers + SSE body) that any framework can forward. See Frontend / @helix-agents/ai-sdk for the seven-path orchestrator and buildSnapshot / getUIMessages helpers.
This guide shows how to integrate Helix Agents with popular HTTP frameworks. handleChatStream returns a web Response, which Hono / Fastify / Cloudflare Workers can return directly; Express needs the pipeWebResponseToExpress / createWebResponseExpressMiddleware helpers from @helix-agents/ai-sdk/adapters/express.
Snapshot route errors
Every snapshot route below answers 404 { error: 'Session not found', code: 'state_session_not_found' } only for a missing session, and a typed { error, code, cause? } for a failed read: core typedErrorResponse's status, 503 for a retryable read (snapshot_unstable, store_error; clients retry it with fetchSnapshotWithRetry, then show an error state) and 409 for a non-retryable one (corrupt_row, short_read). @helix-agents/ai-sdk exports both responses, snapshotNotFoundResponse() and snapshotErrorResponse(error), on every host (@helix-agents/ai-sdk/cloudflare re-exports them).
Chat route errors
handleChatStream answers every HelixError itself as a typed JSON response, never throwing one: its refusals (409 state_already_running, 409 state_not_resumable, 503 transport_stream_drop, ...) and any failed read (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. A route that returns its Response is correct by default. What can still reach a route's catch is its own failure (body parsing, auth) or an AiSdkHandlerError: answer it with chatErrorResponse(error, { sessionId, logger }), which maps both and never echoes an unknown error's text (logger keeps the detail of a foreign checkpoint it answers as a missing one).
Hono
Hono works natively with Web standard Response — handleChatStream returns one directly:
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import {
handleChatStream,
buildSnapshot,
getUIMessages,
chatErrorResponse,
snapshotErrorResponse,
snapshotNotFoundResponse,
} 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';
// Setup
const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const executor = new JSAgentExecutor(stateStore, streamManager, new VercelAIAdapter());
const deps = {
executor,
stateStore,
streamManager,
agent: MyAgent,
} as const;
// Create app
const app = new Hono();
// CORS for frontend
app.use(
'/api/*',
cors({
origin: ['http://localhost:3000'],
allowHeaders: [
'Content-Type',
'Last-Event-ID',
'X-Resume-From-Sequence',
'X-Existing-Message-Id',
'X-Helix-Replay-Prefix',
],
exposeHeaders: ['X-Session-Id'],
})
);
// POST /api/chat/:sessionId — fresh turn / continuing turn / resume.
app.post('/api/chat/:sessionId', async (c) => {
try {
const body = await c.req.json();
return await handleChatStream(deps, {
sessionId: c.req.param('sessionId'),
messages: body.messages ?? [],
request: c.req.raw,
});
} 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 });
}
});
// GET /api/chat/:sessionId — reconnect to a live stream (path 5/6).
app.get('/api/chat/:sessionId', async (c) => {
return handleChatStream(deps, {
sessionId: c.req.param('sessionId'),
messages: [],
request: c.req.raw,
});
});
// GET /api/chat/:sessionId/snapshot — SSR hydration data.
// 404 only for a missing session; a failed read is a typed 503 (retried) or 409.
app.get('/api/chat/:sessionId/snapshot', async (c) => {
try {
const snapshot = await buildSnapshot(deps, { sessionId: c.req.param('sessionId') });
return snapshot ? c.json(snapshot) : snapshotNotFoundResponse();
} catch (error) {
return snapshotErrorResponse(error);
}
});
// GET /api/messages/:sessionId — paginated message history.
app.get('/api/messages/:sessionId', async (c) => {
const sessionId = c.req.param('sessionId');
const offset = parseInt(c.req.query('offset') ?? '0');
const limit = parseInt(c.req.query('limit') ?? '100');
const result = await getUIMessages({ stateStore }, { sessionId, offset, limit });
return c.json(result);
});
export default app;Express
Express needs the web-Response → Express adapter helpers from @helix-agents/ai-sdk/adapters/express. The cleanest path is createWebResponseExpressMiddleware, which wraps a handleChatStream-style handler and pipes the Response into res for you:
import express from 'express';
import cors from 'cors';
import {
handleChatStream,
buildSnapshot,
getUIMessages,
AiSdkHandlerError,
} from '@helix-agents/ai-sdk';
import {
createWebResponseExpressMiddleware,
pipeWebResponseToExpress,
toWebRequest,
} from '@helix-agents/ai-sdk/adapters/express';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { snapshotErrorResponse, snapshotNotFoundResponse } from '@helix-agents/ai-sdk';
// Setup
const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const executor = new JSAgentExecutor(stateStore, streamManager, new VercelAIAdapter());
const deps = {
executor,
stateStore,
streamManager,
agent: MyAgent,
} as const;
// Create app
const app = express();
app.use(
cors({
origin: ['http://localhost:3000'],
allowedHeaders: [
'Content-Type',
'Last-Event-ID',
'X-Resume-From-Sequence',
'X-Existing-Message-Id',
'X-Helix-Replay-Prefix',
],
exposedHeaders: ['X-Session-Id'],
})
);
app.use(express.json());
// POST /api/chat/:sessionId — fresh turn / continuing turn / resume.
app.post(
'/api/chat/:sessionId',
createWebResponseExpressMiddleware(async (req, { request }) => {
return handleChatStream(deps, {
sessionId: req.params.sessionId,
messages: req.body?.messages ?? [],
// A web `Request` with the incoming URL, method and headers: the
// request key (a retried send attaches to its first attempt), the
// resume headers, and a signal aborted when the client disconnects.
request,
});
})
);
// GET /api/chat/:sessionId — reconnect to a live stream.
app.get(
'/api/chat/:sessionId',
createWebResponseExpressMiddleware(async (req, { request }) =>
handleChatStream(deps, {
sessionId: req.params.sessionId,
messages: [],
request,
})
)
);
// GET /api/chat/:sessionId/snapshot — SSR hydration JSON.
app.get('/api/chat/:sessionId/snapshot', async (req, res) => {
try {
const snapshot = await buildSnapshot(deps, { sessionId: req.params.sessionId });
await pipeWebResponseToExpress(
snapshot ? Response.json(snapshot) : snapshotNotFoundResponse(),
res
);
} catch (error) {
await pipeWebResponseToExpress(snapshotErrorResponse(error), res);
}
});
// GET /api/messages/:sessionId — paginated history.
app.get('/api/messages/:sessionId', async (req, res) => {
const result = await getUIMessages(
{ stateStore },
{
sessionId: req.params.sessionId,
offset: req.query.offset ? Number(req.query.offset) : undefined,
limit: req.query.limit ? Number(req.query.limit) : undefined,
}
);
res.json(result);
});
// Centralized error handler — catches AiSdkHandlerError thrown inside the
// middleware (e.g. ValidationError, StreamFailedError).
app.use(
(err: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof AiSdkHandlerError) {
res.status(err.statusCode).json({ error: err.message, code: err.code });
return;
}
next(err);
}
);
app.listen(3001, () => {
console.log('Server running on http://localhost:3001');
});Manual piping with pipeWebResponseToExpress
If you don't want the middleware wrapper, call handleChatStream yourself and pipe its Response into the Express response directly:
import { pipeWebResponseToExpress, toWebRequest } from '@helix-agents/ai-sdk/adapters/express';
app.post('/api/chat/:sessionId', async (req, res) => {
// Abort the stream when the client disconnects.
const ac = new AbortController();
res.on('close', () => ac.abort());
const response = await handleChatStream(deps, {
sessionId: req.params.sessionId,
messages: req.body?.messages ?? [],
// Headers (request key, resume target) + the disconnect signal.
request: toWebRequest(req, ac.signal),
});
// Also cancels the body if the client disconnects mid-stream.
await pipeWebResponseToExpress(response, res);
});Fastify
Fastify with streaming support — call handleChatStream and forward the resulting Response body and headers:
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { Readable } from 'node:stream';
import { handleChatStream, AiSdkHandlerError } from '@helix-agents/ai-sdk';
const fastify = Fastify({ logger: true });
await fastify.register(cors, {
origin: ['http://localhost:3000'],
allowedHeaders: [
'Content-Type',
'Last-Event-ID',
'X-Resume-From-Sequence',
'X-Existing-Message-Id',
'X-Helix-Replay-Prefix',
],
exposedHeaders: ['X-Session-Id'],
});
const deps = {
executor,
stateStore,
streamManager,
agent: MyAgent,
} as const;
fastify.post('/api/chat/:sessionId', async (request, reply) => {
try {
const sessionId = (request.params as { sessionId: string }).sessionId;
const body = request.body as { messages?: unknown[] };
// Build a web Request to forward resume headers (Last-Event-ID, etc.).
const incoming = new Request(
new URL(request.url, `http://${request.headers.host ?? 'localhost'}`),
{
method: request.method,
headers: request.headers as Record<string, string>,
body: JSON.stringify(body),
}
);
const response = await handleChatStream(deps, {
sessionId,
messages: body.messages ?? [],
request: incoming,
});
response.headers.forEach((value, key) => reply.header(key, value));
reply.status(response.status);
if (!response.body) return reply.send();
return reply.send(Readable.fromWeb(response.body as never));
} catch (error) {
if (error instanceof AiSdkHandlerError) {
return reply.status(error.statusCode).send({ error: error.message, code: error.code });
}
throw error;
}
});
await fastify.listen({ port: 3001 });Cloudflare Workers
For Durable-Object-backed deployments, use createCloudflareChatHandler from the @helix-agents/ai-sdk/cloudflare subpath. The factory wires the DO clients internally and exposes handleChat / getSnapshot / getMessages:
import {
createCloudflareChatHandler,
snapshotErrorResponse,
snapshotNotFoundResponse,
} from '@helix-agents/ai-sdk/cloudflare';
import { chatErrorResponse } from '@helix-agents/ai-sdk';
export interface Env {
AGENTS: DurableObjectNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const chat = createCloudflareChatHandler({
namespace: env.AGENTS,
agentName: 'chat-agent',
});
// CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers':
'Content-Type, Last-Event-ID, X-Resume-From-Sequence, X-Existing-Message-Id, X-Helix-Replay-Prefix',
'Access-Control-Expose-Headers': 'X-Session-Id',
},
});
}
const withCors = (response: Response): Response => {
const headers = new Headers(response.headers);
headers.set('Access-Control-Allow-Origin', '*');
return new Response(response.body, { status: response.status, headers });
};
try {
const match = url.pathname.match(/^\/api\/chat\/([^/]+)(\/snapshot)?$/);
if (match) {
const sessionId = decodeURIComponent(match[1]);
const isSnapshot = match[2] === '/snapshot';
if (isSnapshot && request.method === 'GET') {
try {
const snapshot = await chat.getSnapshot({ sessionId });
return withCors(snapshot ? Response.json(snapshot) : snapshotNotFoundResponse());
} catch (error) {
return withCors(snapshotErrorResponse(error));
}
}
if (request.method === 'POST' || request.method === 'GET') {
const body =
request.method === 'POST'
? ((await request.json()) as { messages?: unknown[] })
: { messages: [] };
const response = await chat.handleChat({
sessionId,
messages: (body.messages ?? []) as never,
request,
});
return withCors(response);
}
}
return withCors(new Response('Not found', { status: 404 }));
} catch (error) {
console.error('[worker]', error);
return withCors(chatErrorResponse(error, { logger: console }));
}
},
};CORS Configuration
AI SDK's useChat makes requests from the browser. Configure CORS properly:
Required Headers
// Allow headers
'Content-Type'; // JSON body
'Last-Event-ID'; // SSE resumption (fallback cursor)
'X-Resume-From-Sequence'; // resume cursor (the snapshot's streamSequence)
'X-Existing-Message-Id'; // the message the resumed stream continues
'X-Helix-Replay-Prefix'; // replay that message's committed prefix (C-1)
// Expose headers
'X-Session-Id'; // Return the session id to the clientDevelopment CORS
// Hono
app.use(
'/api/*',
cors({
origin: ['http://localhost:3000', 'http://localhost:5173'],
allowHeaders: [
'Content-Type',
'Last-Event-ID',
'X-Resume-From-Sequence',
'X-Existing-Message-Id',
'X-Helix-Replay-Prefix',
],
exposeHeaders: ['X-Session-Id'],
})
);
// Express
app.use(
cors({
origin: ['http://localhost:3000', 'http://localhost:5173'],
allowedHeaders: [
'Content-Type',
'Last-Event-ID',
'X-Resume-From-Sequence',
'X-Existing-Message-Id',
'X-Helix-Replay-Prefix',
],
exposedHeaders: ['X-Session-Id'],
})
);Production CORS
const ALLOWED_ORIGINS = ['https://myapp.com', 'https://www.myapp.com'];
app.use(
'/api/*',
cors({
origin: (origin) => {
if (!origin || ALLOWED_ORIGINS.includes(origin)) {
return origin;
}
return null;
},
allowHeaders: [
'Content-Type',
'Last-Event-ID',
'X-Resume-From-Sequence',
'X-Existing-Message-Id',
'X-Helix-Replay-Prefix',
],
exposeHeaders: ['X-Session-Id'],
credentials: true,
})
);Authentication
Add authentication before the handler:
// Middleware approach
async function authenticate(req: Request): Promise<{ userId: string }> {
const token = req.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
throw new Error('Unauthorized');
}
// Verify token...
return { userId: 'user-123' };
}
app.post('/api/chat/:sessionId', async (c) => {
// Authenticate first
let user;
try {
user = await authenticate(c.req.raw);
} catch {
return c.json({ error: 'Unauthorized' }, 401);
}
// Then dispatch through handleChatStream. Forward authenticated userId
// via the orchestrator's `userId` field for attribution.
const body = await c.req.json();
return handleChatStream(deps, {
sessionId: c.req.param('sessionId'),
messages: body.messages ?? [],
userId: user.userId,
request: c.req.raw,
});
});Rate Limiting
import { rateLimit } from 'hono-rate-limiter';
// Hono
app.use(
'/api/chat',
rateLimit({
windowMs: 60 * 1000, // 1 minute
limit: 20, // 20 requests per minute
keyGenerator: (c) => c.req.header('X-Forwarded-For') ?? 'unknown',
})
);Request Validation
Validate the incoming message:
import { z } from 'zod';
// AI SDK v6 UIMessage shape (subset — parts can be richer).
const UIMessagePartSchema = z.object({
type: z.string(),
text: z.string().optional(),
});
const UIMessageSchema = z.object({
id: z.string(),
role: z.enum(['user', 'assistant', 'system']),
parts: z.array(UIMessagePartSchema).min(1),
});
const ChatRequestSchema = z.object({
messages: z.array(UIMessageSchema).min(1).max(100),
});
app.post('/api/chat/:sessionId', async (c) => {
const body = await c.req.json();
const result = ChatRequestSchema.safeParse(body);
if (!result.success) {
return c.json({ error: 'Invalid request', details: result.error.issues }, 400);
}
return handleChatStream(deps, {
sessionId: c.req.param('sessionId'),
messages: result.data.messages,
request: c.req.raw,
});
});Next Steps
- React Integration - Building the frontend
- AI SDK Package - Handler configuration
- Cloudflare Runtime - Full Cloudflare setup