Writing a recipe
A recipe turns a booking job into a firm quote, a fare menu, or a paid ticket on one supplier's website — or, since protocol 2, a product on a merchant's website into an exact checkout total and a placed order. Here is the path from zero to an activated domain.
1 — Start from the contract
Clone recipe-spec.
It contains the normative README, the SDK (sdk/index.mjs — zero
dependencies), a complete toy recipe (example.com/recipe.mjs) that
speaks every signal, the JSON Schemas, and the offline conformance suite:
git clone https://github.com/brij-digital/recipe-spec
cd recipe-spec && node test-sdk.mjs # the corpus your recipe must satisfy
2 — The shape of a recipe
One ES module. Its job arrives as one JSON document in
RECIPE_INPUT — {schema, task, data} — and it talks
back only through stdout signals. Three tasks, selected by TASK,
which must match the document's own task:
| Task | Input document (data) | Output signal |
|---|---|---|
search | air-search.v1: origin_iata, destination_iata, depart_date (+ return_date, only) | emitResult("search", {route, count, offers}) |
offer-details | air-offer-details.v1: the same route/dates + flight (the offer id) | emitResult("offer-details", {base, fares}) — each fare: price, currency, and the supplier's own condition wording |
book | air-book.v1: route/dates, flight, passengers[], contact_email (+ fare_index, fare_price, contact_phone) | emitResult("book", {payClicked, paymentStatus, reference…}) |
There is no per-field fallback and no purchase mode. A
missing document is a refusal, never a default — a recipe that defaults its
own passenger or price ceiling is deciding what to buy. Dates are ISO and
IATA codes upper-case; convert to your supplier's dialect at your own door.
Every price you report must carry currency: "USD": the
marketplace settles in dollars and converts nothing, and a price with no
currency is refused as loudly as a wrong one. Whether Pay is reachable is not
something you decide: your book walks to the cashier and stops before Pay,
and the runtime's payer does the rest.
The SDK stamps every signal with {v: 1, task} and refuses to
emit a malformed one (exit 7). The runtime revalidates everything
server-side — honesty is structural, not optional.
3 — The rules that matter
- Never buy what wasn't asked — refuse a checkout carrying add-ons the order didn't request. Refusing is free.
- Never book an unverified price — report the checkout total you actually read, where it is exact (the cashier), not a rounded list price. No ceiling is passed to you: the marketplace compares your total against what the customer engaged, at the approval gate, before the card is touched. A total you could not read is a refusal — a gate cannot judge a number it never received.
- payClicked is sacred: emit
payClicked:trueonly if the Pay control was actually activated; emitpaymentStatus:"paid"only on a structural success signal. This is what separates a clean refund from a frozen order. - Exit codes are your error taxonomy (1 bad input · 2 captcha · 3 offer gone · 4 checkout · 5 return leg · 6 passenger rejected · 7 malformed): they feed your public stats, so bail precisely.
- No subprocesses and no runtime-built code — the static gate rejects them by pattern (it rejected our own first submission). Reaching hosts outside your domain is a rule of the contract that nothing automatically blocks: there is no egress allowlist. What stands behind it is Cloudflare Sandbox isolation, one ephemeral sandbox per attempt, and the fact that your code runs at a commit a human reviewed and pinned. A recipe never receives the card, on any path: a real booking is your walk to the cashier with a synthetic card, then the runtime's own payer (image code, no author file) takes the session over and pays.
Protocol 2 — shop recipes (buy a product)
A recipe can also sell a merchant's products: an agent
names a product by its URL, your recipe reads the exact checkout total, and
the runtime's payer buys it. It speaks protocol_version: 2 and two
tasks that every vertical shares:
| Task | Input document (data) | Output signal (v: 2) |
|---|---|---|
quote | quote.v1: product, item {ref, selections, quantity}, context.ship_to {country, region, postal_code} | status priced (item, breakdown adding up to merchant_total, currency USD, service_ends_at, requires) · options_required (menu) · unavailable (reason) |
buy | buy.v1: product, item, engaged.merchant_total, fulfilment {recipient, "address.shipping"}, contact_email | the book fields + cashier {merchant_total, currency, lines[], ship_to_postal_code} whenever payReachable |
- Read the price at the cashier, with the ship-to ZIP: shipping and tax are invisible on a product page. A quote must be for the quantity and choices asked, or it is refused.
- Your buy walks, it never pays. Type the address, reach
the checkout, report the
cashieryou see, stop. The fulfiller starts the payer only if that cashier IS the order (one line, the quantity, the ZIP, USD, no higher than quoted). - The manifest adds
product: shop,capabilities: {quote, buy},requires: [recipient, address.shipping],cashier: shopify(a payer adapter — Shopify first),flow: guest,oracle: {type: email}— no email patterns needed: the marketplace derives them from the first real confirmation (you MAY declareconfirmed_pattern/reference_pattern/cancelled_subject_pattern, all three or none, plus asampleto test them on) — and 3–5conformance.productsthe dry run draws from. - What settles: the merchant's DKIM-signed confirmation naming the order number the payer read. Shipping emails are status only.
Start from shop.example.com/ in recipe-spec — it runs offline
and uses the SDK's shopify.* helpers (variants, cart permalink,
checkout reading). Full contract: README §"Protocol v2".
Protocol 2 — rail recipes (train tickets)
A rail recipe adds a discover task in front of the shared
quote and buy: rail-search.v1 gives
origin and destination as a traveller types them, a date, an optional
earliest departure and the party size; you answer
emitResult("discover", {count, items: [{ref, title, price_from,
currency: "USD", summary}]}). A quote receives one item's
ref plus context.search and
context.adults so you can find the journey again and read the
checkout total; a buy receives fulfilment.person[] (given,
surname, dob), fills the travellers, and stops at the checkout reporting its
cashier (no ZIP for a train). Manifest: product: rail,
capabilities: {discover: {input_schema: rail-search.v1}, quote, buy},
requires: [person], cashier: trainline,
oracle: {type: email}, and 3–5 conformance.routes
({origin, destination, country?}) the dry run draws from — pick routes with daily
service; country (FR, GB…) walks that route from a residential
exit in that country, which anti-bot walls (DataDome) judge far less harshly
than a foreign one. Start from rail.example.com/ in recipe-spec.
4 — The manifest
protocol_version: 1
domain: yoursupplier.com
recipe: recipe.mjs
flow: guest # guest · ephemeral-account · account
kind: browser # or api (a connector: same signals, no browser)
oracle:
type: email
# Settlement authority is ALWAYS your recipe's own domain, derived from
# `domain` — never declare it.
# Optional patterns: supply them if you have a real confirmation email
# in hand; OMIT ALL THREE for bootstrap mode — the first real purchase
# delivers the genuine email to the marketplace's own order address,
# the patterns get written against it, and the booking settles
# retroactively inside its hold. No sample hunting required.
issued_pattern: '(?i)tickets?\s+(?:have|has)\s+been\s+issued'
pnr_pattern: 'Booking\s+Reference\s*:\s*([A-Z0-9]{5,8})' # group 1 = PNR
cancelled_subject_pattern: '(?i)\b(cancel(?:led|ed)?|refund(?:ed)?)\b'
capabilities: # each task references a versioned input contract
search: {input_schema: air-search.v1}
offer-details: {input_schema: air-offer-details.v1}
book: {input_schema: air-book.v1, accepts: [offer_ref]}
coverage:
regions: [EU]
cabins: [Y]
trip_types: [ow, rt]
payment:
currency: USD # the marketplace settles in USD and converts nothing
max_amount: 150
author:
partner: your-name # a label
wallet: <the wallet you submit from> # REQUIRED: your identity, and what
# lets you read this recipe back
primary: true
The manifest must live in a folder named after its
domain — the folder is the identity, and two folders cannot
claim one domain. Do not declare budgets,
proxy_country, fares or
oracle.sender_domains: the marketplace derives or ignores all
four, and declaring them can only get you rejected.
5 — Test locally
node test-sdk.mjs # contract conformance, offline
# One JSON document per task, in RECIPE_INPUT. There is no per-field fallback.
TASK=search RECIPE_INPUT='{"schema":"air-search.v1","task":"search","data":{
"origin_iata":"MAD","destination_iata":"BCN","depart_date":"2026-12-15"}}' node yourdomain/recipe.mjs
TASK=book RECIPE_INPUT='{"schema":"air-book.v1","task":"book","data":{
"origin_iata":"MAD","destination_iata":"BCN","depart_date":"2026-12-15","flight":"<id from search>",
"passengers":[{"given":"Jean","surname":"Martin","dob":"1979-10-25"}],
"contact_email":"[email protected]"}}' node yourdomain/recipe.mjs
# No mode is passed. Without CARD_* and APPROVE_SIGNAL_FILE the run walks the
# whole flow with a synthetic card and stops before Pay — that is a conformance
# run, and it is what every submission gets.
6 — Read back what you own
Your wallet is your identity, and author.wallet in a pinned
manifest is what makes a recipe yours. It must be the wallet that submitted
it — a recipe cannot be attributed to a wallet that never paid for it. Read
your own source back to build an update:
GET /recipes/session-challenge?wallet=<you> # the exact bytes to sign
POST /recipes/session {wallet,message,signature} # → bearer token, 24h
GET /recipes/source/<domain> # → recipe_js + manifest_yaml + recipes_sha
Free. Sign the challenge verbatim: the server compares the canonical message, not a substring of it. Ownership is checked on every read against the manifest as it stands, so it is never stale. Another wallet gets 403, and a domain naming no wallet is owned by nobody.
6½ — Try one task (0.01 USDC)
POST /recipes/try — x402-paid, same price as a submission.
It runs one task (discover, quote
or buy) of a protocol-2 recipe you have not submitted, on the
input you choose, in the same sandbox and under the same
profile as the dry run: no card, no account, a buy stops at the cashier.
About a minute instead of a whole walk on a route the gate draws.
POST /recipes/try {author_wallet, domain, manifest_yaml, recipe_js,
task, input, country?} # → 202 {try_id}
GET /recipes/try/{try_id} # author session; status running|done|infra_error|abandoned
GET /recipes/try/{try_id}/evidence/{name} # screenshots, case.html.gz, case.payloads.json.gz
GET /recipes/try/{try_id}/network # every request the page made
input is the task's data document (rail-search.v1,
quote.v1, buy.v1) without the envelope; unknown
fields are refused, and every refusal decidable without a browser comes
before the payment. A try has no verdict and activates nothing.
try, submit, status,
evidence, network).7 — Submit (0.01 USDC)
POST /recipes/submissions — x402-paid. The paying wallet is
your durable identity: payout, stake and reputation hang off it.
dryrun-diagnosis findings — the line, the problem,
the fix and the log line or page text that proves it, read by a model from
the evidence, the page at the moment of failure and your numbered source.
Apply them, resubmit with supersedes, repeat: no human needs to
relay anything.curl -X POST https://travel.brij.fi/recipes/submissions \
-d '{"author_wallet":"…","domain":"yoursupplier.com",
"manifest_yaml":"…","recipe_js":"…"}'
# → {submission_id, status, findings}; poll GET /recipes/submissions/{id}
The pipeline: static review → sandboxed dry-run against the live supplier
(runner-collected evidence, synthetic passenger, a dry run that clicks Pay is
refused unconditionally) → an AI judge comparing evidence, manifest and code.
The response is 200 whether queued or rejected — the findings are what the fee
buys. A walk that fails on the market or the supplier's wall — the offer
went away between quote and buy (exit 3), a captcha that outlived its retry
(exit 2), a timeout, no train on the drawn route — gets one second walk on a
fresh draw at no cost to you, and that verdict stands (finding
second-draw). Activation policy: findings-clean or nothing. A pass
with open block/warn findings is not activated —
fix and resubmit (supersedes links your versions);
note-severity findings are observations and gate nothing. Every
response carries next_step saying whose move it is.
Iterate on your own evidence:
GET /recipes/submissions/{id}/evidence returns per-task exit
codes, durations, transcript tails, the Browserbase session_id,
your emitPhase() timeline (phases +
failure_phase) and — on failing tasks — the screenshots the
runner harvested from the sandbox
(…/evidence/screenshots/{name}). When a walk hangs or runs slow,
…/evidence/network/{task} lists every request the task's browser
made — start, duration, type, host, path, status, error — plus the page's
DOMContentLoaded and load: where the time went, which
a screenshot cannot show. A page waiting for load waits on its
slowest tracker. Headers, bodies and URL queries are never included. The
daily canary serves the same for your live version at
GET /recipes/canary/{domain}/network/{task}. The submission id is the
capability: it is returned only to you.
7 — Live: watch yourself
GET /recipes shows your domain's settlement funnel
and run telemetry publicly; GET /partner/runs gives you your own
failures with diagnostics and replay ids. Infra failures are attributed to the
platform, never to your recipe. The best recipe holds the domain; a displaced
author keeps a residual finder's fee.
© 2026 Brij Digital · Terms · Privacy · [email protected]