AI agents by framework / JavaScript and TypeScript
How to add an AI agent to an Express app
An AI agent in an Express app is a POST route behind your session, CSRF and rate-limit middleware that calls a model with tools, runs the tool calls as the signed-in user and streams the reply to the browser as server-sent events. The AI SDK provides the model loop and a helper that pipes the stream into the Express response; the middleware chain, authorization and limits are yours. If the agent only needs to do what your pages already do, you can embed one instead.
What an agent in an Express app is made of
- A route: a
POST /api/chathandler on an ExpressRouterthat holds the model API key in a server environment variable and calls the model. - A middleware chain: your session middleware identifies the user, an auth middleware rejects anonymous requests, then CSRF and rate-limit middleware run before the route. Express has no built-in CSRF protection, so that piece is a package you add.
- Tools: typed functions the model may call, each wrapping a query or service your routes already use, and each bound to the user the auth middleware put on
res.locals. - The agent loop: the model calls a tool, reads the result and decides the next step until it can answer. Cap the number of steps and the total time.
- A streamed response: the reply is written to
resas server-sent events while the model works, and a chat UI in the browser renders it. That UI can be a script on a server-rendered view or a React, Vue or Angular frontend. - Approval: actions that change data wait for the user to confirm them in the UI.
Ways to add an agent to Express
| Approach | Good for | What you take on |
|---|---|---|
| AI SDK in an Express route | A custom agent with your own tools and any model provider, streaming to any frontend. | The middleware chain, authorization in every tool, approval UI, rate and cost limits. |
| Mastra with its Express adapter | Agents with memory, workflows and several agents exposed as routes in an existing Express app. | A framework and its conventions to learn, plus your own auth and limits in front of its routes. |
| A provider SDK with your own loop | One model provider and full control over every request. | The tool loop, streaming format and client protocol that a library would otherwise provide. |
| Hosted answer-only chat widget | Questions about your docs or help center. | Keeping its content current. It does not act in your app. |
| Embedded in-app agent (Bunkhouse) | Letting signed-in users get tasks done through the pages and API you already have. | A script tag, an identity endpoint and hints for operations discovery cannot see. |
Mastra documents its adapter in the Express adapter reference. The rest of this guide uses the AI SDK directly, because it keeps every route and middleware in your own code.
Build an agent with the AI SDK
The AI SDK runs in any Node.js server, and its helper pipeUIMessageStreamToResponse writes a streamed reply into an Express response. The examples use AI SDK 7 with Express 5 on Node.js 24, which runs the TypeScript files directly. Install the SDK, Zod for tool schemas and the middleware packages:
npm install ai zod express-session cookie-parser csrf-csrf express-rate-limit ejs
npm install -D @types/express @types/express-session @types/cookie-parserThe model string "anthropic/claude-sonnet-5.5" goes through the Vercel AI Gateway, which reads the API key from the AI_GATEWAY_API_KEY variable. To call a provider directly, install its package (for example @ai-sdk/anthropic) and pass its model instead.
1. Session, CSRF and rate-limit middleware
The chat route sits at the end of a chain. The express-session middleware loads the signed-in user from your existing login, and requireUser answers 401 without one and copies the user ID to res.locals. The csrf-csrf package implements the double-submit cookie pattern: generateCsrfToken issues a token tied to the session, and the middleware doubleCsrfProtection rejects a POST whose x-csrf-token header does not match it. Finally, express-rate-limit counts requests per user rather than per IP address, since each message costs model tokens.
import cookieParser from "cookie-parser";
import { doubleCsrf } from "csrf-csrf";
import express, { type NextFunction, type Request, type Response } from "express";
import { rateLimit } from "express-rate-limit";
import session from "express-session";
import { chatRouter } from "./chat.ts";
declare module "express-session" {
interface SessionData {
userId: string; // set by your existing login route
}
}
declare global {
namespace Express {
interface Locals {
userId: string;
}
}
}
const app = express();
app.set("trust proxy", 1); // one reverse proxy in front: real client IPs and secure cookies
app.set("view engine", "ejs");
app.use(
session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production" },
// store: a shared store in production; the default MemoryStore is for development only
}),
);
app.use(cookieParser()); // after express-session, before the CSRF middleware
app.use(express.json({ limit: "1mb" }));
function requireUser(req: Request, res: Response, next: NextFunction) {
if (!req.session.userId) return res.status(401).json({ error: "unauthorized" });
res.locals.userId = req.session.userId;
next();
}
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET!,
getSessionIdentifier: (req) => req.session.id,
});
const chatLimit = rateLimit({
windowMs: 60_000,
limit: 10,
standardHeaders: "draft-8",
legacyHeaders: false,
keyGenerator: (_req, res) => res.locals.userId, // per user, not per IP
});
// Server-rendered pages put the token in a meta tag; single-page frontends fetch it.
app.get("/orders", requireUser, (req, res) => {
res.render("orders", { csrfToken: generateCsrfToken(req, res) });
});
app.get("/api/csrf-token", requireUser, (req, res) => {
res.json({ token: generateCsrfToken(req, res) });
});
app.use("/api/chat", requireUser, doubleCsrfProtection, chatLimit, chatRouter);
app.listen(3000);2. The streaming chat route
The route reads the user ID only from res.locals, so the model can choose an order ID but never whose orders it reads or cancels. The body is untrusted input; validateUIMessages checks its shape first. The condition isStepCount(5) caps the loop, and timeout bounds the whole call and each tool. The setting toolApproval makes cancelOrder wait for the user, and the option experimental_toolApprovalSecret signs each approval request so a client cannot forge one in the message history it sends back.
The function pipeUIMessageStreamToResponse sets the server-sent event headers and writes each chunk to the response as the model produces it. Express does not cancel the model call when the browser goes away, so the route aborts it on the response's close event. The keepAliveMs option sends a comment line while a tool runs, which keeps proxies with short idle timeouts from dropping the connection.
import {
convertToModelMessages,
isStepCount,
pipeUIMessageStreamToResponse,
streamText,
toUIMessageStream,
tool,
validateUIMessages,
} from "ai";
import { Router } from "express";
import { z } from "zod";
import { cancelOrder, listOrders } from "./orders.ts"; // your existing data layer
export const chatRouter = Router();
chatRouter.post("/", async (req, res) => {
const { userId } = res.locals; // set by requireUser, never taken from the request body
// The body is untrusted: check its shape before it reaches the model.
const messages = await validateUIMessages({ messages: req.body?.messages });
// Stop the model call if the browser disconnects mid-stream.
const abort = new AbortController();
res.on("close", () => {
if (!res.writableFinished) abort.abort();
});
const result = streamText({
model: "anthropic/claude-sonnet-5.5",
instructions: "You help the signed-in user manage their orders. Use the tools; never guess order data.",
messages: await convertToModelMessages(messages),
tools: {
listOrders: tool({
description: "List the signed-in user's recent orders",
inputSchema: z.object({ status: z.enum(["open", "shipped", "cancelled"]).optional() }),
execute: async ({ status }) => listOrders(userId, { status }),
}),
cancelOrder: tool({
description: "Cancel one of the signed-in user's open orders",
inputSchema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => cancelOrder(userId, orderId),
}),
},
toolApproval: { cancelOrder: "user-approval" },
experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET,
stopWhen: isStepCount(5),
abortSignal: abort.signal,
timeout: { totalMs: 120_000, toolMs: 15_000 },
});
await pipeUIMessageStreamToResponse({
response: res,
stream: toUIMessageStream({ stream: result.stream }),
keepAliveMs: 15_000, // SSE comments keep idle proxies from closing the stream
});
});3. The page and the browser side
A server-rendered page receives its CSRF token in a meta tag and loads a script that renders the chat. The chat itself comes from one of the AI SDK UI packages, which all accept the same transport. The transport below sends the token with every request, including the automatic request that resumes the conversation after an approval.
<!doctype html>
<html lang="en">
<head>
<meta name="csrf-token" content="<%= csrfToken %>" />
<script type="module" src="/assets/orders-agent.js"><\/script>
</head>
<body>
<div id="orders-agent"></div>
</body>
</html>import { DefaultChatTransport } from "ai";
// Pass this transport to useChat (@ai-sdk/react, @ai-sdk/vue) or new Chat (@ai-sdk/angular).
export const transport = new DefaultChatTransport({
api: "/api/chat",
headers: () => ({
"x-csrf-token": document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? "",
}),
});For the chat component itself, with the approval buttons, see the guides for React, Vue and Angular. A single-page frontend served separately fetches its token from /api/csrf-token instead of reading a meta tag.
Production checklist for Express
- Order the middleware deliberately. Session, then authentication, then CSRF, then rate limiting, then the route. Tools read the user only from what the auth middleware set, and every query they run is scoped to that user.
- Use a shared session store. The express-session README warns that the default store,
MemoryStore, leaks memory and is not designed for production. With several processes, sessions, rate limit counters and the secret inTOOL_APPROVAL_SECRETmust be shared by all of them. - Set
trust proxyto match your deployment. Behind a load balancer, Express needs it to see HTTPS for secure cookies and the client's IP address; see Express behind proxies. - Keep streams flowing. Compression middleware or a buffering proxy can hold back a streamed response until it ends, as the AI SDK's streaming troubleshooting page describes; skip compression for the chat route. Set load balancer idle timeouts above your
keepAliveMs. - Handle errors on both sides of the first byte. Express 5 passes rejected promises from async handlers to your error handler, so a failure before streaming starts can still return a status code. Once the stream has started, the AI SDK sends an error event instead, and masks its message by default so internal details do not reach the browser.
- Do not block the event loop. One Node.js process serves every open stream. Tools that wait on a database or an API are fine; move CPU-heavy work to a worker or a queue.
- Treat tool input and tool results as untrusted. The model chooses tool arguments, and data a tool returns (user-written text, emails, web pages) can contain instructions. Validate arguments against your business rules and require approval for anything irreversible.
- Limit cost. Cap steps and time per request, rate-limit per user and set spending limits with your model provider.
Option: embed an in-app agent with Bunkhouse
If the agent's job is to help signed-in users do work your Express app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.
The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your Express backend keeps enforcing its own authorization on every request.
In Express, put the loader in the head of your layout view, after the identity hook and before your application's own scripts, so it loads once per page. For signed-in users, mount a router with the endpoint GET /api/agent-identity after your session middleware; it signs a short-lived token for the user the session already authenticated, with the signing secret in a server environment variable.
<head>
<%# The identity hook, then the loader, before your application's own scripts. %>
<script src="/agent-identity.js"><\/script>
<script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
<script src="/app.js" defer><\/script>
</head>The tested Express integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.
When Bunkhouse fits, and when to build it yourself
- Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
- Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
- Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
- Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.
Try it on your Express app first
A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your Express app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Express page at http://localhost:3000.Use a page with disposable data: everything the assistant reads is sent to your agent.
The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.