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.
{
"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.
"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.optionalis the field that exists. Leaving it out already means the value is required. (Many recipes carried arequiredfield 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:
skip_when? → compute → step.<id> = result → fail_on? → stop_when?skip_whenis checked first, before the step runs. A skipped step storesnulland the recipe goes on.- Then the step computes. It is one of three things. A
transformis a small safe tool. Aningredientis a declared action. Aguardstops the recipe when its condition is met. - The result lands on
{{step.<id>}}for later steps. fail_onis checked after. If it matches, the recipe stops, and the run fails.stop_whenis 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_onwins. A skipped step checks neither. - On a
foreachstep,stop_whenis checked once, after every item. It cannot read{{item.*}}. To decide for each item, useskip_whenor afilterstep. stop_whenworks only insteps. It is refused inprefetch_stepsandtrigger_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_onfailure does not count toward the circuit breaker. That is the switch that turns off a recipe that keeps failing. - Put one
stop_whenon the step that decides. That beats the sameskip_whenon every later step. A step you add later cannot forget it.
#Values and conditions
"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:
"{{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.