A trigger is something that starts a recipe by itself. A recipe declares which kind it uses. Pick the kind by asking what you are reacting to. It is not about the recipe. The same recipe can still be run by hand. And every declared trigger installs disarmed. That means switched off. It stays off until the owner arms it, which means switches it on.
| Reacting to… | Use |
|---|---|
| A record in the warehouse changed. Mail came in, an event moved, a task came due | event_triggers |
| A kind of email came in. A parcel, a bill, a booking, an order, a lead | event_triggers on mail facts |
| An outside system pushes to your server. Stripe, GitHub, Telegram | webhook_triggers |
| Something you must keep checking. A time window, a page changing, "N days since…" | auto_run + trigger_steps |
| Another recipe finished | event_triggers on run outcomes |
| A calendar rhythm the user owns | Nothing. The owner arms a schedule |
Prefer events over polling whenever the source is already synced. Polling means checking again and again. An event subscription costs nothing between events. A poll costs a tick forever.
#Warehouse events
Subscribe with a path pattern. * matches one segment. ** matches
several:
"event_triggers": [
{ "event": "data.mail.**.created" },
{ "event": "data.calendar.**.updated" }
]There is more than created / updated / deleted. Work items also send
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, and a few hot fields. Before you do real work, read the full record again with the matching read operation. Mail facts are an exception. Their event carries the values.
Gate out first-sync noise. An update event carries payload.prev. That
is a snapshot of the hot fields from before the change. Backfills and first
syncs do not carry it. Two gates you will use a lot:
"skip_when": "{{context.event.payload.prev}} is_null"
"skip_when": "{{context.event.payload.prev.start_at}} equal {{context.event.payload.start_at}}"The first one skips fires the system makes up itself, like a first sync. The second one skips updates that changed nothing.
#Shorthand forms and recipe events
There are short forms for common subscriptions:
- CRM changes across vendors:
"on": "deal.changed". You can narrow it by field and value. The record (payload.record) names its connection inconnection_name, and itstarget_idis the id to read or update it by. - Messenger messages:
"on": "message.received". - Accepted intake responses:
"on": "form_response.accepted". See Reception. - Visitor requests:
"on": "reception.request". - Mail facts:
"on": "mail_fact.shipment", or"on": "mail_fact"for any kind of email. See Mail facts.
Browser-page watches use "on": "element.changed" with a URL and a
selector. They work for your own local recipes. They cannot be published.
One more family of events comes from recipes themselves.
run.<recipe_id>.**.completed and .failed fire when a recipe finishes.
That chains one stage to the next without polling.
#Mail facts
Recued can read a kind of email into a few set values. A parcel, a bill, a booking, an order, a lead. Each reading is a mail fact. You teach Recued a kind of email with a template. A template says which emails to read, and how. You make one under Data → Received → Mail facts. Some kinds need no template. Recued reads shop markup and carrier tracking numbers by itself.
The facts about one parcel, order or booking make one thing. A recipe watches a thing's values, not the email itself:
"event_triggers": [
{ "on": "mail_fact.shipment", "fields": ["state"], "where": { "state": "delivered" } }
]"on": "mail_fact.<kind>" watches one kind of email. Most recipes want
this. "on": "mail_fact" watches any kind that has the values you name.
Neither one takes a verb. Neither one takes a connection either, because a
fact is read from every mailbox.
#Which mail fact changes wake a recipe
fields picks which changes wake the recipe:
fields |
Wakes on |
|---|---|
| Left out | Each new thing, and each change to what it says |
["state"] |
A change to that one value |
The thing's identity, like carrier and tracking_number |
Each new thing, once. An identity never changes |
["last_email_at"] |
Every email about the thing, even one that says nothing new |
A new thing counts as a change to every value it has. So the example
above also fires for a parcel whose first email already says delivered.
#Kinds of email
| Kind | Values | state |
|---|---|---|
purchase |
merchant, order_id, total, ordered_at |
ordered, paid, cancelled |
shipment |
carrier, tracking_number, order_id, merchant, expected_at, delivered_at |
label_created, in_transit, out_for_delivery, delivered, exception, returned |
return_refund |
merchant, order_id, return_id, amount |
return_requested, label_issued, received, refund_issued, refund_denied |
reservation |
kind, provider, confirmation_code, starts_at, ends_at, location, party_size, total |
confirmed, cancelled |
bill |
issuer, amount_due, due_at, invoice_number, period, account_ref, document |
issued, paid, overdue |
statement |
issuer, account_ref, period_start, period_end, closing_balance, minimum_due, due_at, document |
available |
subscription |
service, plan, amount, renews_at |
active, cancelled |
pay_tax_document |
issuer, kind, period_or_year, document |
available |
lead |
source, name, email, phone, reference |
new |
order_received |
platform, order_id, buyer_name, buyer_email, total, ship_by |
new, paid, cancelled |
owner_request |
tag, message_id |
new |
#States and notices
Every value of the kind is always there. A value nothing read is null.
A thing's state comes from its newest email that has one. So old mail
read late never moves a parcel back from delivered.
Some kinds also have a notice. A notice is news about the thing, not a
new state. A booking can have changed or reminder. A bill can have
reminder. A subscription can have renewal, price_change,
trial_ending or payment_failed. A notice never changes the state. And
notice holds only the newest email's notice. So a payment after a
reminder leaves none.
Money is an amount and a currency, like
{ "amount": "12.50", "currency": "USD" }. You can also make kinds of your
own. A recipe that names one works only on the server where you made it.
#Filter a mail fact trigger
where narrows a mail fact trigger. Its rules are strict:
- It takes the kind's values, plus
completeandtemplate. It cannot compare money, files, or a value that holds a time of day, likedelivered_at. Watch those withfieldsinstead. A plain date works. - Write each value the way the fact stores it. Never empty. One space
between words. An id without a leading
#, like112-3345. A carrier by its short name, likeUPS. - A
{{config.*}}value is refused. The filter runs before the recipe does, and it has no settings then. Check a setting in a step'sskip_wheninstead. - With
"on": "mail_fact", a kind that lacks a value never matches it. A value nothing read,null, never matches either. So"where": { "notice": "price_change" }never wakes on a purchase. A purchase has no notice. where.templatenames a template on your own server. It matches nothing anywhere else. So leave it out of a recipe you publish. Usetemplate_variableinstead. See A recipe that brings its own template.
The Kitchen checks these rules as you type, and says what to fix.
#A recipe that brings its own template
A recipe you publish can bring the template you built it with. Give the
recipe a setting of type mail_template. Put your template in its
starter. Then name that setting in the trigger, like
"template_variable": "template". The trigger then wakes only on facts
that this template read.
- Installing the recipe makes the template, with its AI off. The setting holds the new template.
- The owner can pick another template in the recipe's settings. The trigger follows the pick. Until the setting holds a template, the trigger does nothing.
- Each dish has its own settings. So two dishes can read two templates.
- The owner cannot change a starter's rules. Duplicate to edit makes their own copy. An update to your recipe brings your new rules.
- Uninstalling removes the template, unless another recipe's setting holds it.
The checks are strict here too. A trigger takes template_variable or
where.template, never both. The setting takes no default. A starter must
use a kind of email built into Recued, the same kind the trigger watches. It
must hold nothing from your own mail, like an email address, a phone
number, or a long number.
In a run, {{context.event.payload.record.template}} is the template that
read the fact.
#What a mail fact event carries
Most events only point at a record. A mail fact event carries the thing itself:
| Field | What it holds |
|---|---|
record_id |
The thing's id. core.mail.fact.get reads it with all its facts |
record |
Every value of the kind, plus complete, missing, template, last_email_at and passes |
record.passes |
Who filled each value: standard, rule or ai |
record.fact |
The fact that caused the change. It points at its email, and holds any extra data the template read |
prev |
The thing before the change. A new thing has none |
changed_fields |
What changed |
The template decides whether AI fills in values. The recipe does not. Must
your recipe never act on an AI value? Then check its pass in skip_when,
like "{{context.event.payload.record.passes.tracking_number}} equal ai".
#When a mail fact starts a recipe
- Only news starts a recipe. News is new mail, after the mailbox's first sync. Mail found again on a restart starts nothing. Each fact starts recipes once.
- So never use the
prev is_nullgate on a mail fact trigger. A new thing has noprev, and it is real news. The gate would drop every new parcel. - Reading past mail starts recipes only when you ask it to. Those runs are
marked as past-mail runs (
run_mode: backfill). - Each email's event arrives on its own. Events are not merged.
- A fact lasts only as long as its email. Keep what must last longer. Make a booking from a reservation, or a commitment from a bill.
- A thing's later emails wake the recipe again. So tie what the recipe
makes to the thing, and look for it before making another. Tie it to the
thing's identity (
record.identity_keys), not only its id. An email that is removed and read again makes a new thing. It has a new id and the same identity, and it starts the recipe again. - A recipe a fact starts runs without you, so each write it makes waits for your approval. Look before writing, and write only to change something. Then a run with nothing to do ends without asking you.
#Inbound webhooks
A webhook is a message another system sends to your server when something
happens. For systems that push like this, add a webhook_triggers entry.
It names a verification profile: Stripe, GitHub, Telegram, Slack,
Paddle, Lemon Squeezy, or a generic token / HMAC / basic-auth mechanism. The
endpoint checks the signature before your recipe ever fires. Then the
recipe reads the accepted event body.
Do not check the signature again inside the recipe. That already happened at the door. Do treat the body as untrusted data. Gate on the fields you need. Skip when they are missing. Webhooks POST straight to your server. Nothing is passed through anyone's cloud.
#Self-ticking watchers
When there is no event source, a recipe can tick by itself. It gates 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. If not, the tick passes
quietly.
#Gating a tick without an extra call
There are built-in watch operations. There are also these transforms. They 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 and weekdays? now, weekdays?, start_hour?, end_hour? |
time_elapsed_since |
Has window_ms passed since since_ms? |
mail_received |
Did any mail arrive that matches 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 an old and a new attendee list? |
http_changed |
Did an ETag or content hash change since last time? |
recipe_succeeded_since |
Has a given recipe already succeeded since since_ms? Use it to avoid doing work twice |
Each one returns the decision plus what it matched. So the same step gates the tick and hands the detail to the steps after it.
Pick the slowest interval the job can live with. For a daily job, the normal choice is an hourly tick gated by a morning window. And stop your own repeats. A watcher may see the same thing on two ticks in a row. So it must stamp what it acted on. Use an annotation on the record, or a marker row. Do not trust the interval alone.
#Configuration on automated fires
A run with nobody watching works out its settings in layers. First, the recipe's variable defaults. Then the settings saved at install. Then the schedule's or trigger's own settings. Each armed row can carry its own. Write your variables so a run on pure defaults either works or stops and asks for setup. It must never half-run.
#Earn each stage of work
Order your gates from cheap to costly. Put fixed checks before AI. Stop safely at every step:
- Identity. Is this event about the thing you watch? Cannot tell? Skip.
- State and window. Is the watch still on? Is the time window open?
- Cheap fields. Subject words, status, thresholds. Check these before any body read or vendor call.
- The costly read. Only after the cheap gates pass.
- A purpose check on what you fetched. Fixed rules, from words the user set up. Not sure? Record it for review and notify. Do not go on.
- AI last. Only to summarize or classify. Only after privacy protection. Never as the thing that decides whether an action happens.
#Keep outward actions out of unattended runs
Some actions reach outside or are hard to undo. Sending mail. Posting messages. Making events with other people on them. The strong pattern for these is a split:
- The unattended recipe detects. It reads, works things out, writes a proposal, and notifies you.
- A second recipe acts. You run it yourself. From the notification, or
from a
buttonon a review digest. Running it is the decision. 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 write with a wide grant happens with no one looking. With the detect-then-act split, background work only piles up proposals. You review them with context. You do not get asks, or surprises.
#Checklist
- Events over polling when the source is synced. Read the payload's record
again before use. Use the
prev is_nullgate on update subscriptions, but never on a mail fact trigger. - Mail facts: one kind in
on, unless any kind will do.wherevalues written as the fact stores them. No template id in a recipe you publish. Bring astarterand usetemplate_variableinstead. - Webhooks: the profile checks the signature at the door. Steps gate on body fields.
- Watchers: the slowest interval you can live with. Repeats stopped with a stamp.
- Works on defaults, or stops and asks for setup.
- Gates ordered cheap → costly → AI. AI never gates an action.
- Nothing that reaches outside in the unattended path. Detect, propose, notify. Act in a recipe the owner runs.
- The description says what arms it, what fires it, and what it will never do.