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.

Hono

Hono works natively with Web standard ResponsehandleChatStream returns one directly:

typescript
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import {
  handleChatStream,
  buildSnapshot,
  getUIMessages,
  FrontendHandlerError,
} 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,
  contentReplayEnabled: true,
} 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',
    ],
    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) {
    if (error instanceof FrontendHandlerError) {
      return c.json(
        { error: error.message, code: error.code },
        error.statusCode as 400 | 404 | 410 | 500 | 501
      );
    }
    throw error;
  }
});

// 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.
app.get('/api/chat/:sessionId/snapshot', async (c) => {
  const snapshot = await buildSnapshot(deps, { sessionId: c.req.param('sessionId') });
  if (!snapshot) return c.json({ error: 'Not found' }, 404);
  return c.json(snapshot);
});

// 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,
  FrontendHandlerError,
} from '@helix-agents/ai-sdk';
import {
  createWebResponseExpressMiddleware,
  pipeWebResponseToExpress,
} 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';

// Setup
const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const executor = new JSAgentExecutor(stateStore, streamManager, new VercelAIAdapter());

const deps = {
  executor,
  stateStore,
  streamManager,
  agent: MyAgent,
  contentReplayEnabled: true,
} as const;

// Adapt the Express `req` into a web `Request` the orchestrator can read.
function toWebRequest(req: express.Request): Request {
  const url = new URL(req.originalUrl, `http://${req.headers.host ?? 'localhost'}`);
  const headers = new Headers();
  for (const [k, v] of Object.entries(req.headers)) {
    if (typeof v === 'string') headers.set(k, v);
    else if (Array.isArray(v)) headers.set(k, v.join(', '));
  }
  return new Request(url, {
    method: req.method,
    headers,
    body: req.method === 'GET' || req.method === 'HEAD' ? undefined : JSON.stringify(req.body),
  });
}

// 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',
    ],
    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) => {
    return handleChatStream(deps, {
      sessionId: req.params.sessionId,
      messages: req.body?.messages ?? [],
      request: toWebRequest(req),
    });
  })
);

// GET /api/chat/:sessionId — reconnect to a live stream.
app.get(
  '/api/chat/:sessionId',
  createWebResponseExpressMiddleware(async (req) =>
    handleChatStream(deps, {
      sessionId: req.params.sessionId,
      messages: [],
      request: toWebRequest(req),
    })
  )
);

// GET /api/chat/:sessionId/snapshot — SSR hydration JSON.
app.get('/api/chat/:sessionId/snapshot', async (req, res) => {
  const snapshot = await buildSnapshot(deps, { sessionId: req.params.sessionId });
  if (!snapshot) {
    res.status(404).json({ error: 'Not found' });
    return;
  }
  res.json(snapshot);
});

// 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 FrontendHandlerError thrown inside the
// middleware (e.g. ValidationError, StreamFailedError).
app.use(
  (err: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
    if (err instanceof FrontendHandlerError) {
      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 } from '@helix-agents/ai-sdk/adapters/express';

app.post('/api/chat/:sessionId', async (req, res) => {
  const response = await handleChatStream(deps, {
    sessionId: req.params.sessionId,
    messages: req.body?.messages ?? [],
    request: toWebRequest(req),
  });
  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, FrontendHandlerError } 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',
  ],
  exposedHeaders: ['X-Session-Id'],
});

const deps = {
  executor,
  stateStore,
  streamManager,
  agent: MyAgent,
  contentReplayEnabled: true,
} 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 FrontendHandlerError) {
      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 } from '@helix-agents/ai-sdk/cloudflare';
import { FrontendHandlerError } 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',
          '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') {
          const snapshot = await chat.getSnapshot({ sessionId });
          if (!snapshot) {
            return withCors(Response.json({ error: 'Not found' }, { status: 404 }));
          }
          return withCors(Response.json(snapshot));
        }

        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) {
      if (error instanceof FrontendHandlerError) {
        return withCors(
          Response.json({ error: error.message, code: error.code }, { status: error.statusCode })
        );
      }
      throw error;
    }
  },
};

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

// Expose headers
'X-Session-Id'; // Return stream ID to client

Development CORS

typescript
// Hono
app.use(
  '/api/*',
  cors({
    origin: ['http://localhost:3000', 'http://localhost:5173'],
    allowHeaders: ['Content-Type', 'Last-Event-ID'],
    exposeHeaders: ['X-Session-Id'],
  })
);

// Express
app.use(
  cors({
    origin: ['http://localhost:3000', 'http://localhost:5173'],
    allowedHeaders: ['Content-Type', 'Last-Event-ID'],
    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'],
    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.