Skip to content
WebMCP Agents
Esc
↑↓navigate↵open⌘Jpreview
On this page

createChatHandler

Creates the /api/chat endpoint as a web-standard function, (req: Request) => Promise<Response>, so it runs on Vercel, in any Node or edge server with web Request and Response, and in vite dev.

Creates the /api/chat endpoint as a web-standard function, (req: Request) => Promise<Response>, so it runs on Vercel, in any Node or edge server with web Request and Response, and in vite dev. One POST is one model step: the model’s tool calls stream back to the browser, which runs them on the live page. Use it on the server side of <AgentChat>.

Import

import { createChatHandler, DEFAULT_MODEL, turnstileVerify } from "@webmcp-agents/chat/server";
import type {
  AllowedTool,
  BeforeModelContext,
  TurnstileOptions,
  ChatHandlerOptions,
  ChatSessionOptions,
  ReasoningLevel,
} from "@webmcp-agents/chat/server";

Example

A handler for the page’s agents, a chosen model, sessions, a tool allowlist and a guard. The same file serves GET (is chat configured, and with which model) and POST.

import { createChatHandler } from "@webmcp-agents/chat/server";

import { agents } from "@/agents";

// A crude per-client limit; use a real store in production.
const seen = new Map<string, number>();

const handler = createChatHandler({
  // The same definitions the page passes to <AgentChat agent>.
  agents,
  model: "openai/gpt-5.5", // needs OPENAI_API_KEY
  reasoning: "low",
  maxSteps: 8,

  // The page's first GET starts a session (an httpOnly cookie signed
  // with WEB_AGENTS_SESSION_SECRET); a POST without one gets 401.
  session: true,
  // Only these tools reach the model.
  tools: ["search_products", "add_to_cart", "checkout"],

  // Runs first: a Response here ends the request.
  guard: (req) => {
    const who = req.headers.get("x-forwarded-for") ?? "local";
    const count = (seen.get(who) ?? 0) + 1;
    seen.set(who, count);
    return count > 100
      ? new Response("Too many requests", { status: 429 })
      : undefined;
  },
});

export const GET = handler;
export const POST = handler;

Options

createChatHandler(options?) takes a ChatHandlerOptions; every option is optional. Each POST is run by an AI SDK ToolLoopAgent built for the request from the registered WebAgent the page named (instructions, tool subset, sub-agents, model), or from the plain assistant prompt.

Name Type Default Description
model LanguageModel | string env.WEB_AGENTS_MODEL ?? DEFAULT_MODEL The model: an AI SDK LanguageModel instance, or a "<provider>/<model>" id resolved with the provider’s key from env (anthropic/…, openai/…, google/…, deepseek/…, xai/…, mistral/…, openrouter/<vendor>/<model>, or any a/b id through the AI Gateway: see resolveModel). A bare claude-… id is Anthropic’s. DEFAULT_MODEL is "anthropic/claude-opus-5-5".
system string DEFAULT_SYSTEM_PROMPT The base system prompt. Keep the default’s per-code guidance if you replace it: see system prompt.
agents readonly WebAgent[] [] The agents the page may name (defineWebAgent); sub-agents are found through their parents. Their instructions, tool subsets and models are read from here by id, never from the request. A request naming an agent not listed is refused with 400.
maxOutputTokens number 8192 Output token cap per step. A thinking model’s reasoning counts against it, so keep room beyond the visible reply.
reasoning ReasoningLevel "low" How hard the model thinks per step ("off", "low", "medium", "high"), for any provider that has the knob: see providerOptionsFor. Low keeps a chat about the page quick.
effort "low" | "medium" | "high" | "xhigh" | "max" not set Deprecated: Anthropic’s own effort levels (xhigh and max map to high). Wins over reasoning when set.
maxSteps number 8 Tool steps per user turn. The request after the last one gets no tools, so the model sums up. Keep it equal to the cloud adapter’s maxSteps; an agent’s own maxSteps wins.
guard (req: Request) => Response | undefined (or a Promise) not set Runs first (after the method and cross-site checks); return a Response to end the request. For rate limits and origin checks: a public deployment needs one.
beforeModel (req, { agent, session }) => Response | undefined (or a Promise) not set Runs after validation, once chat is known to be enabled, right before the model call. Count budgets here, so malformed or refused requests never use them up. session is the request’s session id (with session on): key per-session limits on it.
session boolean | ChatSessionOptions false Require a chat session: the page’s first GET sets a random, signed, httpOnly cookie, and a POST without a valid one gets 401 session_required. true or {} for the defaults. See Sessions.
tools readonly (string | WebTool)[] not set (every tool the page sends) The tools a page may offer the model. A tool from defineWebTool is pinned: the model gets its description and schema from the server, whatever the request says. A tool id allows a tool defined inline in a component, with the page’s own (capped) description. The ids registered agents list are allowed too. Any other tool in the request is dropped.
requireAgent boolean false Refuse a POST that names no agent (400), so every conversation runs with a registered agent’s instructions.
prepareAgent (settings, { agent, request }) => settings not set Tune the AI SDK ToolLoopAgent settings built for the request (add prepareStep, telemetry, middleware). The settings carry the model, the instructions and the page’s tools already applied; return what to use.
env Record<string, string | undefined> process.env (or {} without process) Where the provider keys, AI_GATEWAY_API_KEY, WEB_AGENTS_MODEL and WEB_AGENTS_SESSION_SECRET are read. Pass it where the host does not expose process.env.

Sessions

session takes true or a ChatSessionOptions:

Name Type Default Description
secret string env.WEB_AGENTS_SESSION_SECRET, else random per process The HMAC key, at least 32 characters (a shorter one throws). Every server instance must share it: on serverless hosts set WEB_AGENTS_SESSION_SECRET (openssl rand -base64 32). The per-process fallback is for one dev server.
ttl number 1800 Seconds a session lives without a request. A POST past half of it renews the cookie (same id).
maxAge number 86400 Seconds after which a session is not renewed any more, so the page starts a new one.
cookie string "web_agents_session" The cookie’s name.
verify (req: Request) => boolean (or a Promise) every GET Whether this GET may start a session: the place for any bot check (Vercel BotID, your own). Refused, no cookie is set and the page’s POSTs get 401.
turnstile boolean | TurnstileOptions off Require a solved Cloudflare Turnstile challenge to start a session. true reads the secret from TURNSTILE_SECRET_KEY; without one, creating the handler throws. With verify too, both must pass.

The cookie is HttpOnly; SameSite=Strict, its path is the endpoint’s, and it is Secure over https. The token is v1.<id>.<expires>.<born>.<signature>: 128 random bits and an HMAC-SHA256 signature, so the server keeps no store. The cloud adapter needs no change: its availability check is the GET, and a 401 session_required makes it start a session and retry once.

Turnstile

With turnstile on, a session starts only from a GET carrying a token Cloudflare accepts. The page gets one with turnstileHeaders only when it needs a session (the first message, or after one expired), so a visitor who never chats loads nothing. The server posts the token and the secret to Cloudflare’s siteverify and sets the cookie on success. A GET without the header never calls Cloudflare. A token is single-use, so it starts exactly one session; later messages ride on the cookie.

TurnstileOptions (also turnstileVerify(options, env), a plain verify function):

Name Type Default Description
secret string env.TURNSTILE_SECRET_KEY The widget’s secret key.
header string "x-turnstile-token" The request header carrying the token.
hostname string | readonly string[] any Accept only tokens solved on these hostnames.
action string any Accept only tokens rendered with this action (turnstileHeaders sends "web_agents_chat").
timeout number 5000 Milliseconds to wait for siteverify.

It fails closed: a missing token, a rejected one, a network error or a timeout starts no session. Cloudflare’s test keys work end to end: site key 1x00000000000000000000AA with secret 1x0000000000000000000000000000000AA always passes, secret 2x0000000000000000000000000000000AA always fails.

Returns

A handler. By method, in order:

Request Response
OPTIONS 204
Anything but GET or POST 405 { error: "method_not_allowed" }
A browser request from another origin (Origin differs) 403 { error: "forbidden" }
guard returned a Response That response
GET { enabled, model, provider, agents }: enabled is true with a model instance or the key the model needs; agents lists the registered ids
GET with session on and chat enabled Also a Set-Cookie starting a session (unless verify refused), or renewing a live one that is due
POST, no key for the model (and no instance) 503 { error: "chat_disabled", message }, the message naming the env var(s) to set
POST with session on and no valid session cookie 401 { error: "session_required" }, before the body is read
Not application/json 415 bad_request
Body over 1 MB (counted while streaming) 413 bad_request
Body not JSON 400 bad_request
Fails chatRequestSchema 400 { error: "bad_request", issues } (the first 10)
Names an agent not in agents 400 { error: "bad_request", message: 'Unknown agent "…"' }
Names no agent, with requireAgent 400 { error: "bad_request", message: "Name an agent: …" }
beforeModel returned a Response That response
The provider package is not installed 503 { error: "chat_disabled", message: "Install @ai-sdk/… on the server …" }
OK An AI SDK UI message stream (SSE), reasoning included
The model call throws before streaming 500 { error: "server_error" }

Notes

  • A public deployment needs guard, session and tools. Cross-site browser requests are refused, but anyone can still call the endpoint directly (requests without Origin pass).
  • What a session proves, and what it doesn’t. A POST needs a cookie this server issued, and every request belongs to a session you can count. A script that keeps cookies can still GET first: only verify (a bot check) makes starting a session cost a real browser. Nothing can prove a request came from your page.
  • What the allowlist covers. No tool reaches the model unless the server listed it, and listed defineWebTool tools can’t be redescribed by a request. The chat still works with no tools at all (the model just talks); the default system prompt keeps it to the site and has it decline anything unrelated. The messages themselves are the caller’s: limits and budgets bound what they cost.
  • Page tools are declared without execute, with control characters stripped from descriptions and $schema and $id removed from schemas. Tool descriptions go in tool definitions only, never the system prompt.
  • Of the page, only its path reaches the prompt, and only from a same-origin browser request.
  • When the turn already has maxSteps assistant messages with tool calls, the request gets no tools and the system prompt asks for a summary.
  • With a model id the request carries that provider’s reasoning options (Anthropic: effort and fallbacks: "default"). With a model instance none are set, and no provider SDK is loaded.
  • A named agent: its instructions and the delegation note go into the system prompt, its tools filter and delegate tools shape the tool set (delegate descriptors sent by the page are ignored and rebuilt), its model is resolved with the server’s keys, and its maxSteps bounds the turn.
  • Provider metadata on tool calls (Gemini thought signatures, OpenAI item ids) round-trips through the conversation like reasoning signatures.
  • A call to an unknown tool, or with invalid input, goes back to the model as that call’s error (Error [unavailable]: …, Error [invalid_input]: …). Other model errors are logged and streamed as “The assistant had a problem answering. Try again.”
  • The provider SDKs (@ai-sdk/anthropic, @ai-sdk/openai, …, @openrouter/ai-sdk-provider) are optional peer dependencies: install the one your model id needs on the server. The gateway needs none.
  • The example apps wrap the handler with origin, rate-limit and budget guards through createVercelChatRoute.

See also

Was this page helpful?