classification

The five checks, cheapest first

Detection is pure compute: no network calls, no crypto, just a handful of header string scans well under 1ms. Every check that fires is recorded in signals; the highest-precedence one classifies.

  1. Accept: text/markdown (explicit in the list, q-values honored, q=0 excluded) → agent, acceptsMarkdown: true, high confidence. Claude Code and OpenCode send this. Platform attribution comes from Signature-Agent or UA if present, else unknown.
  2. Web Bot Auth headers: Signature-Agent (e.g. "https://chatgpt.com" → chatgpt) → agent, high confidence; a bare Signature + Signature-Input pair → agent/unknown, medium confidence. Parsed only by detect(); verified stays false here because cryptographic verification is the separate async verifyWebBotAuth() step (see below).
  3. Protocol route on the URL pathname: /.well-known/ucp, /.well-known/acp, /mcp, /acp/* (each also matching subpaths) → protocol.
  4. UA heuristics: a fallback only, capped at medium confidence. Known agent UAs: claude-code, opencode, ChatGPT-User, OAI-SearchBot, GPTBot, PerplexityBot, Perplexity-User, Gemini/Google-Extended, anthropic-ai/claude-web (plus ClaudeBot/Claude-User), meta-externalagent/meta-externalfetcher (Meta), Amazonbot, DuckAssistBot and MistralAI-User. Crawler UAs: Googlebot → crawler/googlebot, bingbot and adidxbot → crawler/bingbot, MicrosoftPreview → crawler/microsoft-preview, Applebot → crawler/applebot. Applebot-Extended is a robots.txt token that never crawls, so it has no pattern.
  5. Default → human. High confidence when a browserish Accept (text/html) or UA (Mozilla/) is present; low otherwise (empty or garbage headers).

From detection to serving path

The gateway maps the detection onto a serving path (a Response means markdown was served; null means your HTML pipeline runs as if the gateway weren’t there):

Detection Path Behavior
Agent with Accept: text/markdown or an identified platform markdown Render from sources; Response returned
Agent (signed/identified) on a protocol route, no markdown Accept protocol Routed to the protocols hook, since signed protocol clients must reach the protocol handler
Agent, unidentified, no markdown Accept (e.g. bare Signature pair) html null, because the gateway never guesses a format nobody asked for
Human html null; your HTML pipeline runs as if we weren’t there
Crawler (Googlebot, bingbot, Applebot) html null, always; see the guardrail below
Protocol route protocol config.protocols hook when wired; null otherwise; the event records the demand either way

The Googlebot guardrail

Known search crawlers receive canonical HTML. The gateway renders the same source offer in markdown for matching agent requests; a user-agent claim does not authorize access to private terms.

Confidence semantics

DetectionResultCopy

const result = detect({ headers: req.headers, url: req.url, method: req.method })
// {
//   kind: 'agent' | 'human' | 'crawler' | 'protocol',
//   platform: 'claude-code' | 'opencode' | 'claude' | 'chatgpt' | 'gemini'  
//           | 'perplexity' | 'meta' | 'amazon' | 'duckduckgo' | 'mistral'  
//           | 'googlebot' | 'bingbot' | 'microsoft-preview' | 'applebot'  
//           | 'unknown' | null,
//   verified: boolean,          // ALWAYS false from detect(); verification is the
//                               // separate async verifyWebBotAuth() step
//   acceptsMarkdown: boolean,   // Accept explicitly lists text/markdown with q > 0
//   signals: string[],          // ordered checks that fired, e.g. ['accept:text/markdown', 'ua:claude-code']
//   confidence: 'high' | 'medium' | 'low',
// }

Fixture-driven testing

Detection rules are tested against versioned header fixtures. Add a regression fixture when introducing a new rule, and keep verified identity separate from heuristic classification.

Verification (Web Bot Auth)

Verification is a separate async step from detection: detect() stays synchronous, parse-only, and its verified is always false. verifyWebBotAuth() cryptographically verifies a Web Bot Auth signature chain (RFC 9421 Ed25519 message signatures) against a caller-injected key registry. It is pure crypto over ambient WebCrypto with zero network, and it never throws: every malformed or adversarial header resolves to { verified: false, reason }.