Turnstile
Make starting a chat session cost a real browser. Cloudflare Turnstile runs an invisible check on the first message of a session; the server verifies the token before it sets the session cookie.
Without a check, a script can GET your route, keep the cookie and chat. Turnstile makes each new session cost a
solved Cloudflare challenge: invisible for nearly everyone, a click when Cloudflare is unsure, and a wall for a script
in a loop. It needs sessions on.
How it works
- Nothing loads until it is needed. A visitor who never chats never loads Cloudflare’s script. The first message of a session loads it, runs the widget and removes it as soon as it answers.
- One token, one session. Tokens are single-use. Later messages ride on the session cookie; the next challenge comes with the next session (after 30 minutes idle, or a day).
- It fails closed. No token, a rejected token, Cloudflare unreachable or slower than 5 s: no session.
Set it up
- In the Cloudflare dashboard, open Turnstile, then Add widget: your domains (add
localhostfor development) and the mode Managed or Invisible. You get a site key and a secret key. - Put the secret on the server as
TURNSTILE_SECRET_KEY, never with a public prefix. - Put the site key where the page reads it: it is public, so it does get the prefix (
VITE_,NEXT_PUBLIC_,PUBLIC_).
Set both or neither: a secret without the site key on the page means no session can ever start.
The server
One option on the route. true reads the secret from TURNSTILE_SECRET_KEY, and creating the handler throws without
it:
const handler = createChatHandler({
agents,
session: { turnstile: true },
});
The complete route turns it on only when the secret is set, so the same file works
before you set Turnstile up. @webmcp-agents/vercel-api and the Vite dev plugin do that by themselves.
Pass options instead of true to tighten it: hostname (accept tokens solved on your domains only), action (the
page sends "web_agents_chat"), timeout. See Turnstile options.
The page
Give the chat a cloud adapter that solves the challenge when the server asks for a session. Create it once, at module scope, and pass it to the chat.
import { createCloudAdapter, turnstileHeaders } from "@webmcp-agents/chat";
const cloud = createCloudAdapter({
sessionHeaders: turnstileHeaders({
siteKey: import.meta.env.VITE_TURNSTILE_SITE_KEY,
}),
});
// Where the chat is:
<AgentChat agent={shopper} adapters={[cloud]} /> import { createCloudAdapter, turnstileHeaders } from "@webmcp-agents/chat";
const cloud = createCloudAdapter({
sessionHeaders: turnstileHeaders({
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
}),
});
// Where the chat is:
<AgentChat agent={shopper} adapters={[cloud]} /> import { createCloudAdapter, turnstileHeaders } from "@webmcp-agents/chat";
const cloud = createCloudAdapter({
sessionHeaders: turnstileHeaders({
siteKey: import.meta.env.VITE_TURNSTILE_SITE_KEY,
}),
});
// Where the chat is:
<AgentChat agent={shopper} adapters={[cloud]} /> import { createCloudAdapter, turnstileHeaders } from "@webmcp-agents/chat";
const cloud = createCloudAdapter({
sessionHeaders: turnstileHeaders({
siteKey: import.meta.env.PUBLIC_TURNSTILE_SITE_KEY,
}),
});
// Where the chat is:
<AgentChat agent={shopper} adapters={[cloud]} /> If the page offers an on-device model too, keep it in the list: adapters={[cloud, local]}. Only the cloud adapter
talks to your route.
Test it
Cloudflare’s test keys work on any domain, localhost included, with no account:
| Site key | Secret | Result |
|---|---|---|
1x00000000000000000000AA |
1x0000000000000000000000000000000AA |
Always passes (invisible) |
2x00000000000000000000AB |
2x0000000000000000000000000000000AA |
Always fails |
3x00000000000000000000FF |
1x0000000000000000000000000000000AA |
Forces the interactive challenge |
Send a message with the first pair and look for the web_agents_session cookie; with the always-fail secret, the chat
says “Couldn’t start a chat session”.
Edge cases
- A Content-Security-Policy must allow
https://challenges.cloudflare.cominscript-srcandframe-src. - The script blocked (an extension, a network that blocks Cloudflare): no session, and the chat says so. Nothing else on the page is affected.
- A click is needed: the widget shows at the bottom of the page and waits up to a minute. It is marked as library UI, so clicking it never makes the agent cursor yield.
- Privacy: Cloudflare sees the visitor’s request to its challenge, as with any Turnstile page. Say so in your privacy policy if you list processors.
- Another bot check (Vercel BotID, your own): use
session.verifyon the server andsessionHeaderson the adapter the same way.