# Adorably claim handlers

A claim handler is a short JavaScript function that Adorably runs when a developer claims your offer. It gets the claim (and the email the developer chose to share) and returns the link the developer opens: a signup with the perk already applied, a trial your API just created, a signed link your backend trusts. You write it, test it and save it through the seller MCP server at mcp.adorably.io.

Without a handler, every claim opens the same plain landing URL. Use a handler when the link should be personal.

Every failure (an exception, a timeout, a bad return value, your own `adorably.fallback`) sends the developer to your plain landing URL, so a broken handler never breaks a claim. It does cost you that developer's personal link, so test it.

## The loop

1. Check that every host your handler links to or fetches is one of your approved hosts: the hosts of your offers' landing pages that Adorably approved, and their subdomains (`whoami` lists them; a landing page on `www.example.com` covers `example.com`). Test runs use the same hosts, so an offer on a host not yet approved (your first offer, say) gets its handler once Adorably approves it. To add a host, put an offer's landing page on it; Adorably reviews it with the offer. A landing page on a platform many people publish on (github.com, docs.google.com, medium.com and the like) is refused: link a page on your own domain.
2. Read the runtime types: the `adorably://claim-handler.d.ts` resource, also at https://adorably.io/docs/claim-handlers/handler-runtime.d.ts. They are the whole API.
3. Start from the closest example (the `adorably://claim-handler-examples` resource, or https://adorably.io/docs/claim-handlers/examples.md) and change it.
4. Run it with `test_claim_handler`. It runs the handler once, the way a claim would, and saves nothing. The result shows what the developer would get, and adds `logs` (your `console` lines) and `fetch_log` (every fetch, its status and time).
5. Read the result, fix, run again, until it returns the link you expect, including when your API fails: `fetch_mocks` makes a fetch answer 500, time out, fail or answer anything you like, without touching your API.
6. Save it with `set_claim_handler`, with your `privacy_url` (it takes `offer_id`, `source`, `secrets`, `test_email` and `claim_id` like `test_claim_handler`, but not `fetch_mocks`). It runs the same test again, against your real API, and saves only when the test passes.
7. Watch real claims: `list_claims` shows each claim's handler status, and `get_analytics` counts handler failures and shows the last error.

Test runs use the claim id `clm_test` (or the `claim_id` you pass) and the email you pass as `test_email` (default `test@adorably.io`), and `input.test` is `true`. Unless a fetch is mocked, they call your real API, so pass `input.test` on to it (or check for it) and have your API treat the request as a test. If your API returns the same answer for the same idempotency key, pass a fresh `claim_id` for each test, or a change to the handler can hide behind a cached answer.

Anyone who can use the seller MCP server for your account (every user and agent token of it) can run handlers with your saved secrets, so they can read them: a handler can put a secret, encoded, into its answer. Redaction in test results keeps plain values out of a transcript; it is not a boundary.

## The contract

The source is a plain script, not a module: no `import`, no `export`. It defines a top-level function named `handle`:

```js
async function handle(input) {
  // ...
  return { url: 'https://example.com/welcome', cta_label: 'Open your account' }
}
```

`const handle = async (input) => { ... }` works too. Adorably runs each claim in a fresh QuickJS sandbox: nothing carries over from one claim to the next.

### Input

| Field | What it is |
| --- | --- |
| `input.offer.id` | Your offer's id, like `off_01JBQZ8Y3S4T5V6W7X8Y9Z0ABC`. |
| `input.offer.vendor` | The offer's vendor name. |
| `input.offer.value` | The offer's value, like `3 months of Pro free`. |
| `input.offer.url` | The offer's plain landing URL. |
| `input.claim_id` | The claim's id, as `list_claims` shows it. `clm_test` in test runs, or the `claim_id` you passed. |
| `input.email` | The email the developer chose to share. Always there: a claim without one never runs your handler, and a test run has `test_email`. |
| `input.email_verified` | `true` in claims: the developer confirmed they own `input.email` with a code Adorably mailed to it. Absent in test runs. |
| `input.test` | `true` in test runs (`test_claim_handler`, `set_claim_handler`), `false` in claims. |

### Output

`handle` resolves to an object. Other fields are ignored.

| Field | Rules |
| --- | --- |
| `url` | Required. https, on one of your approved hosts (or a subdomain), no user or password, at most 1024 characters. |
| `cta_label` | Optional. The claim button's label: plain text, at most 24 characters, not blank, no URL or domain, nothing in a prohibited category (the `adorably://prohibited` resource lists them). Anything that reads as a domain counts, so dotted names like `Node.js` or `Next.js` are refused: write `Node` or `NodeJS`. It is trimmed and moderated; in a claim a label that fails is dropped and the default label shows. |

### What the code can use

- `SECRETS`: the secrets you saved with `set_claim_handler`, by name, read-only. For example `SECRETS.API_KEY`. A name that is not set reads as `undefined`; test runs log a warning the first time.
- `fetch(url, init)`: a subset of fetch. https only, to your approved hosts (and their subdomains), no user or password in the URL. GET, POST, PUT or PATCH. `body` must be a string (`JSON.stringify` an object). `timeoutMs` gives up sooner than the per-fetch limit. Headers that pick the destination or describe the hop (`Host`, `Connection`, `Transfer-Encoding`, `Forwarded`, `X-Real-IP`, `True-Client-IP`, `CF-*`, `X-Forwarded-*`) are dropped. Redirects are not followed: a 3xx comes back as it is. The response has `status`, `ok`, `headers`, `text()` and `json()`; the whole body is already read. `headers` is a plain object with lower-case names, not a `Headers`: `res.headers['x-request-id']`, not `res.headers.get(...)`.
- `console.log`, `info`, `warn`, `error` and `debug`: lines in test runs, nothing in claims.
- `adorably`: helpers for links and codes (below).
- Standard JavaScript: `JSON`, `Math`, `Date`, `Promise`, `RegExp`, `Map`, typed arrays, `encodeURIComponent` and the rest of the language, including `??`, `?.`, `catch` without a binding, `.at()` and `Object.hasOwn`. Regex work counts against the time limit like the rest of your code.

Not there: `URL`, `URLSearchParams`, `TextEncoder`, `atob`, `btoa`, `crypto`, `setTimeout` and other timers, `queueMicrotask`, `structuredClone`, `AbortController`, `AbortSignal`, `Intl`, storage and modules. The `adorably` helpers cover what handlers need these for.

### The adorably helpers

| Helper | What it does |
| --- | --- |
| `adorably.url(base, params)` | `base` plus `params` as an encoded query string. Skips `null` and `undefined` values; keeps an existing query and a `#fragment`. Throws when `base` is not an absolute https URL. Use it for every link you build. |
| `adorably.sha256(data)` | SHA-256 of the text (UTF-8), as 64 lower-case hex characters. |
| `adorably.hmac(key, message, encoding)` | HMAC-SHA256, `'hex'` (the default) or `'base64url'`. |
| `adorably.base64url(data)` and `adorably.base64urlDecode(data)` | base64url without padding, of UTF-8 text, and back. |
| `adorably.jwt(payload, key, { expiresIn })` | An HS256 JSON Web Token. Adds `iat` unless the payload has one; with `expiresIn` (seconds) sets `exp`. |
| `adorably.code(length, alphabet)` | A random code from cryptographic randomness. 12 characters by default, from upper-case letters and digits without I, O, 0 or 1. |
| `adorably.allowedUrl(url)` | `true` when the run may return `url`: https, on one of your approved hosts, no user or password, at most 1024 characters. Check every URL your API gives you with it before you return it. |
| `adorably.remainingMs()` | The ms left before the run's time limit, by the clock. The clock moves while fetches wait, not while your code runs, so read it after a fetch to decide whether another one fits. |
| `adorably.fallback(reason)` | Ends the run now. The developer gets your plain landing URL, and the result is error `fallback` with your reason. It cannot be caught. |

Each helper throws a `TypeError` that starts with `adorably.<name>:` when an argument is wrong. The doc comments in `handler-runtime.d.ts` have every detail.

### Type-checking a handler

`handler-runtime.d.ts` declares the globals a handler has, so type-check with only those, not the DOM's (with the DOM, TypeScript stops at `Cannot redeclare block-scoped variable 'console'`). Next to the `.d.ts` and your handler, a `tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022"],
    "types": [],
    "strict": true,
    "allowJs": true,
    "checkJs": true,
    "noEmit": true
  },
  "files": ["handler-runtime.d.ts", "handler.js"]
}
```

Type the handler with JSDoc, `/** @param {HandlerInput} input @returns {Promise<HandlerOutput>} */` above `async function handle(input)`, and run `npx tsc -p .`.

## Limits

| Limit | Value |
| --- | --- |
| Time per run | 2000 ms, your code, helpers and fetches together |
| Memory | 16 MiB |
| Source | 32 KiB |
| Fetches per run | 3 |
| Time per fetch | 1500 ms |
| Response body per fetch | 64 KiB (65536 bytes) |
| Returned `url` | 1024 characters |
| `cta_label` | 24 characters |
| Secrets | at most 20, names like `API_KEY` (upper case, digits and `_`), values at most 4096 characters |
| `adorably.code` | length 1 to 1024; at most 65536 random bytes per run |
| `adorably.fallback` reason | at most 200 characters |
| Test-run `logs` | at most 50 lines of at most 500 characters |
| Test-run `fetch_log` | at most 50 entries |
| `fetch_mocks` | at most 10, a `delay_ms` of at most 10000 |
| Handler runs | 60 an hour per seller, `test_claim_handler` and the test runs of `set_claim_handler` together |

These are the current values; Adorably can change the time, memory, source and fetch limits, and the `adorably://claim-handler` resource always shows the live ones. Only the developer waits on your handler, so aim well under the limits.

## Debugging with test_claim_handler

`test_claim_handler` runs a handler of one of your offers once with `debug` on, in the same sandbox and with the same checks on its answer as `set_claim_handler` and real claims, and saves nothing. Its arguments:

| Argument | What it is |
| --- | --- |
| `offer_id` | Your offer. |
| `source` | The handler to run. |
| `secrets` | The secrets for this run only; they are not stored. |
| `test_email` | The input's email, default `test@adorably.io`. |
| `claim_id` | The input's claim id, like `clm_...`, default `clm_test`. |
| `fetch_mocks` | Canned answers for this run's fetches (below). |
| `explain` | `{ tags, competitor? }`: run the source's `explain` for those stack tags instead of `handle` (see Sponsored explanations). Model calls are real and billed at cost. |

Left out, `source` is the saved handler and `secrets` the saved secrets, so `test_claim_handler` with only `offer_id` runs what claims run now.

Each of `fetch_mocks` is an object: `match` (required, the start of an https URL), and optional `method` (GET, POST, PUT or PATCH), `status` (200 to 599, default 200), `headers`, `body` (a string), `delay_ms` and `network_error`. A fetch whose URL starts with `match` (and whose method is `method`, when given) gets that answer from the first mock that matches, after `delay_ms`; `network_error: true` makes it reject with `fetch failed`, and a `delay_ms` past the per-fetch limit makes it reject with `fetch timed out`. Mocked fetches pass the same checks as real ones (approved hosts, the fetch limit) and show in `fetch_log`; a fetch no mock matches goes to your API. For example `[{ "match": "https://api.example.com/v1/perks", "status": 503, "body": "down" }]` tests your fallback. Claims never use mocks, and `set_claim_handler` does not take them.

Its text starts with one line, `Passed in <ms> ms with <n> fetches: the developer would open <url>`, then the label if any, then `Nothing was saved.`, or `Failed (<error>) after <ms> ms: <message>. A claim would land on the plain landing_url. Nothing was saved.` After it come the requests (`METHOD url -> status, ms`) and the console lines. Its data is the result. A result looks like this:

```json
{
  "ok": false,
  "error": "fallback",
  "message": "perks API failed: perks API answered 503",
  "ms": 431,
  "defines": { "handle": true, "explain": false },
  "logs": [
    "[warn] attempt 1 perks API answered 503",
    "[warn] attempt 2 perks API answered 503",
    "[warn] attempt 3 perks API answered 503"
  ],
  "fetch_log": [
    { "method": "POST", "url": "https://api.example.com/v1/perks", "status": 503, "ms": 140 },
    { "method": "POST", "url": "https://api.example.com/v1/perks", "status": 503, "ms": 133 },
    { "method": "POST", "url": "https://api.example.com/v1/perks", "status": 503, "ms": 151 }
  ]
}
```

That is the defensive example against an API that always answers 503.

A passing run has `ok` set to `true`, `url`, `cta_label` when you returned one, `fetches` and `ms`, and the same `logs` and `fetch_log`.

- `defines`: which functions your source defines, `handle` and `explain`.
- `logs`: one line per `console` call, `[level]` and the arguments: strings as they are, errors as `Name: message`, other values as JSON.
- `fetch_log`: every fetch, in order: `method` (`?` when it is not an HTTP method), `url`, `status` (`null` when no response arrived), `ms`, and `error` with the rejection your code saw. A fetch still running when the run ended says `still running when the run ended`.
- In the whole answer (the message, the returned `url`, `logs` and `fetch_log`), your secret values and the email are replaced by `[secret]` and `[email]` when they appear plain or URL-encoded. A query value equal to a secret shows as `[secret]`. A value your code changed (upper case, a slice, base64) is not caught.

Claims never collect logs: in production `console` does nothing and neither field exists. Log freely while testing; nothing reaches Adorably's storage.

Iterate like this: run, read `error` and `message` first, then `fetch_log` (did the request go out, what status came back), then `logs`. Test the failure paths too, with `fetch_mocks`: your API answering 500, answering slowly (`delay_ms`), failing (`network_error`), answering with a body that is not JSON, or with a URL off your domains. Check that each one falls back cleanly.

Each seller has 60 handler runs an hour, `test_claim_handler` and the test runs of `set_claim_handler` together. Past that, both answer `rate_limited` with the time the next run is possible, and run nothing. Removing a handler does not count.

## Errors and how to fix them

A failed run has `ok: false`, an `error` and a `message` (at most 200 characters, the email replaced by `[email]`). In claims the developer gets your plain landing URL; `list_claims` shows the status and `get_analytics` the last error as a fixed text per status (the message itself shows only in test runs, because a handler's own text could carry the email in a form no redaction catches).

### compile

The source did not parse, or it defines no function named `handle`, or it is larger than 32 KiB. The message has the syntax error. Fix: plain JavaScript (no TypeScript syntax, no `import` or `export`), and a top-level `async function handle(input)`.

### exception

`handle` threw, or its promise rejected. The message is the error's message. Common causes: `await res.json()` on a body that is not JSON; reading a field of `undefined`; a fetch rejection you did not catch; an `adorably.*` helper given a wrong argument (message `adorably.url: base must be an absolute https:// URL`). Fix: check responses before you use them, and turn expected failures into `adorably.fallback` with a clear reason.

### timeout

The run took longer than 2000 ms, or `handle` waited for something that can never finish (`handle never settled`). Fix: fewer or faster fetches, no long loops or backtracking regexes, and no retry once a fetch has timed out. A fetch that hits its own 1500 ms limit (or your `timeoutMs`) rejects with `fetch timed out`, which you can catch; the run's limit cannot be caught. So give up on a slow API and fall back while there is time:

```js
const body = JSON.stringify({ claim_id: input.claim_id, email: input.email })
const call = () => fetch('https://api.example.com/v1/perks', { method: 'POST', body, timeoutMs: 800 })
let res
try {
  res = await call()
  // One more try for a 5xx, only while a whole try still fits.
  if (res.status >= 500 && adorably.remainingMs() > 900) res = await call()
} catch (e) {
  // 'fetch timed out' or 'fetch failed': another try would not finish in time.
  adorably.fallback('perks API: ' + e.message)
}
if (!res.ok) adorably.fallback('perks API answered ' + res.status)
```

A test run can also fail with `the sandbox stopped the run`: one call of a built-in that the time limit cannot interrupt (an `indexOf` or `split` over a string of megabytes) used more CPU than a run may. Work on less data.

### memory

The run used more than 16 MiB. Fix: do not build large strings or arrays; ask your API for less.

### invalid_output

`handle` returned something Adorably will not use. The message says what:

- `handle must return an object with a url`, or `url must be a string`: return `{ url }`.
- `url must be https on one of your approved hosts`: what a test run says for any of the next two cases.
- `url is not https`, `url has userinfo`, `url does not parse`, `url has control characters`, `url too long`: build links with `adorably.url`, keep them under 1024 characters.
- `url host is not on an approved host`: link to a host of an approved offer's landing page, or check what your API returned with `adorably.allowedUrl` before you return it.
- `cta_label must be a string`, or what is wrong with the label (`must not be blank`, `is 28 chars, limit is 24`, `must not contain a URL or domain`, `is in a prohibited category`): plain text, at most 24 characters, no URL or domain (`Node.js` counts as one). A claim drops a label that fails and shows the default one; a test run (`test_claim_handler`, and the test in `set_claim_handler`) fails instead, so you see it.

### fallback

Your code called `adorably.fallback(reason)`. This is the right ending when there is nothing better to offer: your API is down, refused the request, or answered with something you cannot use. The message is your reason. It ends the run even inside a `try`/`catch`, and later fetches reject with `the run already fell back`. In analytics it counts as a handler failure.

### unavailable

Not a sandbox error: `list_claims` shows `unavailable` when Adorably could not run the handler to the end (the sandbox did not answer in time, stopped the run for using more CPU than a run may, or your saved secrets could not be read). If `test_claim_handler` answers `the sandbox stopped the run`, fix that first (see timeout); otherwise there is nothing to fix in your code, and if it persists, write to hello@adorably.io.

### Fetch rejections

`fetch` rejects with an `Error` whose message is one of these:

| Message | Cause and fix |
| --- | --- |
| `host not allowed` | Not https, or the host is not one of your approved hosts. Fix the URL, or call an API on an approved host (a subdomain of your landing page's host works). |
| `method not allowed` | Only GET, POST, PUT and PATCH. |
| `too many fetches` | More than 3 fetches in one run. Combine calls on your side. |
| `fetch timed out` | No response within 1500 ms. Make the endpoint faster; do not retry. |
| `response too large` | The body is over 65536 bytes. Return less. |
| `fetch failed` | A network error, or a header the Fetch API refuses. |
| `the run already fell back` | A fetch after `adorably.fallback`. |

### When set_claim_handler refuses

`set_claim_handler` saves only after its test passes; a failed test answers `Not saved: the test run failed (<error>: <message>)` with the requests and console lines, and keeps the previous handler. Past the hourly budget of handler runs it answers `rate_limited` and runs nothing. It also needs `privacy_url` (kept from the offer when left out): https, on the offer's landing page host or another approved host, answering 200. Leaving out `secrets` keeps the saved ones; `source: ""` removes the handler.

## Security

### Secrets

- Never put a secret in the returned URL. The developer's browser, history and every log on the way see it. Sign with it instead (`adorably.hmac`, `adorably.jwt`) and verify on your backend.
- Do not log secrets. Test-run answers redact their exact values, plain or URL-encoded, but not a value you transformed (upper case, a slice, a base64 of it).
- Saved secrets are readable by anyone who can run handlers for your seller account (every user and agent token of it): a handler can always return or log a secret in a form no redaction catches. Keep handler keys scoped so that this is acceptable, and rotate them when someone leaves.
- Give the handler its own API key, scoped to what the handler does (create a trial, mint a code), so you can rotate it alone. To rotate, call `set_claim_handler` with the full `secrets` object again: it replaces all saved secrets.

### Signed links

A link your backend acts on (creates an account, applies a perk) must be signed, or anyone can forge one. Two ways:

- A JWT (`adorably.jwt`, HS256, with `expiresIn`): verify it with any JWT library, pinning the algorithm to HS256. See the signed-jwt-link example.
- An HMAC over the query (`adorably.hmac`): the hmac-query example signs `claim + "\n" + email + "\n" + ts` with `LINK_SECRET`, hex. Your backend checks it like this.

#### Verify it in Node

```js
import { createHmac, timingSafeEqual } from 'node:crypto'

const MAX_AGE_S = 15 * 60

// query: the link's parameters (claim, email, ts, sig). Returns { claim, email } or null.
export function verifyAdorablyLink(query, secret) {
  const { claim, email, ts, sig } = query
  if (![claim, email, ts, sig].every((v) => typeof v === 'string') || !/^\d+$/.test(ts)) return null
  const age = Math.floor(Date.now() / 1000) - Number(ts)
  if (age < -60 || age > MAX_AGE_S) return null
  const want = createHmac('sha256', secret).update(`${claim}\n${email}\n${ts}`).digest()
  const got = Buffer.from(sig, 'hex')
  if (got.length !== want.length || !timingSafeEqual(got, want)) return null
  return { claim, email }
}
```

#### Verify it in Python

```python
import hashlib
import hmac
import time

MAX_AGE_S = 15 * 60

def verify_adorably_link(query, secret):
    """query: the link's parameters (claim, email, ts, sig). Returns (claim, email) or None."""
    claim, email, ts, sig = (query.get(k) for k in ("claim", "email", "ts", "sig"))
    if not all(isinstance(v, str) for v in (claim, email, ts, sig)) or not ts.isdigit():
        return None
    age = int(time.time()) - int(ts)
    if age < -60 or age > MAX_AGE_S:
        return None
    want = hmac.new(secret.encode(), f"{claim}\n{email}\n{ts}".encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(want, sig):
        return None
    return claim, email
```

Both reject a missing field, a stale or future timestamp and a wrong signature, and compare in constant time. Then make the link single-use on your side: record the claim id and refuse it the second time, or better, treat a second visit as "sign in" rather than "create".

### The developer's email

- The developer chose to share it for this claim, after seeing your `privacy_url`. Your privacy policy governs it from then on. Use it only to give the developer the perk and run the account they asked for; do not add it to marketing lists, and honor requests to stop or delete. The seller terms (https://adorably.io/seller-terms) say the same.
- Adorably passes it to your handler once and stores only that it was shared and when (`email_shared` in `list_claims`), never the address.
- Prefer sending it to your API in a POST body over putting it in a URL: URLs end up in logs. If the returned link carries it (to prefill a form), sign the link.
- Do not trust it as verified. It is what the developer typed; confirm it the way your signup does.

### Idempotency

Your handler can act more than once for what is, to you, the same person, so make what it does safe to repeat:

- Adorably runs the handler once per new claim; a repeat claim from the same install gets the plain link. But a developer with two machines can claim twice, with different claim ids and the same email.
- If your API acts but answers slowly, the run can time out after your side created the account; the developer then gets your landing page and may sign up by hand with the same email.
- Every test run uses the claim id `clm_test` unless you pass `claim_id`.
- The developer can open the returned link more than once.

So: send the claim id as an idempotency key (`idempotency-key: input.claim_id`) and return the same result for the same key; find-or-create accounts by email; treat a run with `input.test` as a test (and pass a fresh `claim_id` per test run when your API caches by key); and make a link that is used twice sign the developer in instead of failing.

## Examples

Each one runs in Adorably's test suite against the real sandbox. They are at https://adorably.io/docs/claim-handlers/examples.md (all in one file), and one file each next to https://adorably.io/docs/claim-handlers/examples/README.md (for example https://adorably.io/docs/claim-handlers/examples/defensive.js).

| Example | What it shows |
| --- | --- |
| `signup-link.js` | A signup link with the email filled in, through `adorably.url`. No API call. |
| `trial-magic-link.js` | Create a trial through your API with `SECRETS.API_KEY`, return its magic link. |
| `signed-jwt-link.js` | A signed link that expires, through `adorably.jwt`. |
| `hmac-query.js` | An HMAC-signed query (`adorably.hmac`) your backend verifies with the code above. |
| `promo-code.js` | A per-developer promo code from your API, `adorably.fallback` when the API fails. |
| `cta-label.js` | Choosing the button label from what your API knows. |
| `defensive.js` | Retries within the limits, response checks and a clean fallback. |
| `explain-competitor.js` | A sponsored explanation naming the competitor through `adorably.model.complete`, with a second check before it shows. |
| `explain-agent-loop.js` | A streamed tool-call loop over `fetch` against the model API, with one tool over the brief. |

## Sponsored explanations

When a developer presses Explain on your perk, Adorably runs `explain(input)` from the same handler source as `handle(input)` and shows its answer as a message from you. A source may define either function or both; a bundle can set `globalThis.explain`. The Explain button needs a live explanation brief (`set_explanation_brief`, reviewed by the founder); until it is live, `explain` runs only in your test runs.

### Input and output

`explain` gets `{ offer, tags, competitor, brief, test }`:

- `tags`: the stack tags the developer sent. Only your brief's target tags that their project has, like `has:vercel`, ever leave their machine, and only when they press Explain.
- `competitor`: the first competitor in your brief whose tag is in `tags` (like `Vercel`), or `null`.
- `brief`: your brief, `{ facts: [{ kind, text, source }], target_tags, competitors, model? }`; `null` in a test run of an offer without one.
- `test`: `true` in test runs.

It returns `{ message, sources, cta_label? }` to show a message, or `null` to show nothing. Read the model's answer before you return it: you decide whether it is shown. The developer sees it in a frame headed "From <your company> (sponsored)" with your landing page's host, the sources, your claim button (`cta_label`, or the usual label), "Not useful" and "Report", and "Delivered by Adorably. <your company> chose to show you this."

Before anyone sees it, Adorably checks it:

- `message`: 1 to 1200 characters; no URL or domain in the text (sources go in `sources`; `Next.js` and other `.js` names are fine); no control or invisible characters; no prohibited category; no disparaging words ("scam", "garbage", "terrible", "broken" and the like); never calling itself neutral, unbiased or objective.
- `sources`: 1 to 5 https URLs, each on your approved hosts or listed in your brief.
- `cta_label`: the same rules as a claim handler's.
- Then a model check: every factual claim is in your brief's facts, nothing about the competitor beyond them, no superlative a fact does not state.

A message that fails is not shown, and the developer sees the normal perk. Returning `null` or failing a check still bills the run's inference. Without an `explain` of your own, Adorably's default writes the message from your brief, and writes it once more when the model check refuses the first one; your own `explain` runs once. The same tag set is answered from a cache for a day, so the same stack does not pay twice.

### The model API

Inside the sandbox your code reaches an OpenRouter-compatible API at `https://openrouter.adorably.local/api/v1`:

- `POST /chat/completions`: OpenAI chat format, passed to the provider's own OpenAI-compatible endpoint as you send it (Anthropic's, for Claude), with `stream: true` as server-sent events. Every answer has `usage`, with `cost` (USD) added. A request without `model` uses Claude Haiku 5.5. Adorably changes only this: its rules come first, `max_tokens` is capped at 1024, and thinking is off unless you send `reasoning` (`reasoning.effort` goes on as `reasoning_effort`), so a small `max_tokens` still returns text. Provider errors come back with the provider's status and body.
- `GET /models`: the models you can use, with prices per token.

Reach it with `fetch`, `XMLHttpRequest`, `adorably.model.complete({ messages, model?, max_tokens?, tools? })` (one call, resolving to `{ text, tool_calls, finish_reason, usage }`), or any OpenAI- or OpenRouter-compatible client bundled into your handler with that base URL and any API key (it is ignored). There is no key to manage: the address exists only inside the sandbox, and Adorably makes the real call with its own key. Adorably's fixed rules (your voice, use only the facts given, cite them, no disparagement, never call the message neutral) come first in every call's system prompt, ahead of yours.

Errors come back in OpenRouter's shape, `{ "error": { "code", "message" } }`; fall back by returning `null`:

| Status | Message | What to do |
| --- | --- | --- |
| 400 | `model <id> is not available; GET /models lists the models you can use` | Use a model `GET /models` lists. |
| 400 | `about <n> input tokens; the limit is 16000` | Send less: fewer facts or a shorter history. |
| 402 | `the seller's balance or budget does not cover this call` | Add funds; return `null`. |
| 429 | `model call limit of 8 per run reached` | End the loop sooner. |
| 429 | `the offer's daily cap is reached` | Return `null`; it resets within a day. |
| 503 | `no provider for <id>` | Use another model, or return `null`. |

### Limits and billing

An explain run has 10000 ms of its own time (the clock stops while a model request or stream read is pending), a 45000 ms wall clock that counts the waits, 32 MiB of memory, a 1024 KiB source, 5 fetches to your approved hosts, and 8 model calls of at most 16000 input and 1024 output tokens each. Explain runs also get timers, `TextEncoder`/`TextDecoder`, `URL`, `Headers`, `AbortController`, `ReadableStream`, `performance.now`, `structuredClone` and `crypto.randomUUID`, so clients run unchanged (`handler-runtime.d.ts` lists them); claim runs have none of them. At most 100 timers may be pending at once: one more fails the run. Each timer also counts half a millisecond against the run's own time.

Each model call is billed from your balance, inside the offer's daily cap and total budget: at cost plus 30% in live runs, at cost in test runs, and each run rounded up to a whole cent. Before a call is sent, its worst case (the request's input estimate and `max_tokens`) plus 25% must fit the balance, cap and budget, or the call is a 402 or 429; the call is then charged what it used, at most that. A typical Claude Haiku explanation costs one cent.

A source that defines `explain` may be up to 1024 KiB, and its claim runs get that size and the explain memory: a large bundle slows every claim, so keep `handle` in a small source if you can.

### Bundling a client or an agent harness

Write your handler as an ES module that sets `globalThis.explain`, then bundle it into one script:

```sh
npx esbuild explain.mjs --bundle --format=iife --platform=neutral --target=es2022 --main-fields=module,main --minify --outfile=explain.js
```

The OpenAI SDK (`new OpenAI({ baseURL: 'https://openrouter.adorably.local/api/v1', apiKey: 'unused' })`) runs unchanged: completions, streaming and tool-call loops. The Pi agent harness runs too, minified (about 600 KiB), with `@earendil-works/pi-ai` imported by `await import(...)` inside `explain`.

### Testing

`test_claim_handler` with `explain: { tags, competitor? }` runs your `explain` (or, with no source and no saved handler, the default one on your brief) against the real models, and answers whether the message would be shown, each model call (tokens, bytes sent, cost, ms), the request log and the console lines. `set_claim_handler` runs the same test when the source defines `explain`, and saves only when it passes.

Two examples run in Adorably's test suite: `explain-competitor.js` (names the competitor, asks a cheap second question, shows nothing when the answer is unfavorable) and `explain-agent-loop.js` (a streamed tool-call loop over `fetch`).

## Skills for your coding agent

Two Claude Code skills carry this guide, the types and the examples, so your agent loads them when it works on Adorably:

- `adorably-claim-handlers`: writing, testing and saving a claim handler.
- `adorably-seller`: the whole seller flow, from sign-in to tracking claims.

Install them into your project (or into `~/.claude/skills/` for every project):

```sh
for f in adorably-claim-handlers/SKILL.md adorably-claim-handlers/references/guide.md \
  adorably-claim-handlers/references/handler-runtime.d.ts adorably-claim-handlers/references/examples.md \
  adorably-seller/SKILL.md; do
  curl -fsSL --create-dirs -o ".claude/skills/$f" "https://adorably.io/skills/$f"
done
```

The seller MCP server also serves them as the `adorably://skills/adorably-claim-handlers` and `adorably://skills/adorably-seller` resources.
