Skip to main content
Glama
perxel

Perxel MCP Kit

Official
by perxel
README.md
# Perxel MCP Kit

A public template for building a remote MCP server plus its monitoring
dashboard, 100% on Cloudflare Workers. Every MCP built from it follows the
same checklist: see `STANDARD.md`.

Source-available under the PolyForm Shield 1.0.0 license (`LICENSE`): free
to use and modify, not to resell or offer as a competing service. This is
**not** open source (OSI).

The MCP itself is tested and supported for **Claude** (claude.ai web, Desktop,
mobile, Claude Code). The optional **Ask** add-on (an on-site chat over the
same tools, see [Ask](#ask-optional-on-site-chat)) runs on any model through
OpenRouter.

## Infrastructure

Read this first. It answers "how many domains, Workers, and what runs where".

### At a glance

Per Cloudflare account: **one subdomain and one Worker per MCP**, plus **one
shared metrics-proxy Worker** (no public URL). No containers, no Pages, no
separate dashboard host, no hosted Inspector.

```
                        ┌──────────── Cloudflare account ─────────────────────────────┐
 Claude ──/mcp────────▶ │  MCP Worker  "<slug>-mcp"   https://<host>                  │
                        │   /mcp  /  /health  OAuth routes                            │
 You (browser) ─/dash─▶ │   /dash  ◀─ Access app (edge sign-in) + JWT re-check        │
   via Access           │     │ service binding METRICS_PROXY (Worker-to-Worker)      │
                        │     ▼                                                       │
                        │  Metrics proxy Worker  "mcp-metrics-proxy"  (no URL)        │
                        │     │ holds the only Analytics Engine read token            │
                        │     ▼                                                       │
                        │  Analytics Engine dataset  mcp_<slug>  (written by /mcp)    │
                        │  KV: OAUTH_KV, CONTENT_KV  ·  Access: sign-in app, /dash app │
 Website (Ask, opt.) ─▶ │   /ask  ─▶ OpenRouter (model)   D1 ASK_DB  ·  cron daily    │
   CORS + Turnstile     │   /dash/conversations ◀─ its own Access app, always private │
                        └─────────────────────────────────────────────────────────────┘
 Your laptop: MCP Inspector (`pnpm dev` / `pnpm inspect`), never deployed.
```

### Hostnames

| Hostname | Worker | Notes |
|---|---|---|
| `<host>`, e.g. `mcp.example.com` or `docs-mcp.example.com` | the MCP Worker | Attached as a Workers **Custom Domain** from `routes` in `wrangler.mcp.jsonc`; `wrangler deploy` creates the DNS record and certificate. Use a one-level subdomain of a zone in your account (covered by the free Universal SSL certificate; `a.b.example.com` needs a paid Advanced Certificate). |

That's the only hostname. `workers_dev` and `preview_urls` are off on both
Workers, so nothing is reachable any other way.

### Paths on `<host>`

| Path | What | Who can reach it |
|---|---|---|
| `/mcp` | The MCP endpoint (`POST`). The URL users paste into Claude is `https://<host>/mcp`. | Public tools: anyone. Private tools: OAuth (Access one-time PIN sign-in, then consent). |
| `/` | Docs page generated from the tool list, with connect steps. | Public |
| `/health` | `{ok, name, slug, version, kit, sources}`; 503 when a required source can't load. | Public |
| `/.well-known/oauth-*`, `/authorize`, `/callback`, `/token`, `/register` | OAuth 2.1 (discovery, DCR/CIMD, PKCE, refresh), sign-in via Cloudflare Access for SaaS (OIDC). | Public (it's the OAuth flow) |
| `/dash` (`DASH_PATH`) | Monitoring dashboard: calls by status, latency p50/p95, per-tool table, clients, auth split, countries, top problems; 24h / 7d / 30d / 90d. Server-rendered HTML + SVG, no client JS. | **Access-protected**: a Cloudflare Access app on `<host>/dash` signs you in at the edge, and the Worker re-verifies the `Cf-Access-Jwt-Assertion` (signature, audience, issuer, expiry). Not configured → 503; no or bad token → 403. Optional public demo: `DASH_PUBLIC="true"` (see [Public dashboard](#public-dashboard-demo)). |

`DASH_PATH` is configurable (`/dash` default, any path like `/ops/metrics`,
or `off`). It can't be `/` or shadow an MCP route. If you change it, change
the Access app's path to match.

### Workers

| Worker | Config | Count | What it holds |
|---|---|---|---|
| `<slug>-mcp` (template name `example-mcp`) | `wrangler.mcp.jsonc` | one per MCP | The MCP, OAuth, docs, `/health`, `/dash`. Bindings: `OAUTH_KV`, `CONTENT_KV`, `METRICS` (Analytics Engine `mcp_<slug>`), two rate limiters, `METRICS_PROXY` (service binding). |
| `mcp-metrics-proxy` | `wrangler.proxy.jsonc` | **one per Cloudflare account**, shared | The only Analytics Engine read token (`CF_API_TOKEN`, account-wide by Cloudflare's design). Maps each MCP's key to its own dataset (`PROXY_KEYS`) and rejects any SQL that reads another dataset. No public URL. |

Why a separate proxy at all: an Analytics Engine token can't be scoped to one
dataset. If every MCP Worker held it, one compromised MCP could read every
other MCP's metrics. With the proxy, an MCP Worker holds only its own key, which
can only read `mcp_<slug>`.

### Cloudflare Access (Zero Trust)

| Access app | Type | Count | Used for |
|---|---|---|---|
| MCP sign-in | **SaaS, OIDC**. Redirect URI `https://<host>/callback`; login method: email one-time PIN (or another IdP) | one per MCP | Signing in users of private tools. Gives the five `ACCESS_*` secrets. |
| Dashboard | **Self-hosted**, domain `<host>`, path `dash` | one per MCP (none when `DASH_PUBLIC` is `"true"`) | Who can open `/dash`. Policy: allow the emails that should see metrics. Its **AUD tag** goes in `DASH_ACCESS_AUD`. |

Seats: sign-in users and dashboard viewers share Zero Trust's 50 free users
across all MCPs in the account, then about $7/user/month.

### What is deliberately *not* deployed

- **MCP Inspector.** It's a Node (Express) app, not a static site: its
  browser UI talks to its own backend (`/api/mcp/connect`, `/api/mcp/send`,
  and an open `/api/fetch` proxy), so Pages can't host it. Workers can't run
  it either, and it hardcodes root paths (`/assets`, `/api`), so it can't sit
  under `/inspector`. Hosting it would take a container plus an Access app,
  just to expose a generic fetch proxy. Run it locally instead:
  `pnpm dev` (against local) or `pnpm inspect https://<host>/mcp --allow-remote`
  (against production, on purpose).
- **Grafana.** Earlier versions ran Grafana in a Cloudflare Container on
  a separate dashboard host: one more Worker and a container per MCP, cold
  starts, and a Docker build. A fixed set of panels covers "is this MCP
  healthy and used", so the dashboard is now part of the MCP Worker. If you
  really want ad-hoc querying, Grafana is an add-on pointed at the
  same proxy (give the proxy a URL only for that case).
- **Cloudflare Pages.** Nothing here is a static site. Cloudflare also steers
  new projects to Workers (with static assets) rather than Pages.

### What wrangler does and doesn't do

| Wrangler CLI | Cloudflare dashboard (or API with a token) |
|---|---|
| Create KV namespaces, set secrets, deploy both Workers, attach the custom domain (`routes` + `custom_domain`), service binding, Analytics Engine dataset (created on first write), rate limiters, `wrangler tail` | Access apps and the Analytics Engine read token (both done by `pnpm setup:cloudflare` through the API); Workers Paid plan; enabling Zero Trust the first time |

### Costs (checked 2026-09-30)

- Workers Paid, $5/month per account: covers every MCP and the proxy at small scale (10M requests, 30M CPU-ms included).
- Zero Trust: 50 free seats shared, then about $7/user/month.
- No container cost.
- LLM tokens are paid by the user's Claude subscription, not by the MCP.

## What's inside

```
STANDARD.md          SSOT checklist: protocol, tools, auth, limits, metrics row, monitoring, tests, costs
kit/                 owned by the kit; clones never edit (updated via pnpm kit:update)
  core/              gate, OAuth + Access sign-in, consent page, rate limit, metrics, logs, health,
                     docs page, dashboard (dash.ts), static JSON source + search
  metrics-proxy/     shared Worker holding the only Analytics Engine token; scopes each key to one dataset
  testing/           protocol test, gate tests, eval runner, inspect launcher
  update/            the kit:update script
  setup/             the setup:cloudflare script (Cloudflare account setup from one global API key)
src/tools/           small generic example (one public, one private tool)
mcp.config.ts        example config
evals/               example questions
wrangler.mcp.jsonc   the MCP Worker (one per MCP)
wrangler.proxy.jsonc the metrics proxy (one per account)
```

## Create a new MCP from the template

1. **Use the template.** On GitHub: Use this template → create your repo
   (e.g. `docs-mcp`, `shop-mcp`), then clone it.
2. **Install.** `pnpm install` (Node 22, pnpm 10.22.0).
3. **Set `mcp.config.ts`.** `slug` (`[a-z0-9-]`; gives the dataset
   `mcp_<slug>` with `-` → `_`), `name`, `version`, `description`, `scopes`
   (every private tool's scope must be listed), and the `tools` list.
4. **Set `wrangler.mcp.jsonc`.** `name` (`<slug>-mcp`), the `routes` pattern
   and `MCP_PUBLIC_URL` (same host, e.g. `mcp.example.com` and
   `https://mcp.example.com`), the dataset name, unique rate-limiter
   `namespace_id`s, and `CONTENT_URL` if you use a static source. Copy
   `.dev.vars.example` to `.dev.vars` for local dev.
5. **Write tools.** Add files in `src/tools/` with `defineTool` from
   `kit/core/tool.ts`: `name` (snake_case), `title`, `description` (what it
   returns, units, freshness, limits), `access: "public" | "private"` (plus a
   `scope` when private), a zod `inputSchema`, and `execute(input, ctx)`.
   Keep the token rules from `STANDARD.md`: list/search tools return compact
   items only (title, slug, excerpt, URL, category, never full text), full
   text comes only from a detail tool for one item, every list has a `limit`
   (default 5, max 20), no "dump everything" tool. Errors: throw
   `new ToolError("not_found" | "bad_input" | "upstream" | "unavailable", msg)`;
   anything else becomes `"Internal error"`.
6. **Tests.** `pnpm typecheck && pnpm test`: unit tests per tool, the
   protocol test (runs against your own config), gate tests (200/401/403),
   dashboard and proxy tests.
7. **Evals.** Edit `evals/questions.md` (one `##` question: `- tool:` lines
   with JSON args, `- expect:` lines with facts that must appear), then
   `pnpm eval --url http://localhost:8788/mcp` against `pnpm dev`.
8. **Clones never edit `kit/`.** Kit fixes arrive via `pnpm kit:update`.

### Content source (static JSON)

For a site-backed MCP (e.g. an MCP over your own website), the site publishes
one JSON file at build time and the MCP reads it:

- The file must be at a **public URL** (e.g. `https://example.com/content.json`),
  set as `CONTENT_URL`. The Worker has no filesystem, so a file in the repo
  isn't read at runtime.
- Recommended shape and search rules: `STANDARD.md` → "Static JSON source".
- In `src/`, create the source once at module level and pass it to both the
  tools and `/health`:

```ts
// src/content.ts
import { z } from "zod";
import { createStaticSource } from "../kit/core/static-source.js";
export const content = createStaticSource({
  name: "content",
  url: (env) => env.CONTENT_URL as string,
  kvKey: "content",
  parse: (json) => Content.parse(json), // your zod schema
});

// src/index.ts
export default createWorker(config, { sources: { content } });
```

- Loading order: fresh memory (about 10 min) → conditional fetch
  (`If-None-Match`) → stale memory → `CONTENT_KV` last-good copy → a clear
  "unavailable" error. `/health` loads the source too, so it reports what a
  tool call would get (`memory`, `fetch`, `kv-fallback`, or `empty` → 503).
- Content changes show up within about 10 minutes of the site deploy, with
  no MCP redeploy.
- Search tools: `searchItems()` ranks compact hits; add `passages(body, query)`
  to the top 3 hits so Ask (and any client) can answer from the search result
  alone. Without them every answer needs a detail call, one more model round
  trip (about 1 to 2 s with Ask).

## Local development

| Command | What |
|---|---|
| `pnpm dev` | `wrangler dev` on `http://localhost:8788` (MCP at `/mcp`, dashboard at `/dash/`), plus the Inspector UI on `http://localhost:8002` pointed at the local `/mcp`. |
| `pnpm inspect` | The Inspector alone, against local by default. `pnpm inspect https://<host>/mcp --allow-remote` inspects production; without the flag it refuses. |
| `pnpm eval --url http://localhost:8788/mcp` | Eval questions; also refuses non-local URLs without `--allow-remote`. |
| `pnpm typecheck && pnpm test` | Must pass after every change. |

The local dashboard skips the Access check (`MCP_PUBLIC_URL` is localhost in
`.dev.vars`). It shows an empty page with a notice unless the proxy runs too:
`wrangler dev -c wrangler.mcp.jsonc -c wrangler.proxy.jsonc` with
`DASH_PROXY_KEY`, `CF_API_TOKEN` and `PROXY_KEYS` in `.dev.vars` (that queries
the real Analytics Engine, so use a real read token only when you need to).

## Deploy

Never deploy, `secret put`, or create resources from a dev machine without
the owner's approval (`STANDARD.md` → Dev rules).

### One command: `pnpm setup:cloudflare`

The only input is the account's **Global API Key** (Cloudflare → My Profile →
API Tokens → Global API Key) and the login email, in `.dev.vars` (gitignored)
or the environment:

```
CF_EMAIL="you@example.com"
CF_GLOBAL_API_KEY="..."
```

Set `name`, `routes`, `MCP_PUBLIC_URL` and the `mcp_<slug>` dataset in
`wrangler.mcp.jsonc`, then:

```
pnpm setup:cloudflare --dry-run     # reads only, prints what it would do
pnpm setup:cloudflare               # does it, deploys, checks /health
```

Flags: `--emails a@x.com,b@x.com` (who may sign in and see the dashboard;
default: the login email), `--policy "<name>"` (reuse an existing reusable Access
policy by name; default `MCP access`), `--no-deploy`.

It is idempotent (looks first, creates only what is missing, safe to re-run) and
drives wrangler with the same key, so no `wrangler login` is needed. It does:

| Step | What |
|---|---|
| Account | Finds the account; fills `CF_ACCOUNT_ID` in `wrangler.proxy.jsonc` |
| Zero Trust | Reads the team domain into `DASH_ACCESS_TEAM` |
| Access policy | Creates (or reuses) one reusable *allow* policy for the emails |
| Access apps | Creates the SaaS OIDC sign-in app (`https://<host>/callback`) and the self-hosted dashboard app (`<host>/dash`), attaches the policy, fills `DASH_ACCESS_AUD`. With `DASH_PUBLIC="true"` it skips the dashboard app (and warns if one still exists) |
| KV | Creates `<worker>-OAUTH_KV` and `<worker>-CONTENT_KV`, fills their ids |
| Rate limits | Picks `namespace_id`s no other Worker in the account uses |
| Metrics proxy | Deploys `mcp-metrics-proxy` if absent; creates the Analytics Read token and sets `CF_API_TOKEN` |
| Secrets | `IP_HASH_SALT`, `COOKIE_ENCRYPTION_KEY`, the five `ACCESS_*`, `DASH_PROXY_KEY` |
| Proxy keys | Keeps every MCP's key hash in a `mcp-kit-registry` KV namespace and rebuilds the full `PROXY_KEYS` map from it, so adding an MCP never drops another |
| Deploy | `wrangler deploy`, then waits for `/health` |

One thing it cannot do: **enable Zero Trust on an account for the first time**
(one click, Zero Trust → Get started, free plan). It stops with that message
if it is off. Also: Cloudflare shows a SaaS app's client secret only when the
app is created, so if `ACCESS_CLIENT_SECRET` is ever lost, delete the sign-in
Access app and re-run.

The global key is all-powerful: keep it in `.dev.vars` only, never commit it,
and rotate it if it leaks. The Worker never reads it.

### Deploy on push (GitHub Actions)

After the first `pnpm setup:cloudflare`, every push to `main` deploys through
`.github/workflows/deploy.yml` (it comes with "Use this template"; it does not
run in the kit repo itself). It runs `pnpm typecheck` and `pnpm test`, applies
the Ask D1 migrations when the config has `ASK_DB`, deploys the MCP Worker
only, then waits for `<MCP_PUBLIC_URL>/health`. It never touches secrets or
the metrics proxy; those stay with `setup:cloudflare`. Pushes that only change
`.claude/`, `.idea/`, `*.md` or `evals/` don't deploy.

It needs two repo secrets:

- `CLOUDFLARE_ACCOUNT_ID`: the account id (`CF_ACCOUNT_ID` in
  `wrangler.proxy.jsonc`).
- `CLOUDFLARE_API_TOKEN`: a scoped API token, never the Global API Key.
  Account: *Workers Scripts Edit*, *Workers KV Storage Edit*, *D1 Edit*,
  *Account Settings Read*; for a custom domain also, on its zone: *Workers
  Routes Edit*, *DNS Edit*, *Zone Read*. One token can serve every MCP in the
  account.

```
gh secret set CLOUDFLARE_ACCOUNT_ID -R <owner>/<repo>
gh secret set CLOUDFLARE_API_TOKEN -R <owner>/<repo>
```

`kit:update` replaces `kit/` only, so clones made before a change to this
workflow copy the new file by hand.

### Manual steps (what the script automates)

Only needed to understand or repair a setup by hand. Run `pnpm exec wrangler
login` first (it opens a browser).

### Once per Cloudflare account (the first MCP does this)

1. **Workers Paid plan** ($5/month). Don't run production on free (its 10 ms CPU cap per request is a real risk).
2. **Analytics Engine read token** (dashboard → My Profile → API Tokens →
   Create: *Account → Account Analytics → Read*, this account only).
3. **Metrics proxy.** Set `CF_ACCOUNT_ID` in `wrangler.proxy.jsonc`, then:
   ```
   pnpm exec wrangler secret put -c wrangler.proxy.jsonc CF_API_TOKEN
   pnpm exec wrangler secret put -c wrangler.proxy.jsonc PROXY_KEYS   # full JSON map, see below
   pnpm exec wrangler deploy -c wrangler.proxy.jsonc
   ```
4. **Zero Trust team domain** (Zero Trust → Settings → team name,
   `<team>.cloudflareaccess.com`) goes in every MCP's `DASH_ACCESS_TEAM`.

### Per MCP

1. **KV.** `pnpm exec wrangler kv namespace create OAUTH_KV` and
   `... CONTENT_KV`; paste the ids into `wrangler.mcp.jsonc`.
2. **Access sign-in app** (Zero Trust → Access → Applications → Add →
   SaaS → OIDC). Redirect URI `https://<host>/callback`; add a policy (e.g.
   email one-time PIN for allowed emails). Note the client ID, client
   secret, token URL, authorization URL and JWKS URL.
3. **Access dashboard app** (Add → Self-hosted). Domain `<host>`, path
   `dash`; policy: the emails allowed to see metrics. Copy its
   **Application Audience (AUD) tag** into `DASH_ACCESS_AUD`.
4. **Proxy key.** Generate one (`openssl rand -hex 32`), hash it
   (`printf %s '<key>' | shasum -a 256`), add `"<hash>": "mcp_<slug>"` to
   the proxy's `PROXY_KEYS` and re-put the **whole** map (a secret put
   replaces the value), then set the key on the MCP:
   `pnpm exec wrangler secret put -c wrangler.mcp.jsonc DASH_PROXY_KEY`.
5. **MCP secrets** (`pnpm exec wrangler secret put -c wrangler.mcp.jsonc <NAME>`):
   `IP_HASH_SALT`, `COOKIE_ENCRYPTION_KEY` (random, `openssl rand -hex 32`),
   `ACCESS_CLIENT_ID`, `ACCESS_CLIENT_SECRET`, `ACCESS_TOKEN_URL`,
   `ACCESS_AUTHORIZATION_URL`, `ACCESS_JWKS_URL`, `DASH_PROXY_KEY`.
6. **Deploy.** `pnpm exec wrangler deploy -c wrangler.mcp.jsonc`. The custom
   domain, DNS record and certificate are created here.
7. **Check.**
   - `https://<host>/health` is `ok: true` (and the source shows `fetch`).
   - `https://<host>/` lists the tools.
   - `https://<host>/dash` asks for Access sign-in, then shows panels (empty until the first tool calls).
   - In Claude: Settings → Connectors → Add custom connector → `https://<host>/mcp`. Public tools work at once; a private tool shows a Connect card → Access PIN → Allow → Claude retries.
   - `pnpm exec wrangler tail <slug>-mcp` for live logs.

### Where every value comes from

| Name | Kind | Where it's set | Source |
|---|---|---|---|
| `MCP_PUBLIC_URL` | var | `wrangler.mcp.jsonc` | `https://<host>`; must match `routes` |
| `CONTENT_URL` | var | `wrangler.mcp.jsonc` | the site's public JSON URL (only with a static source) |
| `DASH_PATH` | var | `wrangler.mcp.jsonc` | `/dash` default, or `off` |
| `DASH_ACCESS_TEAM` | var | `wrangler.mcp.jsonc` | Zero Trust team domain (per account) |
| `DASH_ACCESS_AUD` | var | `wrangler.mcp.jsonc` | AUD tag of the `/dash` Access app (per MCP) |
| `DASH_PUBLIC` | var | `wrangler.mcp.jsonc` | Optional. `"true"` opens `/dash` with no sign-in (public demo, per MCP); leave it out to keep Access. Metrics become world-readable. `setup:cloudflare` then skips the dashboard Access app |
| `IP_HASH_SALT`, `COOKIE_ENCRYPTION_KEY` | secret | MCP | random |
| `ACCESS_CLIENT_ID`, `ACCESS_CLIENT_SECRET`, `ACCESS_TOKEN_URL`, `ACCESS_AUTHORIZATION_URL`, `ACCESS_JWKS_URL` | secret | MCP | the Access SaaS/OIDC app |
| `DASH_PROXY_KEY` | secret | MCP | random; its hash goes in `PROXY_KEYS` |
| `CF_ACCOUNT_ID` | var | `wrangler.proxy.jsonc` | dashboard account id |
| `CF_API_TOKEN` | secret | proxy | Account Analytics Read token |
| `PROXY_KEYS` | secret | proxy | `{"<sha256 of each MCP's key>": "mcp_<slug>", ...}` |

## Public dashboard demo

By default `/dash` is private (Access, above). To show it off as a live demo
with no sign-in, opt in per MCP:

1. `wrangler.mcp.jsonc` vars: `"DASH_PUBLIC": "true"` (any other value keeps it private).
2. Delete the dashboard Access app on `<host>/dash` (Zero Trust → Access →
   Applications), or `/dash` still asks for a login. `setup:cloudflare` skips
   creating it and warns if it exists.
3. `wrangler deploy -c wrangler.mcp.jsonc`.

Everything on the dashboard becomes world-readable (call counts, clients,
countries, tool names, problems), so only do this for an MCP whose usage you
are happy to show. The sign-in app for private tools is unaffected. To go
private again, remove the var and re-run `pnpm setup:cloudflare`.

## `kit:update`

Pulls the latest `kit/` from the kit repo without touching the clone's own
files (`src/`, `mcp.config.ts`, `evals/`, wrangler configs, `package.json`):

```
pnpm kit:update [ref] [--from <local path>]
```

- Refuses if `git status --porcelain kit/` is not empty (commit first).
- Default source is `kit.json` (`repo` + `ref`, tarball from GitHub);
  `--from` copies from a local checkout.
- Replaces `kit/` entirely, writes `kit/VERSION` as `<ref>@<short sha>`,
  compares `kit/deps.json` against the clone's `package.json` and prints
  any mismatches with the `pnpm add` command to fix them (never edits
  `package.json` automatically), then prints `git diff --stat kit/`.
- Review the diff, run `pnpm typecheck && pnpm test`, commit.
- Clone-owned files the kit changed in a release (wrangler configs,
  `package.json` scripts) are listed in that release's commit message; copy
  those changes by hand.

## Ask (optional on-site chat)

Ask answers a website's visitors from the MCP's own **public** tools, called
in process (not over MCP), through a model on OpenRouter (AI SDK). Add an
`ask` block to `mcp.config.ts` to switch it on; without one, none of the
routes below exist (404).

```ts
ask: {
  model: "<openrouter model id>",
  fallbackModels: ["<another id>"],            // optional
  summaryModel: "<cheaper id>",                // optional: summaries, topic labels
  systemPrompt: { vi: "…", en: "…" },          // the client's facts and voice; appended to the kit's rules (kit/ask/prompt.ts)
  allowedOrigins: ["https://example.com"],     // exact origins; localhost only in local dev
  handoff: [{ label: "Contact", href: "https://example.com/contact" }],
  topics: ["pricing", "process"],              // + "other"
  retentionDays: 90,                           // conversation text deleted after this
  sessionHours: 24,                            // x-ask-session life after the last answer; then Turnstile again, same conversation
  actions: ["lead", "subscribe"],              // optional: prepare_lead / prepare_subscribe (below)
  limits: { messagesPerConversation: 20, maxInputChars: 1000, maxOutputTokens: 700, maxToolSteps: 4, perIpPerMinute: 6, dailyBudgetUsd: 3 },
}
```

| Path | What |
|---|---|
| `POST /ask` | One visitor turn. Body: `useChat`'s `{ id (UUID), messages }` plus `locale` (`vi`/`en`), `page`, and `turnstileToken` on the first turn. Answers with an AI SDK UI message stream (text, `source-url` parts built from tool results, a `suggest_handoff` tool part). Returns `x-ask-session` (HMAC, `sessionHours`, default 24 h, sliding; once expired, send a fresh `turnstileToken` without the header and the same `id` carries on) for later turns. |
| `POST /ask/summary` | `{ id }` + session: a 1 to 3 sentence summary of what the visitor wants, for a lead form. Once per 5 minutes per conversation. |
| `POST /ask/lead` | `{ id }` + session: marks the conversation as having sent a lead. |
| `GET /dash/conversations` | Review page (stripped text, tools, cited pages; keep a question, delete a conversation). **Always behind Access**, even with `DASH_PUBLIC=true`. |

**Actions** (optional, `actions`). `prepare_lead {intent: contact | quote | other,
message, contact?, name?, briefUrl?}` and `prepare_subscribe {email?}` let the
model prepare a contact request or a newsletter signup when the visitor asks
for one. They send nothing: the tool part's output carries the fields and the
website shows a form the visitor checks and sends through its own form handler.
A field the visitor did not write in the current message (phone by its digits,
the rest as text) is dropped from the output, and a `briefUrl` must be
http(s). The prompt tells the model never to offer them on its own
(`actionRules` in `kit/ask/prompt.ts`). Since the stored history is redacted,
details from earlier messages are the website's job (it has the raw
conversation). Evals: `"expectAction": "lead" | "subscribe"` on a question; a
question without it fails if any action is called.

Checks are judged in this order: origin → body → session or Turnstile →
per-IP limit (`ASK_RATE_LIMITER`) → conversation cap (D1) → daily budget (D1
spend, cached 60 s) → model. Origin and body come first; Turnstile, the
limiter and the D1 reads then run together (the conversation, its messages
and the spend in one D1 `batch`), so a turn waits for the slowest of them
instead of their sum. Cost of that: a request with a bad Turnstile token
still makes one D1 read and counts against the rate limit. Errors are JSON `{ code }`: `origin`, `bad_request`,
`verify_failed`, `session_expired`, `rate_limited`, `conversation_limit`,
`budget`, `upstream`. Only the last user message is read from the request;
the history comes from D1, so a client can't write the assistant's turns.

Privacy: every visitor message is stripped of emails, phone numbers and long
numbers (`kit/ask/redact.ts`) before it is stored; the raw text goes to the
model only. A daily cron labels idle conversations with a topic and deletes
message text after `retentionDays` (the conversation row, with no visitor
text, stays). OpenRouter requests use `provider.data_collection: "deny"`.

Bindings and secrets (all created by `pnpm setup:cloudflare` when the config
has `ask`): D1 `ASK_DB` (migrations in `kit/ask/migrations`), rate limiter
`ASK_RATE_LIMITER`, a cron trigger, the Access app on
`<host>/dash/conversations` (`ASK_DASH_ACCESS_AUD`), a Turnstile widget for
the allowed origins (its site key goes in the website), and the secrets
`OPENROUTER_API_KEY` (prompted, never written to disk; set a credit limit on
the key in OpenRouter first), `TURNSTILE_SECRET`, `ASK_SESSION_SECRET`.

Local dev: run `wrangler dev` with Cloudflare's Turnstile test secret
`1x0000000000000000000000000000000AA` in `.dev.vars`, then
`pnpm eval --ask evals/ask-questions.json` (local URLs only). Besides the
pass rate it prints, per question and as p50/p90, the time to the first
answer text, the full answer and the content tool calls per answer: run it
before and after a prompt or tool change to see what it did to speed.

Metrics: tool calls made by Ask are ordinary rows with client `ask`; each
turn adds one `ask.turn` row (see `STANDARD.md`). The public dashboard shows
Ask counts, outcomes, topics and cost, never message text.

Latency: each answered turn records the time to the first answer text (what
the visitor feels), the full answer, the checks before the model, the model
wait and tool time (from the AI SDK's per-step `performance`), and the number
of model steps. The dashboard's "Ask latency" card shows their p50/p95 and a
trend; every turn also logs `ask.timing` with a per-step breakdown (`wrangler
tail` or Workers Logs) for looking at one slow answer.

## Transferring to another Cloudflare account

See `TRANSFER.md`.