Reference
Recipe format
The JSON a recipe is made of. Site, params, steps, extraction, login, writes, output and tests, with the exact semantics of the interpreter.
A recipe is one JSON file: recipes/<site-id>/<slug>.json. Its id is <site-id>/<slug>. Older versions stay next to it as <slug>.v<N>.json. The schema is https://wield.run/schema/recipe/v1.json, and the build rejects any file that doesn't match it.
A small example
{
"schema": "https://wield.run/schema/recipe/v1.json",
"id": "example.de/search-items",
"version": 1,
"site": {
"id": "example.de",
"name": "Example",
"origin": "https://www.example.de",
"origins": ["https://www.example.de", "https://api.example.de"],
"country": "DE",
"language": "de"
},
"task": "Search items by keyword and maximum price",
"description": "What comes back, and what is filtered (e.g. sponsored results are dropped).",
"keywords": ["suche", "artikel", "search", "items"],
"effect": "read",
"requires_login": false,
"params": {
"type": "object",
"properties": {
"query": { "type": "string", "minLength": 2, "maxLength": 100 },
"max_price": { "type": "integer", "minimum": 1 },
"limit": { "type": "integer", "default": 20, "maximum": 50 }
},
"required": ["query"],
"additionalProperties": false
},
"steps": [
{
"id": "search",
"request": {
"method": "GET",
"url": "https://api.example.de/v2/search",
"query": { "q": "{{ params.query }}", "price_to": "{{ params.max_price }}", "size": "{{ params.limit }}" }
},
"expect": { "status": [200] },
"extract": { "json": { "total": "meta.total", "items": "results[?!sponsored]" } }
}
],
"output": {
"schema": { "type": "object", "required": ["items"] },
"value": "{total: steps.search.total, items: steps.search.items[].{title: title, price_eur: price.amount, url: join('', ['https://www.example.de', path])}}"
},
"verify": {
"example_params": { "query": "fahrrad", "max_price": 300 },
"checks": ["length(output.items) > `0`", "output.items[0].price_eur <= `300`"]
}
}site
| Field | |||
|---|---|---|---|
id | Registrable domain, e.g. bahn.de | ||
name | Shown to users | ||
origin | https://host of the tab the recipe runs in. The extension uses a tab of this origin (yours, or a background one) | ||
start_path | Optional page on origin to open instead of /, for sites whose root redirects elsewhere. Must start with a single /. The extension, wield-dev and find_recipe's tab warm-up all open it | ||
locale_hosts | Exact language hosts the browser may land on instead of origin, e.g. ["en.zalando.de"] when English-first browsers are sent there. When the tab is on one, relative request URLs go to it, and page.host says which host it is, so a recipe can choose that host's paths and ids (`page.host == 'en.zalando.de' && 'catalogue' | 'katalog'). Only host names under the site's own domain; never wildcards. Use relative URLs for same-site requests so they follow. Recipes that read page need an extension that lists the page_context` capability | |
origins | The only origins this recipe may send requests to. Everything else is blocked, redirects included | ||
country, language | Two letters. Ranking prefers the user's country | ||
login_url | Where to send the user when login_required |
params
A JSON Schema (draft 2020-12) object. wield-mcp validates params before anything is sent and fills in defaults. Write a description for every property, with examples: agents read them.
steps
Steps run in order. Up to 12 per recipe. Each has an id ([a-z_][a-z0-9_]*) and a request:
| Field | |||
|---|---|---|---|
method | GET, POST, PUT, PATCH, DELETE | ||
url | Absolute, or relative to site.origin. Templates allowed | ||
query | Appended to the URL. null or missing values are dropped; arrays repeat the key | ||
headers | Cookie and Host are forbidden: the browser sends the site's cookies itself | ||
body | `{type: "json" \ | "form" \ | "text", value}`. Templates allowed in every string. The content type is set for you |
credentials | include (default) sends the site's cookies. omit is for cross-origin public APIs that reject credentialed requests |
Optional per step:
when: JMESPath over the context. A falsy result skips the step, andsteps.<id>is thennull.foreach:{in, as, max}runs the step once per item of the arrayin(JMESPath).maxdefaults to 10 and is at most 20.steps.<id>becomes an array with one{<as>: item, ...extracted}per iteration.write: true: the step changes data. Only allowed ineffect: "write"recipes; the user's Allow comes first.read_only_post: true: this non-GET step only reads, like a search API that takes POST. Every non-GET step needs either this orwrite: true, or the publish lint fails. The flag only counts for registry-signed recipes: in drafts, local and private recipes, every non-GET step needs the user's Allow.delay_ms: wait before the request (≤ 3000).parse:auto(by Content-Type),json,html,text, orturbo_stream.
Concurrency: a GET step starts as soon as the steps it references (steps.<id> anywhere in it, including when) are done. So two independent lookups run in parallel. Any other method, and every write, waits for all earlier steps, and later steps wait for it.
expect
Checked in this order: login_required_if, then status, then parsing, then assert.
status: allowed codes, default[200].content_type:json,htmlortext, if the header can't be trusted.assert: JMESPath over{body, status}; it must be truthy.login_required_if:{status?, assert?}. When it matches, the run fails withlogin_requiredinstead ofexpect_failed.
A response that looks like a bot manager's check instead of data becomes site_challenge. Recipes don't need to handle that:
- 403 or 429;
- a challenge page where JSON was expected;
- a small HTML page with a challenge marker.
extract
Pick what later steps and the output need. Several kinds can be combined:
json:{var: JMESPath over the parsed body}.html:{var: {selector, attr?, all?}}. Withoutattryou get the element's text, whitespace collapsed.allreturns every match.html_items:{var: {selector, max?, fields: {name: {selector, attr?}}}}. One object per matched element; a field selector""or:scopemeans the element itself.- JSON inside an element. Any
htmlspec orhtml_itemsfield can addparse: "json": the attribute (or, withoutattr, the element's text) is parsed as JSON, andpathpicks a value inside it with JMESPath. Shops often keep each tile's data in an attribute such asdata-grid-data='{"price":{"price":4.99}}'; then{"selector": ":scope", "attr": "data-grid-data", "parse": "json", "path": "price.price"}returns4.99as a number. Values are parsed withJSON.parseonly, never run, and anything over 256 KB or not valid JSON givesnull.pathwithoutparseis a lint error. Only an extension that lists thehtml_jsoncapability runs these recipes. With an older one, wield-mcp runs the recipe's last good version or asks the user to update. embedded_json: JSON inside the page.selector: e.g.script#__NEXT_DATA__orscript[type="application/ld+json"].attr: read the JSON from an attribute instead.strip_js: true: the first balanced{…}or[…]inside JavaScript.strip_js_after: "<text>": start looking only after this text. Use it for pages that assign several objects to one global, e.g.["results"] =. If the text is missing the step fails, or gives nulls withrequired: false. (Pending the next schema update.)decode: "astro_props": Astro islands.required: false: a missing element givesnullinstead of failing.vars:{var: JMESPath}.
Login and tokens
requires_login: true: the recipe needs the user's session.login_checkis a cheap step whoseexpectpasses only when logged in.statususes it, and runs check it first.auth.derive: values read in the site's tab before the steps, available asvars.<name>. Typical uses are a CSRF token or a bearer token the page keeps.name,from:cookie,local_storage,session_storage,meta,domorpage_url.keyorselector, andattr.jmespath(applied after JSON parsing).regex: the first capture group. Withdomand several matches, the first element whose text matches wins.required(default true; a missing required value meanslogin_required).
Derived values are secrets. They never appear in the output, events, logs or error messages; they're replaced by [redacted].
Writes
effect: "write" recipes need a confirm.summary: a template rendered over {params, steps, now}, such as "Send '{{ params.message }}' to {{ steps.listing.seller }}". Before the first write: true step, the user sees the summary and each write request (method, path and body) in an Allow window. Reads before the first write run as usual, so the summary can name what was found.
output
value: JMESPath over{params, steps, now, page}.varsare not visible here.nowis the run's start time as ISO-8601 with offset, for relative dates such as "today, 11:42".schema: JSON Schema of the result. Agents read it.
Return typed values (numbers, booleans, ISO dates, absolute URLs). Drop ads and sponsored results by default.
verify
example_params: realistic params.checks: JMESPath over{output}; each must be truthy.max_ms: optional time budget.
Tests, health checks and the first-run check use these.
Templates
{{ path | filter:arg | … }}. A path is dotted with [n] indexes: steps.search.items[0].id, params.date, vars.csrf, now, page.host.
page.host and page.origin name the host relative URLs go to. That is origin's host, or the listed language host the tab landed on (see locale_hosts). They are available in templates, when, foreach.in, output.value and confirm.summary.
- A string that is exactly one
{{…}}keeps the value's type:"{{ params.limit }}"gives the number 20. - In mixed strings a missing value renders as
"". In JSON bodies,undefinedmembers are dropped.
| Filter | |
|---|---|
urlencode, json, string, number | Conversions |
lower, upper, trim | Text |
default:<literal> | When missing or empty. Literals: 'text', numbers, true, false, null |
date:<format> | Tokens YYYY MM DD HH mm ss SSS, local time, and ZZ for the UTC offset (+01:00) |
add_days:<n> | On a date or datetime |
join:<sep>, first | Arrays |
For example, "{{ now | date:'YYYY-MM-DDTHH:mm:ssZZ' }}" gives the current time with its offset, as some APIs require.
JMESPath
Wield uses JMESPath Community Edition, which has let, arithmetic, from_items, zip and group_by. These traps catch most authors, and wield-dev validate warns about each:
- Literals need backticks: `
length(items) >0. Slices don't:items[:5]`. - Inside a filter
[? … ],params.xrefers to the element, not the context. Bind it first:let $max = params.max_price in items[?price <= $max]. The same holds inside&…inmap,sort_byand similar. !a.bparses as(!a).b. Write!(a.b).let $a = x, $b = $a in …fails: oneletbinds all its names at once. Nest them:let $a = x in let $b = $a in ….<,<=,>and>=compare only numbers.'2026-11-07' < '2026-11-08'isnull, so a filter on date strings matches nothing. Compare numbers, e.g.to_number(replace(date, '-', '')).to_number('07')isnull: JSON numbers have no leading zeros. For zero-padded time or date parts use `to_number(trim_left(x, '0')) ||0`.- A
foreachstep's result is a list, sosteps.details.priceisnull. Usesteps.details[].priceorsteps.details[0].price.
Limits
12 steps, 20 iterations per foreach, 40 requests per run, 5 MB per response, 60 s per run. Time waiting for the user's Allow doesn't count.
Check your recipe
pnpm build:core
pnpm wield-dev validate recipes/example.de/search-items.json
pnpm wield-dev run recipes/example.de/search-items.json --params '{"query":"fahrrad"}'wield-dev run runs read recipes from Node, without cookies. It checks verify.checks and treats redirects like the browser does. Sites that need the browser's session or bot-protection cookies only work in the extension.
Public submissions must also pass wield-build lint, which adds the safety rules S1–S18. Then follow contributing for the review.