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:

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

FieldWhat it is
input.offer.idYour offer's id, like off_01JBQZ8Y3S4T5V6W7X8Y9Z0ABC.
input.offer.vendorThe offer's vendor name.
input.offer.valueThe offer's value, like 3 months of Pro free.
input.offer.urlThe offer's plain landing URL.
input.claim_idThe claim's id, as list_claims shows it. clm_test in test runs, or the claim_id you passed.
input.emailThe 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_verifiedtrue in claims: the developer confirmed they own input.email with a code Adorably mailed to it. Absent in test runs.
input.testtrue in test runs (test_claim_handler, set_claim_handler), false in claims.

Output

handle resolves to an object. Other fields are ignored.

FieldRules
urlRequired. https, on one of your approved hosts (or a subdomain), no user or password, at most 1024 characters.
cta_labelOptional. 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

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

HelperWhat 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

LimitValue
Time per run2000 ms, your code, helpers and fetches together
Memory16 MiB
Source32 KiB
Fetches per run3
Time per fetch1500 ms
Response body per fetch64 KiB (65536 bytes)
Returned url1024 characters
cta_label24 characters
Secretsat most 20, names like API_KEY (upper case, digits and _), values at most 4096 characters
adorably.codelength 1 to 1024; at most 65536 random bytes per run
adorably.fallback reasonat most 200 characters
Test-run logsat most 50 lines of at most 500 characters
Test-run fetch_logat most 50 entries
fetch_mocksat most 10, a delay_ms of at most 10000
Handler runs60 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:

ArgumentWhat it is
offer_idYour offer.
sourceThe handler to run.
secretsThe secrets for this run only; they are not stored.
test_emailThe input's email, default test@adorably.io.
claim_idThe input's claim id, like clm_..., default clm_test.
fetch_mocksCanned 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.

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:

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:

MessageCause and fix
host not allowedNot 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 allowedOnly GET, POST, PUT and PATCH.
too many fetchesMore than 3 fetches in one run. Combine calls on your side.
fetch timed outNo response within 1500 ms. Make the endpoint faster; do not retry.
response too largeThe body is over 65536 bytes. Return less.
fetch failedA network error, or a header the Fetch API refuses.
the run already fell backA 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

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

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

Idempotency

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

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

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

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 }:

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:

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:

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:

StatusMessageWhat to do
400model <id> is not available; GET /models lists the models you can useUse a model GET /models lists.
400about <n> input tokens; the limit is 16000Send less: fewer facts or a shorter history.
402the seller's balance or budget does not cover this callAdd funds; return null.
429model call limit of 8 per run reachedEnd the loop sooner.
429the offer's daily cap is reachedReturn null; it resets within a day.
503no 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:

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.