Sign inTry it

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

json
{
  "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
idRegistrable domain, e.g. bahn.de
nameShown to users
originhttps://host of the tab the recipe runs in. The extension uses a tab of this origin (yours, or a background one)
start_pathOptional 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_hostsExact 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
originsThe only origins this recipe may send requests to. Everything else is blocked, redirects included
country, languageTwo letters. Ranking prefers the user's country
login_urlWhere 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
methodGET, POST, PUT, PATCH, DELETE
urlAbsolute, or relative to site.origin. Templates allowed
queryAppended to the URL. null or missing values are dropped; arrays repeat the key
headersCookie 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
credentialsinclude (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, and steps.<id> is then null.
  • foreach: {in, as, max} runs the step once per item of the array in (JMESPath). max defaults 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 in effect: "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 or write: 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, or turbo_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, html or text, 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 with login_required instead of expect_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?}}. Without attr you get the element's text, whitespace collapsed. all returns every match.
  • html_items: {var: {selector, max?, fields: {name: {selector, attr?}}}}. One object per matched element; a field selector "" or :scope means the element itself.
  • JSON inside an element. Any html spec or html_items field can add parse: "json": the attribute (or, without attr, the element's text) is parsed as JSON, and path picks a value inside it with JMESPath. Shops often keep each tile's data in an attribute such as data-grid-data='{"price":{"price":4.99}}'; then {"selector": ":scope", "attr": "data-grid-data", "parse": "json", "path": "price.price"} returns 4.99 as a number. Values are parsed with JSON.parse only, never run, and anything over 256 KB or not valid JSON gives null. path without parse is a lint error. Only an extension that lists the html_json capability 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__ or script[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 with required: false. (Pending the next schema update.)
    • decode: "astro_props": Astro islands.
    • required: false: a missing element gives null instead of failing.
    • vars: {var: JMESPath}.

Login and tokens

  • requires_login: true: the recipe needs the user's session. login_check is a cheap step whose expect passes only when logged in. status uses it, and runs check it first.
  • auth.derive: values read in the site's tab before the steps, available as vars.<name>. Typical uses are a CSRF token or a bearer token the page keeps.
    • name, from: cookie, local_storage, session_storage, meta, dom or page_url.
    • key or selector, and attr.
    • jmespath (applied after JSON parsing).
    • regex: the first capture group. With dom and several matches, the first element whose text matches wins.
    • required (default true; a missing required value means login_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}. vars are not visible here. now is 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, undefined members are dropped.
Filter
urlencode, json, string, numberConversions
lower, upper, trimText
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>, firstArrays

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.x refers to the element, not the context. Bind it first: let $max = params.max_price in items[?price <= $max]. The same holds inside &… in map, sort_by and similar.
  • !a.b parses as (!a).b. Write !(a.b).
  • let $a = x, $b = $a in … fails: one let binds 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' is null, so a filter on date strings matches nothing. Compare numbers, e.g. to_number(replace(date, '-', '')).
  • to_number('07') is null: JSON numbers have no leading zeros. For zero-padded time or date parts use `to_number(trim_left(x, '0')) || 0 `.
  • A foreach step's result is a list, so steps.details.price is null. Use steps.details[].price or steps.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

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