A recipe fires automatically through one of a few declared surfaces. Which one to use is a property of what you are reacting to, not of the recipe — the same recipe can still be run by hand, and every declared trigger installs disarmed until the owner arms it.
| Reacting to… | Use |
|---|---|
| A warehouse record changing — mail arrived, an event moved, a task came due | event_triggers |
| An external system pushing to your server — Stripe, GitHub, Telegram | webhook_triggers |
| A condition you must poll — a time window, a page changing, "N days since…" | auto_run + trigger_steps |
| Another recipe finishing | event_triggers on run outcomes |
| A calendar cadence the user owns | Nothing — the owner arms a schedule |
Prefer events over polling whenever the source is already synced: an event subscription costs nothing between events; a poll costs a tick forever.
#Warehouse events
Subscribe with a path pattern — * spans one segment, ** several:
"event_triggers": [
{ "event": "data.mail.**.created" },
{ "event": "data.calendar.**.updated" }
]Beyond created / updated / deleted, work items emit lifecycle events —
due_soon, overdue, state_changed — so a deadline radar is a
subscription, not a poll.
The payload is an address, not the record. It carries routing fields (which record, a few hot fields); re-read the full record with the matching read operation before doing real work.
Gate out first-sync noise. Update events carry payload.prev, a
snapshot of the prior hot fields; backfills and first syncs don't. Two
standing gates:
"skip_when": "{{context.event.payload.prev}} is_null"
"skip_when": "{{context.event.payload.prev.start_at}} equal {{context.event.payload.start_at}}"The first skips synthesized fires, the second skips no-op updates.
Shorthand forms exist for common subscriptions: cross-vendor CRM changes
("on": "deal.changed" with optional field and value narrowing), messenger
messages ("on": "message.received"), accepted intake responses
("on": "form_response.accepted" — see Reception), and
visitor requests ("on": "reception.request"). Browser-page watches
("on": "element.changed" with a URL and selector) work for your own local
recipes but cannot be published.
One more event family: recipes themselves. run.<recipe_id>.**.completed
and .failed fire when a recipe finishes, which chains stages without
polling.
#Inbound webhooks
For systems that push, declare a webhook_triggers entry naming a
verification profile — Stripe, GitHub, Telegram, Slack, Paddle,
Lemon Squeezy, or a generic token / HMAC / basic-auth mechanism. The
endpoint verifies the signature before your recipe ever fires; the recipe
then reads the accepted event body.
Don't re-verify signatures inside the recipe — that happened at the boundary. Do treat the body as untrusted data: gate on the fields you need and skip when they're absent. Webhooks POST straight to your server; nothing is relayed through anyone's cloud.
#Self-ticking watchers
When there is no event source, a recipe can tick itself and gate each tick:
"auto_run": { "interval_ms": 3600000 },
"trigger_steps": [
{ "id": "window", "op": "core.watch.time",
"args": { "weekdays": [1, 2, 3, 4, 5], "start_hour": 7, "end_hour": 19 } }
]Every trigger step must return should_run: true or the tick passes
silently.
Alongside the built-in watch operations, these transforms answer "has this changed?" from data you already fetched, so a gate costs no extra call:
| Transform | Answers |
|---|---|
time_within_window |
Is now inside the allowed hours/weekdays? now, weekdays?, start_hour?, end_hour? |
time_elapsed_since |
Has window_ms passed since since_ms? |
mail_received |
Did any mail arrive matching from / subject / label? |
file_changed |
Did any file under path_prefix change since since_ms? |
calendar_new_since |
Were events created since since_ms? |
calendar_changed_since |
Were events edited since since_ms? |
calendar_starting_soon |
Does an event start within minutes_ahead? |
attendee_diff |
Who joined or left between a prior and current attendee list? |
http_changed |
Did an ETag or content hash move since last time? |
recipe_succeeded_since |
Has a given recipe already succeeded since since_ms? Use it to avoid re-doing work |
Each returns the decision plus what it matched, so the same step both gates the tick and hands the detail to the steps after it.
Pick the slowest interval the job tolerates — an hourly tick gated by a morning window is the norm for a daily job. And de-duplicate your own fires: a watcher that can see the same condition on consecutive ticks must stamp what it acted on (an annotation on the record, a marker row) rather than trusting the interval.
#Configuration on automated fires
An unattended fire resolves configuration in layers: the recipe's variable defaults, then the install's saved configuration, then the schedule's or trigger's own configuration (each armed row can carry one). Author variables so a fire on pure defaults either works or fails closed asking for setup — never half-runs.
#Earn each stage of work
Order gates cheap to expensive, deterministic before AI, and fail closed at every rung:
- Identity — is this event about the thing you watch? Can't establish it → skip.
- State and window — is the watch still active, the time window open?
- Cheap fields — subject terms, status, thresholds — before any body read or vendor call.
- The expensive read — only after the cheap gates pass.
- A purpose check on the fetched content — deterministic, from configured terms. Ambiguous → record it for review and notify, don't proceed.
- AI last, for synthesis or classification only — after privacy protection, and never as the thing that decides whether an action happens.
#Keep outward actions out of unattended runs
The robust pattern for anything outward-facing or hard to reverse — sending mail, posting messages, creating events with attendees — is a split:
- the unattended recipe detects: it reads, computes, writes a proposal, and notifies you;
- a second recipe acts, and you run it yourself — from the
notification, or from a
buttonon a review digest. Running it is the decision, and its writes still pass grants and approvals like any other run.
A background run has nobody at the keyboard: at best an outward action waits as a held approval; at worst a broadly-granted write happens with no one looking. The detect/act split means background work only ever accumulates proposals you review with context, instead of asks — or surprises.
#Checklist
- Events over polling when the source is synced; payload re-read before
use;
prev is_nullgate on update subscriptions. - Webhooks: verification by profile at the boundary; body fields gated in steps.
- Watchers: slowest tolerable interval; fires de-duplicated with a stamp.
- Works on defaults or fails closed asking for configuration.
- Gates ordered cheap → expensive → AI; AI never gates an action.
- Nothing outward-facing in the unattended path — detect, propose, notify; act in a recipe the owner runs.
- The description says what arms, what fires it, and what it will never do.