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

Deploy

Put the chat route and its environment variables on a host, guard it against abuse, and smoke test it. Vercel for the Vite and Next.js apps is what the examples run; TanStack Start and Astro describe the shape.

A WebMCP Agents site has exactly one piece of server code: the chat’s /api/chat route, where the model runs and the key lives. Everything else (the tools, the cursor, the devtools) is static JavaScript in the browser. So deploying is: ship the static site, run the route on a server, give the server the key, and put guards in front of it.

What is tested: the Vite examples (examples/playground, examples/ecommerce, examples/flights) deploy as static SPAs whose api/chat.ts is a Vercel Function, and examples/nextjs deploys as a Next.js project. The TanStack Start and Astro sections describe the shape of the same route on those frameworks; no example app is deployed from them yet.

1. The route on your framework

The route is createChatHandler from @webmcp-agents/chat/server: a web-standard (Request) => Response function, which is why it runs on any host that runs server code. See Add the chat for the route in each framework. What differs is where the route sits and what the host needs.

Vite

Vite builds a static site, so the route is a separate function. On Vercel, api/chat.ts at the project root is one:

{
  "framework": "vite",
  "rewrites": [{ "source": "/((?!api/|assets/).*)", "destination": "/index.html" }],
  "functions": { "api/chat.ts": { "maxDuration": 60 } }
}

The rewrite serves index.html for every path except /api/* and /assets/*, so deep links like /product/ocean-tee work. maxDuration: 60 is the function’s limit; each tool step is its own request, so a long multi-step turn never hits it. Any other host that runs a Request to Response function works the same way.

Next.js

The route is app/api/chat/route.ts, part of the Next.js project, so there is no second thing to deploy. On Vercel the project’s framework is Next.js:

{
  "framework": "nextjs",
  "functions": { "app/api/chat/route.ts": { "maxDuration": 60 } }
}

Read the key through a server-only module, as Field Store on Next.js does, so a client component cannot import it by accident.

TanStack Start

The route is src/routes/api/chat.ts, a server route of the Start app, so it deploys wherever the Start server does. Pick the host and its build preset from TanStack Start’s hosting guide; the chat adds nothing to that except the environment variables below, set on the server. Not deployed from this project’s examples yet.

Astro

The route is src/pages/api/chat.ts with export const prerender = false, so the site needs a server adapter (Node, Vercel, Netlify, Cloudflare, …); a fully static Astro build has nowhere to run it. Set the environment variables on the host: the route reads them through getSecret from astro:env/server at request time. Not deployed from this project’s examples yet.

2. Environment variables (server-side only)

The key and the model are read on the server. Never give them a public prefix: VITE_ (Vite, TanStack Start), NEXT_PUBLIC_ (Next.js) and PUBLIC_ (Astro) variables are copied into the JavaScript the browser downloads.

Var Required Default Purpose
ANTHROPIC_API_KEY (or the key of the provider in WEB_AGENTS_MODEL: OPENAI_API_KEY, OPENROUTER_API_KEY, …) for chat none Enables /api/chat. Without it the widget shows “Chat isn’t configured” and the app works; the function log names the variable.
AI_GATEWAY_API_KEY no none Serves any provider/model id through the AI Gateway when the provider’s own key is missing.
WEB_AGENTS_MODEL no anthropic/claude-sonnet-5-5 in the examples The model, as <provider>/<model>. See Pick a model.

The next rows are read by @webmcp-agents/vercel-api, the guards the example apps use (section 3). They do nothing in a route that does not use it.

Var Default Purpose
WEB_AGENTS_MAX_OUTPUT_TOKENS 4096 Per model step; a thinking model’s reasoning counts against it.
WEB_AGENTS_MAX_STEPS 6 Tool steps per user message (counted from the conversation the browser sends).
RATE_LIMIT_PER_MINUTE 30 Per IP. Each tool step is one request (a 6-step turn is 7): keep room for a few turns.
RATE_LIMIT_PER_DAY 300 Per IP.
RATE_LIMIT_PER_SESSION 150 Per chat session per day.
RATE_LIMIT_GLOBAL_DAILY 3000 Model calls across all visitors (malformed requests do not count). Past it the chat says “daily limit reached, try tomorrow”.
TURNSTILE_SECRET_KEY none Turns on Cloudflare Turnstile for new chat sessions. Set the widget’s public site key for the page too (VITE_TURNSTILE_SITE_KEY, NEXT_PUBLIC_TURNSTILE_SITE_KEY).
WEB_AGENTS_SESSION_SECRET none Signs the chat session cookies (openssl rand -base64 32). Required on Vercel production and preview deployments: without it the chat stays off.
KV_REST_API_URL / KV_REST_API_TOKEN none Upstash Redis, set by the Vercel Marketplace integration (UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN also work). Recommended.

On Vercel, set the key without it passing through a file or a chat:

vercel env add ANTHROPIC_API_KEY production   # paste the key at the prompt
vercel env add ANTHROPIC_API_KEY preview

To rotate it: create a new key in the provider’s console, vercel env rm ANTHROPIC_API_KEY production and vercel env add …, redeploy, and revoke the old key.

3. Guards

The route refuses browser requests from other sites, but anyone can still call it directly, and every call costs model tokens. A public deployment needs a guard in front of the model. Secure the chat explains each layer (sessions, the tool allowlist, Turnstile, limits) and shows the locked-down route for every framework; this section is the deployment side of it.

createChatHandler takes two hooks for it: guard runs first and returns a Response to end the request (origin checks, per-IP rate limits), and beforeModel runs after the request is validated, right before the model call (a global budget, so malformed requests never use it up). See createChatHandler.

The example apps use @webmcp-agents/vercel-api for both: chat sessions, a tool allowlist, the origin allow-list, per-IP limits per minute and per day, a per-session limit and a global daily budget, as one line, createVercelChatRoute({ app, agents, tools }). That package is private and not on npm: read its reference page and packages/vercel-api/src as a starting point for your own guards. Without Redis each function instance counts on its own (a warning is logged), so limits are per instance, not global; add Upstash Redis from the Vercel Marketplace for real ones. If Redis stops answering (or rejects the token), per-IP limits let requests through (the chat stays up) and the global budget refuses them (the bill stays bounded; the chat says it is busy).

POSTs must carry the deployment’s own Origin (any domain it is served on, vercel dev included); other sites’ pages are refused. Any script can fake an Origin header, so the real protection is the per-IP limits, the global budget and the per-request token cap. The step cap is counted from the conversation the browser sends, so a scripted caller can sidestep it: the budget is what bounds the bill.

Sessions. The page’s first GET /api/chat sets a random session id in an httpOnly, SameSite=Strict cookie, signed with WEB_AGENTS_SESSION_SECRET; a POST without it gets 401. That stops a bare POST, ties every request to a session you can limit, and gives you one place to put a bot check: session: { verify } (Cloudflare Turnstile, Vercel BotID) decides whether a GET may start a session. Without a check, a script that keeps cookies can still GET first; no header or token can prove a request came from your page, since the page runs on the visitor’s machine.

Turnstile. The check that comes built in. Create a widget in the Cloudflare dashboard (Turnstile, Add widget: your domains, mode Managed or Invisible), then set its secret on the server (TURNSTILE_SECRET_KEY) and its site key on the page (VITE_TURNSTILE_SITE_KEY in the Vite examples, NEXT_PUBLIC_TURNSTILE_SITE_KEY in Next.js). The examples pass turnstileHeaders to their cloud adapter when the site key is set, and @webmcp-agents/vercel-api turns session.turnstile on when the secret is. The first message of a session runs an invisible challenge (a click only when Cloudflare is unsure); later messages ride on the cookie. Each new session then costs a solved challenge, so the per-session limit means something. Set both keys or neither: a secret without the site key on the page means no session can start.

The tool allowlist. tools lists what the model may see. The app’s defineWebTool tools are pinned (their descriptions come from the server, not the request); tools defined inline in components are listed by id; anything else a request sends is dropped. A chat with no tools still works: the default system prompt keeps the model to the site.

A spend limit at the provider. The last guard is not in your code. In the provider’s console, cap what the key can spend: Anthropic’s Console has monthly spend limits per workspace (give the site’s key a workspace of its own), and OpenAI’s and the AI Gateway’s dashboards have the same. If every guard above failed, the provider stops serving the key at that amount instead of billing on. Alerts at a lower amount tell you before it trips.

4. Create the projects on Vercel

For each app, in the Vercel dashboard (Add New, then Project, then import the repo), or with the CLI (vercel link in apps/<name>):

Setting Value
Root Directory examples/playground (or examples/ecommerce, examples/flights, examples/nextjs)
Framework Vite (set by vercel.json); Next.js for examples/nextjs
Build, install, output from vercel.json: turbo run build, pnpm install --frozen-lockfile, dist (Vite apps)
Node.js version 24.x (the AI SDK needs Node 22+)
Skip unaffected projects on (vercel.json’s ignoreCommand: npx turbo-ignore does the same from code)

Then deploy, and smoke test the deployment:

vercel deploy            # preview: check it, then
vercel deploy --prod
P=https://<deployment>
curl -s $P/api/chat   # {"enabled":true,"model":"anthropic/claude-sonnet-5-5","provider":"anthropic","agents":[…]}
curl -s -o /dev/null -w "%{http_code}\n" $P/some/deep/link                  # 200 (SPA rewrite, Vite apps)
# a foreign origin is refused
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$P/api/chat" \
  -H 'origin: https://evil.example' -H 'content-type: application/json' -d '{}'   # 403
# no session cookie: 401
BODY='{"messages":[{"id":"u","role":"user","parts":[{"type":"text","text":"hi"}]}],"tools":[],"agent":"shopper"}'
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$P/api/chat" -H "origin: $P" -H 'content-type: application/json' -d "$BODY"
# with the GET's cookie, the per-minute limit: the 31st request in a minute gets 429
curl -s -c /tmp/chat.jar -o /dev/null $P/api/chat
for i in $(seq 1 32); do
  curl -s -b /tmp/chat.jar -o /dev/null -w "%{http_code} " -X POST "$P/api/chat" -H "origin: $P" -H 'content-type: application/json' -d "$BODY"
done; echo

The last three checks apply to a route behind the example guards (shopper is the agent of the ecommerce apps; use one of yours); with your own, test what your guards promise.

Notes

  • The polyfill. The provider loads it in production too, so in browsers without native WebMCP the deployed demos have their tools and the chat works. To ship native WebMCP only, pass polyfill={false} (In production).
  • The on-device assistant (@webmcp-agents/local-agent) runs ONNX Runtime single-threaded. Cross-origin isolation (COOP same-origin plus COEP require-corp) makes WASM several times faster but breaks third-party embeds, so the vercel.json files do not set it.
  • ONNX Runtime’s WASM (about 27 MB) is a static asset of the build; it is fetched only after a visitor chooses On-device and clicks Download.

The website

apps/docs is the whole website, a static Blume site: the landing page at / (a custom page whose own actions are WebMCP tools, built on the packages) and the docs/ folder under /docs. It is its own Vercel project and needs no secrets.

Setting Value
Root Directory apps/docs
Framework none ("framework": null in vercel.json)
Build, install, output from vercel.json: turbo run build, pnpm install --frozen-lockfile, dist
Include source files outside of the Root Directory on (the default): the content is ../../docs
Skip builds vercel.json’s ignoreCommand skips a commit that touches neither apps/docs, docs/, packages/ nor the lockfile
VERCEL_DEEP_CLONE optional, true: full git history, so pages show their real “last modified” date

Blume picks up the production domain from the host’s environment for canonical URLs, the sitemap and Open Graph images (or from SITE_URL). SITE_URL also fills the registry URL on the shadcn chat blocks page, so set it to the site’s origin. vercel.json also sets the content types of the raw Markdown copies (/<page>.md) and llms.txt, and holds the redirects from the routes the docs had before they were ordered by framework and by step: the same list as redirects in apps/docs/blume.config.ts, because a Git-connected Vercel project reads only the root vercel.json.

D=https://<site-deployment>
curl -s -o /dev/null -w "%{http_code}\n" $D/                                        # 200, the landing page
curl -s -o /dev/null -w "%{http_code}\n" $D/docs/reference/core/define-web-tool     # 200
curl -sI $D/docs/reference/core/define-web-tool.md | grep -i content-type            # text/markdown; charset=utf-8
curl -s $D/llms.txt | head -5

Publishing the packages

Releases are local, run by the maintainer from a clean, up-to-date main (scripts/release.mjs; no CI job publishes). One-time setup: own the @webmcp-agents scope on npm and npm login; gh auth login (the changelog links PRs).

pnpm release --dry-run         # the plan, the gate, and the tarballs; nothing is written
pnpm release                   # pending changesets → versions + CHANGELOGs → gate → commit → npm publish → push tags
pnpm release patch             # no changeset written? one is generated from the commits since the last release
pnpm release:snapshot          # a throwaway prerelease under the `next` dist-tag, no commit

pnpm release:version and pnpm release:publish split the release in two; release:publish again after a publish that failed half-way skips the versions already on npm. The seven public packages share one version (fixed). pnpm turbo run check-exports runs publint and are-the-types-wrong on every package.

Was this page helpful?