A condensed field reference for recipe authors. For the narrative introduction, read Authoring recipes first.
#Envelope
{
"recipe_id": "detect-deal-risk-hubspot",
"version": 1,
"ttl": 600,
"metadata": {},
"variables": {},
"requires": [],
"depends_on": [],
"chat_exposed": false,
"trigger": [],
"event_triggers": [],
"webhook_triggers": [],
"auto_run": null,
"trigger_steps": null,
"run_mode": null,
"provenance": true,
"prefetch_steps": [],
"steps": [],
"output": {}
}| Field | Type | Notes |
|---|---|---|
recipe_id |
string | {action}-{entity}-{qualifier}[-{platform}], platform always last |
version |
number | Integer version of this recipe |
ttl |
number | Default cache TTL in seconds for ingredient fetches |
metadata |
object | Display and marketplace fields (below) |
variables |
object | User-configurable values, read as {{config.*}} |
requires |
string[] | Permissions the recipe needs, such as notification_send or mail_send |
depends_on |
string[] | Packs whose operations the steps call, as publisher.pack; built-in core.* operations need no entry |
chat_exposed |
boolean | Offer this recipe to chat and connected agents as a tool; see Exposing recipes to chat |
trigger |
string[] | URL patterns the recipe is designed for; absent means manual-only |
event_triggers |
object[] | Warehouse events that fire the recipe; see Triggers and watchers |
webhook_triggers |
object[] | Inbound webhooks that fire the recipe, each named with a verification profile |
auto_run |
object | Reactive regime: { "interval_ms": 60000, "dynamic": false } |
trigger_steps |
step[] | Gate phase before each reactive tick; requires auto_run |
run_mode |
string | live, backfill, or manual; tags audit entries |
provenance |
boolean | Default true; emits the links that power timelines |
prefetch_steps |
step[] | Fetched in parallel before sequential steps |
steps |
step[] | Sequential work |
output |
object | render sections routing step results to display |
#metadata
| Field | Notes |
|---|---|
name, description |
Display |
author |
Publisher handle |
tags |
Free-form strings for discovery and filtering |
supported_platforms |
Platforms this variant targets |
variant_group |
Groups cross-platform variants of one recipe family |
execution_scope |
Optional narrowing, a subset of the scope derived from ingredient kinds |
budget_ms |
Wall-clock cap for a run; the engine aborts past it. Set one on everything you ship |
fork_of |
Lineage of a published fork: original recipe, author, version |
#Steps
{
"id": "risky",
"transform": "filter",
"array": "{{step.deals}}",
"field": "stage",
"operator": "equal",
"value": "at_risk",
"skip_when": "{{step.deals}} is_empty"
}The step type is inferred from which field is present:
| Kind | Field | What it does |
|---|---|---|
| Transform | transform |
Pure local function; its parameters sit flat on the step — see Transforms |
| Ingredient | ingredient |
Declared operation: HTTP, DOM, AI, chat, MCP, service, storage, or connection; parameters under input, with dotted keys such as llm.data |
| Operation | op |
A named operation — built-in (core.mail.get, core.notification.send, core.watch.time, …) or from an installed pack (publisher.pack.entity.verb, listed in depends_on). Arguments under args; the bound account under connection, usually "{{config.<your connection variable>}}" |
| Guard | guard |
The field is a condition string; halts the recipe when it is met |
| Condition | condition |
Evaluates a condition and routes into then / else step blocks |
Execution flow, identical for every step:
skip_when? → compute → step.<id> = result → fail_on?skip_whentrue → the step storesnulland the recipe continues.fail_ontrue (evaluated after compute) → the recipe halts.foreachiterates an array input, binding{{item.*}}per iteration; the step's output is one envelope per element,{ ok, result?, error?, item }, in source order.- Prefetch steps additionally accept
"optional": true— a failure storesnullinstead of halting the run.
Trigger-phase steps (trigger_steps) use the same shape. Every one must
return should_run: true for the reactive tick to proceed; their outputs
land on {{trigger.<id>.*}} for downstream steps.
#Variables
Two forms. A bare value is a constant you tuned; its type is inferred and the one-shot run form leaves it alone. An object is a question for whoever runs the recipe, and gets its own field on that form.
"variables": {
"recipient": { "label": "Send to", "type": "text" },
"window": { "label": "Window", "type": "enum", "options": ["7d", "30d"] },
"max_rows": 50,
"deal_id": null
}| Bare value | Inferred | Control |
|---|---|---|
50 |
number | Number input |
true / false |
boolean | Toggle |
"text" |
string | Text input |
["a", "b"] |
enum | Dropdown, first element is the default |
null |
string, required | Text input with no default; the recipe cannot run until it is supplied |
A tuning constant stays editable everywhere tuning belongs — the install
dialog, the recipe editor, and the Schedule and Trigger tabs, where
"run nightly with window: 30d" is legitimate setup. Only the one-shot run
form stops asking.
#Object-form fields
These are the only fields that exist. Anything else is refused when you install, so an invented field fails loudly instead of being quietly ignored.
| Field | Notes |
|---|---|
label |
Required. The text shown, and the signal that this is a question rather than a constant |
type |
Required, and a non-empty string. See the table below |
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 control |
link |
A documentation URL for the control |
options |
type: "enum" — the choices; the first is the default |
provider, scopes |
type: "oauth" — broker routing and requested scopes |
accept_mime_types |
type: "file_ref" — narrows the picker only; never a security boundary |
connection_kind |
type: "connection" — api, mcp, or notification |
variant_group |
type: "service_ref" — which enrolled service family the picker offers |
There is no required field: optional is the one that exists, and leaving it
out already means required. There is no pattern either — validate the shape
of a value with a guard step, since a declaration says what a value is called
and how it is collected, never what counts as valid.
#Types
Unlike the field list, type is open. These drive a specific control:
text · number · boolean · enum (with options) · datetime · url ·
secret · oauth · file_slug · file_ref · file_ref[]
These are admitted too and are widely used. connection and service_ref get
their own pickers; the rest render as a plain text control:
string · array · object · json · connection · service_ref
string is by far the most common type in shipped recipes and behaves as
text. Any other non-empty string installs and renders as text, so the set can
grow without invalidating what you have already published — which is exactly
why an unknown type is admitted while an unknown field is not. A type has a
sensible fallback; a field nobody reads has none.
#A caller may only pass what you declared
If a chat request, an MCP call, a button, or a schedule overlay hands your
recipe a name that is not in variables, the run is refused rather than
ignoring it. So every argument you want to accept has to be declared, and a
leftover name you no longer read has to go.
Give a default only where a sensible one exists. A required input with a
placeholder default — an empty string, a zero, an empty list — is not required
at all: the caller can omit it and the recipe runs with the placeholder.
#Value system
"literal" → use as-is
"{{ref}}" → resolve, preserve type
"{{ref:format}}" → resolve; the hint applies only during interpolation
"text {{ref}} text" → interpolate → string
10, true, false → native JSON type
null → required from callerFormat hints: :currency, :number, :date, :relative, :percent.
#Conditions
Inline strings, one condition per field, one operator per condition. Literals
are bare — equal closed_won, never equal 'closed_won'.
| Operator | Form |
|---|---|
equal, not_equal |
binary |
greater, greater_or_equal, less, less_or_equal |
binary |
is_null, is_not_null, is_empty, is_not_empty |
unary |
contains, not_contains |
binary |
in, not_in |
binary, array value — use the object form |
Object form for array values:
{ "field": "{{step.x}}", "operator": "in", "value": ["a", "b"] }Compound logic is a transform step (any / all) whose stored boolean you
check; and / or do not exist inside condition strings.
#Namespaces recipes can reference
| Namespace | Contents |
|---|---|
config |
Recipe variables, set at install or run time |
context |
Caller state, plus engine-injected fields (below) |
meta |
Recipe metadata |
step |
All step outputs, prefetch and sequential |
trigger |
Trigger-phase outputs on reactive recipes |
item |
The current foreach element |
shared |
User-writable cache-tier records |
data.shared |
User-writable durable records |
data.memory |
Audit and memory entries; requires the read-memory permission |
data.contact |
The contact graph, keyed by canonical email |
connection |
Non-secret fields of named connections |
{{vault.*}} is ingredient-only — recipes never see credential material.
Vendor read-side aliases (data.hubspot.*, data.salesforce.*,
data.crm.*) resolve enrichments on CRM records, with static ids only.
#Engine-injected context
| Field | Meaning |
|---|---|
context.tabs |
Ingredient slugs whose URL patterns match open browser tabs |
context.server |
{ available, name? } — paired-server reachability |
context.event |
Trigger payload on reactive fires; payload.prev snapshots prior hot fields on updates |
context.recipe.* |
Prior-run step snapshots for run-to-run continuity; undefined on the first run |
#Transforms
Per-transform parameters live in the transforms reference. Tier 1 — atomic:
| Group | Transforms |
|---|---|
| Collection | filter, sort, map, reduce, unique, flatten, slice, group_by, to_list, partition |
| Object | merge, pick, omit, rename, set |
| String | lowercase, uppercase, trim, split, concat, replace, template, truncate, strip_html |
| Numeric | round, clamp, to_number, math, weighted_score |
| Date | date_diff, date_format, date_add, date_parse, is_past, is_future, date_period |
| Logic | compare, coalesce, switch, all, any, count, default, not, ternary, pluralize |
| Boolean | starts_with, ends_with |
| Privacy | hash_replace, hash_restore, redact |
| Display | to_checklist, to_table, to_summary, to_csv |
Tier 2 — compound conveniences: find, pluck, sum, min_by, max_by, percent, join, group_by with aggregate.
Notable modes: filter accepts mode: "all" | "any" with a conditions
array for multi-condition filtering in one step; map accepts an
expression over {{item.*}} (a single ref, a math expression, or an object
template), with output_field to attach the computed value to each item
instead of replacing it.
#AI functions
| Function | Input | Output |
|---|---|---|
ai-classify |
llm.data, llm.categories[], llm.context? |
{ category, confidence, reasoning } |
ai-score |
llm.data, llm.criteria[], llm.scale? |
{ score, breakdown[], reasoning } |
ai-extract |
llm.data, llm.fields[] |
{ [field]: value } |
ai-summarize |
llm.data, llm.max_length?, llm.focus? |
{ summary, key_points[] } |
ai-sentiment |
llm.data |
{ sentiment, score, signals[] } |
ai-compare |
llm.data_a, llm.data_b, llm.dimensions[]? |
{ differences[], similarities[], recommendation } |
ai-generate |
llm.data, llm.template_type, llm.tone? |
{ content } |
ai-translate |
llm.data, llm.target_language |
{ translated, source_language, confidence } |
ai-rewrite |
llm.data, llm.style, llm.instructions? |
{ rewritten } |
ai-prompt |
llm.system_prompt, llm.prompt, llm.output_format?, llm.model_hint?, llm.allow_search? |
uncontracted |
Batch mode: every function except ai-compare accepts an array llm.data
plus a non-empty llm.id_field — one model call, returning a per-element
array in input order, each element the source record merged with the result
fields. Model hints: fast, quality, thinking.
#Output
"output": {
"render": [
{ "type": "summary", "source": "step.overview", "label": "Overview" },
{ "type": "ai_analysis", "source": "step.analysis" }
]
}Pure routing, no logic — a null source is not rendered. Section types:
| Type | Renders |
|---|---|
checklist |
Output of to_checklist as pass/fail/neutral items |
table |
Output of to_table as a data table |
summary |
Output of to_summary as key-value pairs |
ai_analysis |
An AI result with its confidence badge |
text |
Any string |
copyable |
A string with a copy affordance |
button |
Owner-clicked actions that open a normal, still-governed recipe run |
file_artifact |
One or more file cards with authenticated preview and download |
link_button |
A plain outbound link — label plus HTTPS URL; the one block a public visitor-facing page can act on |
json |
Raw structured data from a step — the detail behind a curated card. Each surface decides how to show it: the web surfaces collapse it, a chat or agent caller receives it verbatim |
filter |
An editable form built from your declared variables; submitting re-runs the whole recipe with the collected values. Owner-only — a visitor-facing page omits the block entirely |
render is the only channel by which a run's data reaches any reader. A step
result that is not routed into a section is unreachable — by a person, and by a
model. Reach for json when a reader needs to check the work behind a summary.
The filter block is the one section that is not { type, source, label }. It
also carries fields (declared variable keys shown as controls), hidden
(declared keys carried on submit but never displayed, a paging cursor being the
usual case), and submit (the button text). Every control is derived from the
variable's own declaration, so there is no per-field styling to write.
#When Recued refuses your recipe
Validation names what it rejected. The ones authors meet most:
| Refusal | Cause |
|---|---|
output_unknown_key, output_section_unknown_key |
A field invented on output or on a section. label is the display field; there is no title, content, or body |
variable_hint_invalid |
An object-form variable missing a non-empty label or type |
variable_hint_unknown_key |
A field on a variable that does not exist — most often required, which was never one |
undeclared_variable_ref |
The recipe reads {{config.x}} with no x in variables |
undeclared_config_argument |
A caller supplied a config key the recipe never declared. Raised at run time, not install |
unknown_transform |
A transform name that is not registered |
condition_operator_invalid |
An operator outside the fourteen |
forward_step_ref |
A step reads a step that runs later |
nested_template |
A {{ }} inside another {{ }} |
context_recipe_in_auto_run |
{{context.recipe.*}} in a reactive recipe. It carries prior-run state on manual and scheduled runs only |
filter_variable_undeclared, filter_field_not_labeled |
A filter block naming a key that is not declared, or one that is a bare constant rather than a labelled question |
orphan_step and ttl_below_floor are advisory rather than refusals: a step
nothing reads, and a cache TTL below the practical floor.
#Naming
Recipe: {action}-{entity}-{qualifier}-{platform} detect-deal-risk-hubspot
Ingredient: {action}-{entity}-{platform} deal-reader-hubspot
AI: ai-{function} ai-score
Vendor: {action}-{vendor} search-exaPlatform always last — this is what lets variant_group collapse
cross-platform families into one marketplace card.