Every playbook starts with a trigger. A trigger answers one simple question — “What should make this playbook run?” It might be an email arriving, a file landing in a watched folder, an HTTP request hitting a route, the platform booting, or a scheduled moment ticking over. Once you understand triggers, the rest of a playbook falls neatly into place. This page walks through every kind of trigger, with plenty of copy-and-adapt examples.
The shape of a trigger. A trigger always names an event — the thing that happens — and may add one or more filters that narrow down which occurrences you actually care about. No event, no run. It really is that simple.
In YAML, the trigger is the first block of a playbook. The bare minimum is a single event:
name: log-every-message
description: "Write a log line whenever any message arrives."
trigger:
event: Fact.Mail.Received
steps:
- run: database.write
with:
table: message_log
row:
message: "A message arrived"
Events are facts about the world that Binions observes and broadcasts. They all follow a readable, dotted naming pattern — Fact.<Area>.<Thing> — so you can usually guess what one means just by reading it. Fact.Mail.Received is a fact that mail was received; Fact.System.Boot is a fact that the platform booted. A playbook subscribes to one event in its trigger and runs every time that event fires, subject to any filters you add. Because the playbook engine runs many instances concurrently, a burst of matching events launches multiple runs at once rather than queuing them one-by-one.
Here are the events you will reach for most often:
| Event | Fires when… | Typical use |
|---|---|---|
Fact.Mail.Received | A message arrives at a mailbox the platform watches. Messages delivered over MQTT and AMQP are surfaced through the same mailbox broker, so a queued message looks just like an email to your playbook. | Auto-reply, ticket creation, parsing inbound data, routing by subject. |
Fact.Http.Received | An HTTP request reaches a route you have registered. | Webhooks, lightweight APIs, inbound signals from external systems. |
Fact.System.Boot | The platform starts up. | Provisioning — making sure mailboxes, routes and schedules exist. |
Fact.Schedule.Fired | A timer you registered earlier reaches its scheduled moment. | Nightly reports, periodic backups, rotating credentials. |
Fact.Data.FileDiscovered | A watched folder on a registered storage bucket receives a new file. | Supplier drop folders, ingest pipelines, “process every file that lands here”. |
Fact.Data.Transported | An object lands in storage — an upload, or a mail attachment the mailbox offloaded. | Parse every stored PDF, archive every EDI file, share every finished report. |
Fact.Analytics.ItemEmitted | A list was exploded into per-element facts (analytics.emit_items). | Per-item work: one reminder, one label, one schedule per row — each with its own run and audit trail. |
Fact.Modbus.ThresholdCrossed | A polled industrial value entered (or left) an alarm condition — one fact per episode edge, hysteresis built in. | Alarms that page a human once, not once per noisy sample. |
Fact.Modbus.ServerRegisterWritten | An external MODBUS master wrote into the platform’s own served registers. | SCADA pushing a command into your automations. |
Fact.AI.OperationFailed | A language-model call failed — timeout, rate limit, refused output, type mismatch. | Fallback and “tell a human” playbooks instead of silent dead letters. |
Fact.Playbook.Rejected | A playbook file was refused on arrival because it could never run (unknown verb, bad reference, missing secret). | Admin alerts on broken deploys — the previous good version keeps serving. |
Fact.Logs.ErrorRateExceeded | One daemon logged more errors in a window than the configured threshold (opt-in alerting). | “Something is failing hard” notifications. |
Here is a playbook that reacts to inbound HTTP requests on a route:
name: simple-webhook
description: "Log every request that reaches the webhook route."
trigger:
event: Fact.Http.Received
filter:
route.eq: webhook # the segment after /in/ — no leading slash
steps:
- run: database.write
with:
table: message_log
row:
message: "Someone hit the webhook route"
MQTT and AMQP are not special cases. Binions surfaces a queued broker message through the same mailbox machinery as email, so it fires
Fact.Mail.Receivedjust like an inbox message — one playbook works whether the message arrived over IMAP, MQTT, AMQP, a WebSocket, SSE, or Kafka.
Running on every occurrence of an event is rarely what you want. You usually care about a subset — emails from one address, files with a certain extension, requests to a single path. That is what filters are for. A filter compares one field of the event against a value using an operator, and the playbook only runs when the comparison is true.
The operator set is small, predictable, and identical everywhere filters appear:
| Operator | Meaning | Example |
|---|---|---|
.eq | Equals | subject.eq: "Invoice" |
.ne | Not equal | folder.ne: "Spam" |
.contains | Value contains the text | subject.contains: "order" |
.startswith | Value begins with the text | subject.startswith: "RE:" |
.endswith | Value ends with the text | from.endswith: "@acme.com" |
.gt | Greater than | size.gt: 1000000 |
.ge | Greater than or equal | size.ge: 1000000 |
.lt | Less than | size.lt: 500 |
.le | Less than or equal | size.le: 500 |
.in | Value is one of a list | from.in: ["a@x.com", "b@x.com"] |
.not_in | Value is not in a list | folder.not_in: ["Spam", "Trash"] |
.is_null | Field is absent or null | customer_email.is_null: true |
.is_not_null | Field is present and not null | item.due_at.is_not_null: true |
has_attachments | Whether the message carries files | has_attachments: true |
A single filter looks like this — run only when the subject contains the word “invoice”:
name: catch-invoices
description: "Notice emails whose subject mentions an invoice."
trigger:
event: Fact.Mail.Received
filter:
subject.contains: "invoice"
steps:
- run: database.write
with:
table: message_log
row:
message: "An invoice email arrived"
You can list several filters together. When you do, all of them must match for the playbook to run — they are combined with “and”. This example fires only for messages that come from a finance address, mention “invoice” in the subject, and carry at least one attachment:
name: process-finance-invoices
description: "Only invoices, from finance, with attachments."
trigger:
event: Fact.Mail.Received
filter:
from.endswith: "@finance.example.com"
subject.contains: "invoice"
has_attachments: true
steps:
- run: database.write
with:
table: message_log
row:
message: "A finance invoice with attachments arrived"
Filters work on any event, not just mail. Here an HTTP-driven playbook runs only for requests to one route, ignoring hits on every other route:
name: handle-signup-hook
description: "React only to the signup webhook route."
trigger:
event: Fact.Http.Received
filter:
route.eq: signup
steps:
- run: database.write
with:
table: message_log
row:
message: "A signup webhook arrived"
Alternatives with or:. When one playbook should fire on any of a few shapes — the Polish subject, the English subject, or the trusted sender — the reserved or: key holds a flat list of condition maps. Top-level conditions still apply to everything; the or: list matches when at least one branch does. The list stays deliberately flat (no nesting, no not) — three or more genuinely different cases still read best as separate playbooks.
name: triage-invoices
description: "One triage rule instead of three copies differing only by filter."
trigger:
event: Fact.Mail.Received
filter:
via.eq: crm-inbox
or:
- subject.contains: "faktura"
- subject.contains: "invoice"
- { from.email.endswith: "@accounting.example", has_attachments: true }
steps:
- run: database.write
with:
table: triage
row:
subject: ${trigger.envelope.subject}
sender: ${trigger.envelope.from.email}
Taming bursts. Two optional trigger attributes (both off by default) keep an event storm from becoming a run storm: debounce_ms drops a matching fact that arrives too soon after the previous run this playbook spawned, and max_concurrent caps how many instances may be in flight — extra facts are dropped, not queued. Manual triggers and replays bypass both. See Anatomy of a playbook for the details.
Think of filters as a guest list. The event opens the door; the filters decide who actually gets in. Top-level conditions combine with “and”, so each one makes the rule stricter; the
or:list is the one place a rule widens — and even it must pass every top-level condition first.
Some work is not a reaction to anything — it just needs to happen on a timetable. A daily summary at 7am. A backup every night. A token rotation once a month. Binions handles this in two clean halves, which keeps the “when” and the “what” cleanly separated:
scheduler.register_schedule at boot to declare a recurring moment, cron-style.Fact.Schedule.Fired and does the actual work each time the timer fires.First, the provisioning side declares the schedules. This runs once at boot and tells the platform’s scheduler “fire a timer called nightly-backup every night, and one called daily-report every morning”:
name: register-schedules
description: "Declare the recurring timers this host needs."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- run: scheduler.register_schedule
with:
name: nightly-backup
cron_expr: "0 2 * * *" # 02:00 every day
- run: scheduler.register_schedule
with:
name: daily-report
cron_expr: "0 7 * * *" # 07:00 every day
Then, separate business playbooks react when those timers fire. Each one filters on the timer’s name so the right job runs at the right time:
name: run-nightly-rollup
description: "Count the day's orders every night and post the total."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: nightly-backup
steps:
- run: database.count
with:
table: orders
- run: webhook.send
with:
endpoint: ops-channel
body:
orders_total: ${prev.count}
name: send-daily-report
description: "Build a summary every morning and email it out."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: daily-report
steps:
- run: database.write
with:
table: message_log
row:
message: "Building and sending the daily report"
A schedule says when in one of four ways — exactly one per schedule:
cron_expr — a cron expression. The standard five-field form is minute hour day-of-month month day-of-week (0 9 * * MON is 09:00 every Monday); a six-field form adds seconds in front (0 0 3 * * * is 03:00:00 daily). The shorthand @every <N>[s|m|h|d] also works; other shorthands like @daily do not.interval_seconds — a plain fixed cadence (“every 45 seconds” — something cron cannot say: */45 fires at :00 and :45, not every 45 s).at — a ONE-SHOT instant: RFC-3339 (2026-07-05T10:00:00+02:00) or a naive YYYY-MM-DDTHH:MM[:SS] resolved in the schedule’s timezone. The naive form is the data-driven shape — at: ${prev.row.termin} takes the date straight from a database row.dates — a whole calendar of instants (up to 100, same grammar as at). Each fires once; instants that were already overdue at registration catch up one per tick.# A one-shot per database row: fires once at the row's due date, then removes itself
- run: scheduler.register_schedule
with:
name: "term-${trigger.item.id}"
at: ${trigger.item.due_at} # e.g. 2026-07-15T09:00:00 (naive)
timezone: Europe/Warsaw # naive timestamps resolve here
payload:
invoice_no: ${trigger.item.invoice_no}
Local time that stays local. Add timezone: Europe/Warsaw (any IANA zone) and a cron grid keeps its local wall-clock hour through both daylight-saving transitions — the 06:00 report is at 06:00 in winter and in summer. A timestamp that falls into the spring-forward gap is refused loudly at registration rather than silently shifted. Exhausted schedules clean up after themselves: once a one-shot has fired, or a calendar has run out of dates, the scheduler deregisters the entry (emitting Fact.Schedule.Deregistered) — no graveyard of dead timers. And on a fleet of installs sharing one cron line, jitter_secs spreads the firings: each fire becomes due a deterministic 0…N seconds past its grid instant, so fifty machines don’t strike the same second.
Why split it in two? Registering the timer is a one-time setup fact; running the job is recurring work. Keeping them apart means you can change when something happens (edit the cron line) without touching what happens (the business playbook), and the other way round. Registration upserts by
name, so re-running provisioning simply refreshes the timetable.
You have already met the boot trigger, because provisioning depends on it. Fact.System.Boot fires when the platform starts, and provisioning playbooks listen for it to make sure everything they need actually exists — mailboxes, routes, watched folders, storage buckets and schedules.
Boot fires across the whole platform, so provisioning playbooks add one specific filter to make sure they run in the right place — component.eq: playbook-service:
name: register-support-mailbox
description: "Make sure the support mailbox exists on every boot."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- run: mail.register_mailbox
with:
alias: support
imap:
host: "imap.example.com"
port: 993
username: "support@example.com"
password: ${secret.SUPPORT_IMAP_PASS}
The reason this is safe to run on every single boot is that registration steps are designed to be idempotent — running one again when the resource already exists simply confirms it and moves on, rather than creating a duplicate or failing. So a fresh install and the thousandth restart both end up with exactly the same set of mailboxes, routes and schedules. That is the whole point of provisioning: the platform converges on the state you described, no matter how many times it boots.
Secrets live here, and only here. Provisioning playbooks are the one place a credential reference like
${secret.SUPPORT_IMAP_PASS}may appear. Business playbooks refer to resources by the alias a provisioning playbook registered (such asvia: support), so they never touch a password — which keeps them safe to read, review and share.
Boot is for setup, not for work. Use
Fact.System.Bootto declare the resources your business playbooks rely on. Save the actual day-to-day work — replying, importing, reporting — for event and schedule triggers that fire while the platform is running.
Not every resource can be set up at boot, because not every resource is known in advance. When a new customer signs up on an external system, you may want a mailbox created for them immediately — but you obviously cannot list that mailbox in a boot playbook, because the customer did not exist yet. This is where reactive provisioning comes in: an inbound signal triggers the creation of new resources on the fly.
External business events — such as “a new customer just signed up” — reach Binions as HTTP callbacks on a registered route (Fact.Http.Received) or as inbound messages (Fact.Mail.Received). There is no dedicated Fact.Customer.* namespace; external systems push their signals through the HTTP or mail inbound channels, and your playbook extracts the relevant identifiers from the event payload. The pattern combines triggers you already know: a filter narrows the event to the right endpoint or subject, and a dynamic alias built from the payload creates a per-customer resource rather than a hard-coded one.
Here, a webhook from a sign-up service fires the playbook and registers a mailbox named after the customer identifier it carries:
name: register-customer-mailbox-on-signup
description: "Give every new customer their own mailbox when they sign up."
trigger:
event: Fact.Http.Received
filter:
route.eq: customer-signup
steps:
- run: mail.register_mailbox
with:
alias: "customer-${trigger.body.customer_id}"
imap:
host: "imap.example.com"
port: 993
username: "${trigger.body.customer_id}@example.com"
password: ${secret.CUSTOMER_MAILBOX_PASS}
From that moment on, mail to the new address produces ordinary Fact.Mail.Received events, and your existing mail playbooks handle it without any change. The platform grows itself in response to real-world events: boot provisioning lays down the fixed foundations, and reactive provisioning fills in the per-customer details as they arrive.
The same idea covers credential rotation. A provider whose tokens expire — say an OAuth mailbox that needs a fresh token every fifty minutes — can be re-registered on a schedule. The provisioning playbook triggers on Fact.Schedule.Fired and simply registers the resource again with the refreshed secret; because registration is idempotent, the existing resource is updated in place rather than duplicated.
Two kinds of provisioning, one toolbox. Boot provisioning answers “what must always exist?” Reactive provisioning answers “what should exist now that this just happened?” Both use the same triggers and the same registration steps — only the event that starts them is different.
A playbook’s top-level mode field controls how its steps execute once the trigger fires. It does not change what triggers a run, but it does shape how quickly the platform processes a burst of them:
mode: saga (default) — steps run sequentially. Each run: step waits for its response fact before the next one starts, and ${prev} carries that result forward. Every existing playbook uses saga mode automatically; no change is needed.mode: async — run: steps are fire-and-forget. The playbook emits actions without waiting, then uses an explicit wait_for: step to join a specific result when it is ready. This lets a single playbook fan out many actions at once instead of chaining them serially.Either way, the engine handles many playbook runs concurrently. A slow step in one run does not block other runs from starting or completing. If ten instances of Fact.Mail.Received arrive at the same moment, the engine launches up to ten concurrent runs, bounded by the configured concurrency cap. Per-resource ordering is preserved: actions targeting the same resource (the same schedule name, the same mailbox alias) are still processed in order.
For most trigger-driven playbooks, saga mode with sequential steps is exactly right. The async mode is worth reaching for when a run needs to fire several independent, long-running actions (API calls, AI classifications, data transfers) and then join only the results it actually needs, rather than waiting for each one in turn.