Guides

Authoring recipes

How to write a recipe as plain JSON: steps, values, conditions, AI steps, and the ways a recipe can run.

A recipe is a list of steps written in JSON. It says what to fetch, what to work out, and what to show or do. There is no code in a recipe. Transforms are small safe tools that always give the same answer. Ingredients are actions that are declared ahead of time. You can look at what goes into every step and what comes out. You write and test recipes in the Kitchen. Then you keep them for yourself, or you publish them.

#Anatomy

The ingredient names and fields below are just examples. Real ones come from what you have installed.

json
{
  "recipe_id": "digest-unanswered-email",
  "version": 1,
  "ttl": 600,
  "metadata": {
    "name": "Unanswered email digest",
    "description": "Summarize threads waiting on a reply from you.",
    "author": "you",
    "tags": ["email", "digest"]
  },
  "variables": {
    "days": 3
  },
  "steps": [
    { "id": "mail", "ingredient": "mail-reader",
      "input": { "folder": "inbox", "within_days": "{{config.days}}" } },
    { "id": "waiting", "transform": "filter",
      "array": "{{step.mail.messages}}",
      "field": "answered", "operator": "equal", "value": false },
    { "id": "summary", "ingredient": "ai-summarize",
      "input": { "llm.data": "{{step.waiting}}",
                 "llm.focus": "who is waiting on me" },
      "skip_when": "{{step.waiting}} is_empty" }
  ],
  "output": {
    "render": [
      { "type": "ai_analysis", "source": "step.summary" }
    ]
  }
}

variables become {{config.*}}. These are the values a caller fills in. output.render sends step results into typed display sections. If a source is null, that section is just not shown. Note where parameters go. Transform parameters sit flat on the step, like array and field. Ingredient parameters go under input, with dotted keys such as llm.data.

#Variables: what gets asked, and what you tuned

How you write a variable says who it is for.

json
"variables": {
  "recipient": { "label": "Send to", "type": "text" },
  "days": 3
}

A variable written as an object with a label is a question. It is asked of whoever runs the recipe. It gets its own box on the run form. A variable written as a bare value is a setting you tuned. The run form leaves it alone.

You can still edit both. A tuned setting still shows up wherever tuning belongs. That means the install dialog, the recipe editor, and the Schedule and Trigger tabs. "Run this every night with days: 30" is a setup choice. Only the one-time run form stops asking about it.

Field Meaning
label Required on the object form. The words shown. It is also the sign that this is a question
type Required. text, number, boolean, enum, datetime, date, url, secret, and others
optional true lets the runner leave it empty. Leave it out to require a value
default Used when nothing is given
help One line under the box
options For type: "enum". The choices. The first one is the default

#Two rules about variables

Two rules are worth knowing before you hit them:

  • Only these fields exist. Anything else is refused when you install. So a field you make up fails loudly. It is not quietly ignored. In particular, there is no required. optional is the field that exists. Leaving it out already means the value is required. (Many recipes carried a required field that never did anything. That is exactly why it is refused now.)
  • A caller can only pass variables you declared. A chat request, an MCP call, or a button may hand your recipe a name. If that name is not in variables, the run is refused. It is not quietly ignored. So declare every argument you want to accept. And remove any old name you no longer read.

Do you want to check the shape of a value? An email address, or a range? Use a guard step. A declaration says what a value is called and how it is collected. It never says what counts as valid.

#How a step executes

Every step follows the same flow:

text
skip_when? → compute → step.<id> = result → fail_on? → stop_when?
  • skip_when is checked first, before the step runs. A skipped step stores null and the recipe goes on.
  • Then the step computes. It is one of three things. A transform is a small safe tool. An ingredient is a declared action. A guard stops the recipe when its condition is met.
  • The result lands on {{step.<id>}} for later steps.
  • fail_on is checked after. If it matches, the recipe stops, and the run fails.
  • stop_when is checked last. If it matches, the recipe stops too. But this time the run succeeds.

foreach runs a step once for each item in an array. Inside each turn, {{item.*}} points at that item.

#Stop a run early

fail_on and stop_when both end the run at their step. They differ in how the run ends:

Check The run Use it when
fail_on Fails The data is wrong. An id is missing, a value is bad, or a page came back cut short
stop_when Succeeds There is nothing more to do. No new mail came in, or the work is already done

For example, "stop_when": "{{step.new_count}} equal 0" ends a run that found no new mail.

A stopped run still shows its output. The output is built from the steps that ran before the stop. So use stop_when when that part is enough. No step after the stop runs. That includes a summary step at the end.

  • A step can have both. Then fail_on wins. A skipped step checks neither.
  • On a foreach step, stop_when is checked once, after every item. It cannot read {{item.*}}. To decide for each item, use skip_when or a filter step.
  • stop_when works only in steps. It is refused in prefetch_steps and trigger_steps.
  • The run log shows the step where the run ended early.
  • Neither check turns off an automated recipe. A stop is a success. And a fail_on failure does not count toward the circuit breaker. That is the switch that turns off a recipe that keeps failing.
  • Put one stop_when on the step that decides. That beats the same skip_when on every later step. A step you add later cannot forget it.

#Values and conditions

text
"literal"               used as-is
"{{ref}}"               resolved, type preserved
"text {{ref}} text"     interpolated into a string
"{{ref:currency}}"      format hint, applied only during interpolation
10, true, null          native JSON (null = required from caller)

A condition is a short string with exactly one operator:

text
"{{step.deal}} is_null"
"{{step.days}} greater {{config.threshold}}"
"{{step.stage}} equal closed_won"

Two rules save the most debugging time. First, literals are bare. Write equal closed_won, never equal 'closed_won'. A quoted literal never matches. Second, joining conditions is a transform step (any / all). It stores a true-or-false value, and you check that. and / or do not exist inside condition strings. The full list of operators is in the recipe schema reference.

#Compute with transforms

There are about fifty transforms. They are safe and always give the same answer. They cover collections (filter, sort, map, group_by), objects, strings, dates, numbers, and logic. Prefer a chain of small steps like these. Use AI only where real judgment is needed.

#AI steps

Use the contracted AI functions first. Contracted means the shape of what goes in and what comes out is fixed. They are ai-classify, ai-score, ai-extract, ai-summarize, and friends. Use ai-prompt only when none of them fits. Fixed shapes can be checked. And every function except ai-compare can handle a whole array in one batched call. Ask for model quality by hint (fast, quality, thinking), not by provider name. See AI and models.

#Ways a recipe runs

Mode Declared by
Manual Nothing. Every recipe can be run by hand
Page-triggered trigger: URL patterns of the pages the recipe is made for
Reactive An auto_run interval, plus an optional trigger_steps gate
Scheduled A cron schedule on the server

A reactive recipe gates itself. Its trigger_steps run on every tick. Each step must return should_run: true for the run to go ahead. A quiet tick leaves no trace. If a recipe fails many times in a row, a circuit breaker turns it off and tells you. You turn it back on yourself, on purpose.

#Test, fork, publish

The Kitchen checks your recipe against the schema as you type. It runs the recipe against your server. Forking a marketplace recipe keeps it local. Publishing a fork gives it your publisher handle and its own credential scope. So two recipes with the same name from different publishers never share secrets. Publishing needs a free account. Browsing and installing never do. The full flow is in Publishing: validation, review, versions, and unpublishing.

Does a recipe need an operation that is not installed yet? Do not put a secret key or a one-off request straight into the recipe. Build a proper, governed capability in Authoring ingredient packs. Install it on your server. Then point the recipe at its operation.

Recued Docs

Search documentation

Start typing to search the documentation.