# Policy reference

Rules live in your repo as code, in the middleware file. They are plain data: easy to read, diff and roll back. This page documents the demo policy that runs on heedeo.com, with its line numbers (every decision cites one):

```text
 1  import { heedeo } from "@heedeo/next";
 2  
 3  export default heedeo({
 4    trustedOperators: ["chatgpt.com", "browserbase.com", "anchorbrowser.io"],
 5    "/signup": {
 6      humans: "allow",
 7      agents: {
 8        verified: "allow",
 9        noPrincipal: { challenge: "passkey" },
10        unknown: { challenge: "passkey" },
11      },
12      perPrincipal: "5/day",
13      overCap: { throttle: 5, rate: "1/hour", then: "block" },
14    },
15    "/checkout": { agents: { verified: "allow", unknown: "block" } },
16    "/docs/*": { agents: "allow" },
17    "*": { agents: { unknown: { challenge: "passkey" } } },
18  });
```

Get it yourself: `curl -s https://heedeo.com/api/policy` returns `{ "source": ..., "rules": ... }`.

Anything not on this page is set up with you at onboarding.

## `trustedOperators` (line 4)

The operators whose signatures count as verified. An entry also covers its subdomains. A valid Web Bot Auth signature from any other operator counts as unknown.

## Routes and matching

Each other top-level key is a route.

| Key | Matches |
|---|---|
| `"/signup"` | exactly `/signup` |
| `"/checkout"` | exactly `/checkout` |
| `"/docs/*"` | anything under `/docs/`, for example `/docs/auth` (not `/docs` itself) |
| `"*"` | every path no other key matches |

**Order:** exact routes first, then `/x/*` prefixes (longest prefix first), then `"*"`.

**Normalization**, before matching: lowercase; drop the query and fragment; decode percent escapes; read `\` as `/`; collapse `//`; resolve `.` and `..`; drop the trailing slash. So `/SignUp/` matches `/signup`, and `/docs/../checkout` matches `/checkout`, not `/docs/*`:

```bash
curl "https://heedeo.com/api/decide?route=/docs/../checkout&client=agent&signed=false"
# verdict "block", rule.path "/checkout", line 15
```

## Route rule keys

| Key | Values in the demo | Meaning |
|---|---|---|
| `humans` | `"allow"` | What to do with people. Unset means allow. |
| `agents` | `"allow"`, or an object | A string applies to every agent, whatever its tier. An object sets each tier. Unset means allow. |
| `agents.verified` | `"allow"` | Agents signed by a trusted operator. |
| `agents.noPrincipal` | `{ challenge: "passkey" }` | Verified agents that name no principal, on a route with `perPrincipal`. The cap can't be counted without a principal. Unset means challenge with passkey. |
| `agents.unknown` | `{ challenge: "passkey" }`, `"block"` | Agents that are not verified, including detected ones. Unset falls back to the `"*"` rule's `unknown`, then to challenge with passkey. |
| `perPrincipal` | `"5/day"` | A cap per principal on the whole route, people and agents alike. Windows: `"N/min"`, `"N/hour"`, `"N/day"`. |
| `overCap` | `{ throttle: 5, rate: "1/hour", then: "block" }` | What happens past the cap. The next `throttle` requests are rate-limited at `rate`, then blocked. |

Actions are `"allow"`, `"block"` or `{ challenge: "passkey" }`.

## The cap, step by step

With `perPrincipal: "5/day"` and the `overCap` above, for one principal on `/signup`:

| Count in the window, including this request | Verdict | Status | Rule line |
|---|---|---|---|
| 1 to 5 | the client rule's verdict | 200 for allow, 401 for challenge | 6, 8 or 10 |
| 6 to 10 | throttle, `retryAfter` 3600 (1/hour) | 429 | 13 |
| 11 or more | block | 403 | 13 |

Without an `overCap`, anything over the cap is throttled until the window resets, with `retryAfter` set to the window length, and never blocked.

The cap is checked after the client rule. A client rule that blocks wins. A challenge or allow inside the cap keeps its verdict.

## Verdicts

| Verdict | HTTP status | What happens |
|---|---|---|
| allow | 200 | The request reaches your route. |
| challenge | 401 | The request waits on a person: a passkey step. Whoever finishes it becomes the principal. |
| throttle | 429 | Rate-limited, not refused. The response carries `Retry-After` (`retryAfter` in seconds). |
| block | 403 | The request does not reach your route. Logged with its reason. |

## Agent tiers

| Tier | Meaning | How rules treat it |
|---|---|---|
| verified | A valid Web Bot Auth signature from an operator on `trustedOperators`. | `verified` |
| detected | Recognised by user agent but not signed (or signed by an untrusted operator). | Treated as `unknown`. |
| unknown | No valid signature from a trusted operator, and not recognised either. | `unknown` |

## The demo policy, decided

Every row is real output of `/api/decide` (https://heedeo.com/docs/api.md).

| route | client | signed | operator | principal, count | verdict | line |
|---|---|---|---|---|---|---|
| /signup | human | | | | allow | 6 |
| /signup | agent | true | chatgpt.com | maya, 2 | allow | 8 |
| /signup | agent | true | chatgpt.com | none | challenge | 9 |
| /signup | agent | false | | | challenge | 10 |
| /signup | agent | true | agent.example-operator.com | | challenge | 10 |
| /signup | agent | true | chatgpt.com | maya, 7 | throttle | 13 |
| /signup | agent | true | chatgpt.com | maya, 11 | block | 13 |
| /signup | human | | | dave, 12 | block | 13 |
| /checkout | agent | true | chatgpt.com | | allow | 15 |
| /checkout | agent | false | | | block | 15 |
| /docs/auth | agent | false | | | allow | 16 |
| /pricing | agent | false | | | challenge | 17 |
| /pricing | agent | true | chatgpt.com | | allow | 17 |

```bash
curl "https://heedeo.com/api/decide?route=/signup&client=agent&signed=true&operator=chatgpt.com&principal=maya&count=11"
curl "https://heedeo.com/api/decide?route=/pricing&client=agent&signed=false"
```

## A variant that is not in the demo

A per-minute cap on checkout, for drop bots:

```ts
"/checkout": { agents: { verified: "allow", unknown: "block" }, perPrincipal: "60/min" },
```

This is not part of the demo policy, so `/api/decide` won't show it. With no `overCap`, the 61st request in a minute for one principal is throttled (429, `retryAfter` 60) until the minute resets. Unknown agents are still blocked by the `unknown` rule first.

---

Docs index: https://heedeo.com/docs.md. How to verify our claims: https://heedeo.com/trust.md. Book a call: https://cal.com/heedeo/30min

Updated: 2026-10-02
