A recipe is a JSON description of work: fetch data, compute over it, render or act on the result. There is no code in a recipe — transforms are pure functions, ingredients are declared operations, and every step's inputs and outputs are inspectable. You author and test recipes in the Kitchen, then keep them local or publish them.
#Anatomy
Ingredient slugs and fields below are illustrative; 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.*}} — the arguments a caller fills.
output.render routes step results into typed display sections; a null
source is simply not rendered. Note the two parameter placements: transform
parameters sit flat on the step (array, field, …), while 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 for whoever runs the recipe — it gets its own field on the run form. A variable written as a bare value is a constant you tuned, and the run form leaves it alone.
Both stay editable. A tuning constant still appears wherever tuning belongs — the
install dialog, the recipe editor, and the Schedule and Trigger tabs, because
"run this nightly with days: 30" is a setup decision. It is only the one-shot
run form that stops asking.
| Field | Meaning |
|---|---|
label |
Required on the object form. The text shown, and the signal that this is a question |
type |
Required. text, number, boolean, enum, datetime, url, secret, and others |
optional |
true lets the runner leave it empty. Leave it out to require a value |
default |
used when nothing is supplied |
help |
one line under the field |
options |
for type: "enum" — the choices; the first is the default |
Two rules worth knowing before you hit them:
- Only these fields exist. Anything else is refused when you install, so a
field you invent fails loudly instead of being quietly ignored. In particular
there is no
required—optionalis the one that exists, and leaving it out already means required. (A great many recipes carried arequiredfield that never did anything, which is exactly why it is refused now.) - A caller can only pass variables you declared. If a chat request, an MCP
call, or a button hands your recipe a name that is not in
variables, the run is refused rather than silently ignoring it. So every argument you want to accept has to be declared — and a leftover name you no longer read has to go.
To validate the shape of a value — an email address, a range — use a guard
step. Declarations say what a value is called and how it is collected, never what
counts as valid.
#How a step executes
Every step follows the same flow:
skip_when? → compute → step.<id> = result → fail_on?skip_whenevaluates before execution; a skipped step storesnulland the recipe continues.- The step computes: a
transform(pure function), aningredient(declared operation), or aguard(halts the recipe when its condition is met). - The result lands on
{{step.<id>}}for later steps. fail_onevaluates after; a match halts the recipe.
foreach runs a step once per item of an array, with {{item.*}} bound
inside each iteration.
#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)Conditions are inline strings 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. Literals are bare — write
equal closed_won, never equal 'closed_won'; a quoted literal never
matches. And compound logic is a transform step (any / all) whose stored
boolean you check — and / or do not exist inside condition strings. The
full operator table is in the
recipe schema reference.
#Compute with transforms
Around fifty pure transforms cover collections (filter, sort, map, group_by), objects, strings, dates, numbers, and logic. Prefer a chain of small deterministic steps; reach for AI only where judgment is genuinely needed.
#AI steps
Use contracted AI functions — ai-classify, ai-score, ai-extract,
ai-summarize, and friends — before ai-prompt. Contracted shapes validate,
and every function except ai-compare can process an array in one batched
call. Ask for model quality by hint (fast, quality, thinking), not by
provider. 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 pages the recipe is designed for |
| Reactive | auto_run interval plus an optional trigger_steps gate |
| Scheduled | A cron schedule on the server |
Reactive recipes gate themselves: trigger_steps run on every tick, and each
step must return should_run: true for the run to proceed — a quiet tick
leaves no trace. A circuit breaker disables a recipe after repeated
consecutive failures and notifies you; you re-enable it deliberately.
#Test, fork, publish
The Kitchen validates against the schema as you edit and 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 same-named recipes from different publishers never share secrets. Publishing requires a free account; browsing and installing never do. The full flow — validation, review, versions, unpublishing — is in Publishing.
When a recipe needs an operation that is not already installed, do not inline credentials or an ad hoc request in the recipe. Build a governed capability in Authoring ingredient packs, install it locally, then reference its operation from the recipe.