Skip to content

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:

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

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

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

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

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

typescript
// 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 client

Development CORS ​

typescript
// 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 ​

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

typescript
// 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 ​

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

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

Released under the MIT License.