# [Classification](https://rebilder.com/docs/classification)

> How the gateway decides who is asking (five checks, cheapest first, pure compute) and the guardrail that keeps crawlers on canonical HTML.

- **Updated:** 2026-08-15
- **Author:** Rebilder
- **Section:** Concepts
- **Description:** How the gateway decides who is asking (five checks, cheapest first, pure compute) and the guardrail that keeps crawlers on canonical HTML.
- **Publisher:** Rebilder

## 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.

- **`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`.
- **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).
- **Protocol route** on the URL pathname: `/.well-known/ucp`, `/.well-known/acp`, `/mcp`, `/acp/*` (each also matching subpaths) → `protocol`.
- **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`). Crawler UAs: `Googlebot` → `crawler`/`googlebot`, `bingbot` → `crawler`/`bingbot`.
- **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) | `html` | `null`, **always**; see the guardrail below |
| Protocol route | `protocol` | `config.protocols` hook when wired (see [Protocols](/docs/protocols)); `null` otherwise; the event records the demand either way |

## The Googlebot guardrail

> **Crawlers always get canonical HTML** One deliberate exception to the ordering: a known crawler UA always classifies as `crawler`, even if other signals fired, and even if a request claiming to be Googlebot sends `Accept: text/markdown`. The markdown path is unreachable for crawlers by construction. (`acceptsMarkdown` still reports the header fact; `kind: 'crawler'` pins the serving path.)

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

DetectionResult

```
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' | 'googlebot' | 'bingbot' | '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',
// }
```

- **high**: an explicit negotiation signal (`Accept: text/markdown`, or an identified `Signature-Agent`). Also the browserish-human default.
- **medium**: UA heuristics and bare signature pairs. These identify a claimed requester, not verified authority.
- **low**: the default `human` classification with empty or garbage headers.
- Authorize private actions through the appropriate verified credentials and scopes. Web Bot Auth verification is separate from a parsed platform claim.

## 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 }`.

> **Configure trusted platform keys** Configure a registry of trusted platform keys to enable signature verification. Without a matching trusted key, verification returns `unknown-agent`. See the [UCP checkout configuration](/docs/protocols#checkout-verification).