---
description: "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."
---

# `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`. 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>`](/docs/reference/chat/agent-chat).

## Import

```ts
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`.

```ts title="api/chat.ts"
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`](/docs/reference/chat/define-web-agent) 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`](/docs/reference/chat/resolve-model)). 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](/docs/reference/chat/system-prompt). |
| `agents` | `readonly WebAgent[]` | `[]` | The agents the page may name ([`defineWebAgent`](/docs/reference/chat/define-web-agent)); 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`](/docs/reference/chat/resolve-model). 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](#sessions). |
| `tools` | `readonly (string \| WebTool)[]` | not set (every tool the page sends) | The tools a page may offer the model. A tool from [`defineWebTool`](/docs/reference/core/define-web-tool) 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 `POST`s get `401`. |
| `turnstile` | `boolean \| TurnstileOptions` | off | Require a solved [Cloudflare Turnstile](#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](/docs/reference/chat/create-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`](/docs/reference/chat/create-cloud-adapter#turnstile) 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`](/docs/reference/chat/chat-request-schema) | `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`](/docs/reference/vercel-api/create-vercel-chat-route).

## See also

- [Add the chat](/docs/build/add-the-chat), the page that teaches it
- [`defineWebAgent`](/docs/reference/chat/define-web-agent), [`toToolLoopAgent`](/docs/reference/chat/to-tool-loop-agent) and [`resolveModel`](/docs/reference/chat/resolve-model)
- [`webAgentsChatDev`](/docs/reference/chat/web-agents-chat-dev), the same endpoint in `vite dev`
- [`chatRequestSchema` and `LIMITS`](/docs/reference/chat/chat-request-schema)
- [`buildSystemPrompt` and `DEFAULT_SYSTEM_PROMPT`](/docs/reference/chat/system-prompt)
- [`createCloudAdapter`](/docs/reference/chat/create-cloud-adapter), the client side
- [Tool error codes](/docs/reference/core/tool-error)
