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
- 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 (
whoamilists them; a landing page onwww.example.comcoversexample.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. - Read the runtime types: the
adorably://claim-handler.d.tsresource, also at https://adorably.io/docs/claim-handlers/handler-runtime.d.ts. They are the whole API. - Start from the closest example (the
adorably://claim-handler-examplesresource, or https://adorably.io/docs/claim-handlers/examples.md) and change it. - 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 addslogs(yourconsolelines) andfetch_log(every fetch, its status and time). - Read the result, fix, run again, until it returns the link you expect, including when your API fails:
fetch_mocksmakes a fetch answer 500, time out, fail or answer anything you like, without touching your API. - Save it with
set_claim_handler, with yourprivacy_url(it takesoffer_id,source,secrets,test_emailandclaim_idliketest_claim_handler, but notfetch_mocks). It runs the same test again, against your real API, and saves only when the test passes. - Watch real claims:
list_claimsshows each claim's handler status, andget_analyticscounts 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:
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 withset_claim_handler, by name, read-only. For exampleSECRETS.API_KEY. A name that is not set reads asundefined; 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.bodymust be a string (JSON.stringifyan object).timeoutMsgives 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 hasstatus,ok,headers,text()andjson(); the whole body is already read.headersis a plain object with lower-case names, not aHeaders:res.headers['x-request-id'], notres.headers.get(...).console.log,info,warn,erroranddebug: lines in test runs, nothing in claims.adorably: helpers for links and codes (below).- Standard JavaScript:
JSON,Math,Date,Promise,RegExp,Map, typed arrays,encodeURIComponentand the rest of the language, including??,?.,catchwithout a binding,.at()andObject.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:
{
"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:
{
"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,handleandexplain.logs: one line perconsolecall,[level]and the arguments: strings as they are, errors asName: message, other values as JSON.fetch_log: every fetch, in order:method(?when it is not an HTTP method),url,status(nullwhen no response arrived),ms, anderrorwith the rejection your code saw. A fetch still running when the run ended saysstill running when the run ended.- In the whole answer (the message, the returned
url,logsandfetch_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:
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, orurl 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 withadorably.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 withadorably.allowedUrlbefore 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.jscounts as one). A claim drops a label that fails and shows the default one; a test run (test_claim_handler, and the test inset_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_handlerwith the fullsecretsobject 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, withexpiresIn): 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 signsclaim + "\n" + email + "\n" + tswithLINK_SECRET, hex. Your backend checks it like this.
Verify it in Node
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
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_sharedinlist_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_testunless you passclaim_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, likehas:vercel, ever leave their machine, and only when they press Explain.competitor: the first competitor in your brief whose tag is intags(likeVercel), ornull.brief: your brief,{ facts: [{ kind, text, source }], target_tags, competitors, model? };nullin a test run of an offer without one.test:truein 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 insources;Next.jsand other.jsnames 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), withstream: trueas server-sent events. Every answer hasusage, withcost(USD) added. A request withoutmodeluses Claude Haiku 5.5. Adorably changes only this: its rules come first,max_tokensis capped at 1024, and thinking is off unless you sendreasoning(reasoning.effortgoes on asreasoning_effort), so a smallmax_tokensstill 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:
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):
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.
Downloads: this page as markdown, handler-runtime.d.ts, all examples, the claim handler skill and the seller skill.