A playbook has only a handful of parts. There is a name, an optional description, an optional mode that picks how the steps are sequenced, one trigger that says when to run, and a list of steps that say what to do. That is the whole shape. Once you can recognise those parts, you can read any playbook in Binions — and write your own. This page takes a playbook apart field by field, with one small, precise example for every piece.
Keep it open beside a real file. Every section below shows the smallest possible snippet for one field, so you can match what you see in your playbook to the part that explains it.
A playbook is a single YAML file. Here is a complete, annotated example with every part labelled. Read it top to bottom, then we will walk each piece below.
name: invoice-to-sql # 1. a short label (required)
description: "Extract invoice fields with AI and save them." # 2. one-liner (optional)
mode: saga # 3. how steps are sequenced (optional, default saga)
trigger: # 4. when to run (required, exactly one)
event: Fact.Mail.Received # the event to listen for
filter: # only fire if these match (optional)
via.eq: accounts-inbox
has_attachments: true
steps: # 5. what to do (required)
- id: extract # a name for this step (optional)
run: ai.extract # daemon.operation = the action
with: # arguments for the operation
text: ${trigger.envelope.body_text}
fields: [supplier_name, amount_gross, invoice_number]
- id: save
run: database.write
with:
table: invoices
row: ${steps.extract.result}
Those five keys — name, description, mode, trigger, and steps — are the entire top level of a playbook. There is nothing else to learn up here.
The top level is a fixed set. Beyond the five keys above, nothing else belongs up here. Keys such as
vars,env,version, orimportsare deliberately not part of the schema. If you see one of those, the file is wrong — see What playbooks deliberately can’t do at the end of this page.
name is the playbook’s label. It is required, it should be unique, and it is what you will see in the event log and the operator console. description is an optional one-line summary, purely for the humans reading the file.
name: daily-revenue-report
description: "Summarise yesterday's orders and email the team."
name — required, a short string. Think of it as the playbook’s identity. By convention it matches the file name (for example daily-revenue-report.yaml).description — optional, one line. Skip it on tiny playbooks; add it the moment the intent is not obvious from the name.The optional mode key decides how the engine sequences your run steps. It defaults to saga, so you can leave it out entirely and existing playbooks keep working exactly as before. There are two values.
saga | The default. Steps run sequentially: each run step waits for its own result fact before the next step starts, and that result flows forward through ${prev}. Write a saga playbook when each step needs the output of the one before it. |
async | Each run step is fire-and-forget: it emits its action and the playbook moves straight on without waiting for a result. To collect a result later you add an explicit wait_for step. This lets one playbook fan many actions out at once and then join only the results it cares about. |
name: enrich-and-store
mode: async # fire actions out, join results explicitly
trigger:
event: Fact.Mail.Received
steps:
# ... fire-and-forget run: steps, then a wait_for: to collect a result
Pick saga unless you measure a reason not to. Saga is the simplest model to read and reason about — do this, then this, then this. Reach for
asyncwhen a playbook fans out several independent actions and the serial wait between them is the bottleneck. Thewait_forstep (below) is what turns fire-and-forget back into a result you can use.
Whichever mode you choose, the platform processes work concurrently underneath. You do not configure this and you do not write anything special for it — it is simply how Binions runs.
What this feels like in practice. A simple single-step run completes in roughly a tenth of a second, and throughput scales with how many runs you push at the host. You get this for free in both
sagaandasyncmode;asyncsimply lets a single playbook take advantage of it across several actions at once.
The trigger block answers one question: when should this playbook run? Every playbook has exactly one trigger. It names an event to listen for, and may add a filter to narrow things down.
Events are named <Kind>.<Area>.<Type>, for example Fact.Mail.Received. Here are the events you will reach for most often:
Fact.Mail.Received | A new message arrived (also fires for MQTT and AMQP messages through the mailbox service). |
Fact.Schedule.Fired | A schedule ticked — this is how time-based playbooks run. |
Fact.Http.Received | An inbound HTTP request reached the gateway — the trigger for webhook-style automations. |
Fact.System.Boot | The platform started — the trigger used by provisioning playbooks. |
The simplest possible trigger is just an event:
trigger:
event: Fact.Mail.Received
A scheduled run listens for Fact.Schedule.Fired — the actual cron timing is set up separately, in a provisioning playbook that registers the schedule by name. The business playbook simply reacts when it fires:
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: nightly-backup # the registered schedule's name
Events, schedules, and inbound requests in depth. The full set of events, how cron schedules are registered, and how inbound HTTP and file drops work are covered on Triggers & scheduling. Here we just need to recognise the shape.
A filter sits under the trigger and decides which events actually start the playbook. It is a map of conditions. Each key is written as <field>.<operator>, and the value is what you compare against.
trigger:
event: Fact.Mail.Received
filter:
via.eq: accounts-inbox # field "via" equals "accounts-inbox"
from.endswith: "@supplier.com" # AND "from" ends with this
This is the complete operator set. There are no others — if you need a comparison that is not here, it cannot be expressed in a filter, and the logic belongs in a daemon instead.
.eq | equals |
.ne | not equals |
.gt | greater than |
.ge | greater than or equal |
.lt | less than |
.le | less than or equal |
.contains | text contains the value |
.startswith | text starts with the value |
.endswith | text ends with the value |
.in | value is in the provided list |
.not_in | value is not in the provided list |
.is_null | the field is absent or null |
.is_not_null | the field is present and not null |
has_attachments | predicate — true if the message has attachments |
One example of each, so the shape of every operator is clear:
filter:
status.eq: settled # equals
status.ne: refunded # not equals
amount.gt: 0 # greater than
amount.ge: 100 # greater than or equal
amount.lt: 10000 # less than
amount.le: 10000 # less than or equal
subject.contains: invoice # text contains
key.startswith: incoming/ # text starts with
key.endswith: ".edi" # text ends with
region.in: [eu-west, eu-central] # in a list
region.not_in: [us-east, us-west] # not in a list
due_at.is_not_null: true # the field is present
has_attachments: true # the predicate (no field, no operator)
Combining filters. When you list more than one condition, all of them must match — they are joined with AND. A bare key with no operator suffix means equals, so filter: { name: daily-tick } is the same as name.eq: daily-tick.
trigger:
event: Fact.Mail.Received
filter:
from.endswith: "@accounts.example" # from an accountant
has_attachments: true # AND carries an attachment
Alternatives: the or: list. One reserved filter key expresses “any of these”: or: holds a flat list of condition maps. Each entry is an ordinary AND-map, and the whole filter matches when every top-level condition matches and at least one or: branch does.
trigger:
event: Fact.Mail.Received
filter:
via.eq: crm-inbox # top-level conditions still AND
or: # ...AND at least one branch:
- subject.contains: "faktura"
- subject.contains: "invoice"
- { from.email.endswith: "@accounting.example", has_attachments: true }
The list is deliberately flat: an or: inside a branch, an empty list, an empty branch, or a branch that is not a map are all rejected at validation. There is still no not and no nesting — three or more truly independent shapes still read best as separate playbooks.
Taming event storms: the trigger governor. Two optional trigger attributes keep a burst of matching facts from spawning a burst of runs — both default off, and both sit next to filter: in the trigger block:
debounce_ms: N — a matching fact arriving less than N ms after the previous run this playbook spawned is dropped (logged and counted, but no run). Bursts collapse into one run.max_concurrent: N — a hard cap on in-flight (queued plus running) instances of this playbook; a matching fact above the cap is dropped rather than queued.trigger:
event: Fact.Mail.Received
filter:
via.eq: crm-inbox
debounce_ms: 2000 # bursts within 2 s collapse into one run
max_concurrent: 4 # never more than 4 in flight
The governor drops, it does not queue. That is the point — a mail storm should not become a backlog that replays for an hour. Suppressed triggers are visible in the daemon’s logs and metrics, and a manual trigger or replay always bypasses the governor: when an operator asks for a run, they get it.
steps is an ordered list. The most common step does exactly one thing: it runs one operation on one daemon. In the default saga mode the steps run top to bottom, one after another, each waiting for the one before it; in async mode the run steps fire without waiting (see mode above and wait_for below).
A run step has three parts. An optional id, a single run action, and a with block of arguments.
steps:
- id: save_reading # optional name for this step
run: database.write # which daemon.operation to call
with: # the arguments for that operation
table: sensor_readings
row:
sensor_id: ${trigger.sensor_id}
value: ${trigger.value}
observed_at: ${trigger.received_at}
id — optional, but recommended. Without it a step still runs; with it, later steps can refer back to this step’s result (see Interpolation below). Use a short, descriptive name like extract or save_po.run — the action, exactly one per run step. It is always <daemon>.<operation>, lowercase and dotted — for example ai.extract, data.upload, or webhook.send. The daemon is the service; the operation is the generic verb it exposes.with — the arguments to the operation. Values can be plain text, numbers, lists, or nested maps (like the row: above). Which arguments an operation accepts depends on the operation.A step is always exactly one of four kinds — a run, a parallel block, a loop block, or a wait_for join. The rest of this page covers each in turn.
Operations are generic technical verbs, never business names. A daemon knows how to ai.extract, not how to “extract an invoice” — the business meaning lives in your playbook, the verb stays reusable.
Which verbs exist, and which arguments they take? The full list of daemons and operations is on Verb vocabulary, and each daemon’s exact arguments are documented under Daemons. Treat argument names in examples as illustrative and confirm them there.
Steps pass data to one another through ${...} references. There are exactly five, and nothing else:
${trigger.…} — a field of the event that started the run.${prev.…} — the result of the step just before.${steps.<id>.…} — the result of any earlier step you gave an id.${loop.<name>} — the loop counter, inside a loop: body.${secret.<KEY>} — a stored credential, in provisioning playbooks only.A reference that is the whole value keeps its type — a number stays a number, a list stays a list; one sitting inside a longer string is turned into text. There is no ${vars.X}, no ${env.X}, and no expressions or functions: a reference only fetches a value, it never computes one.
Full reference. Each form — what it reads, how it is built, and exactly what every verb hands back — is covered with worked examples in Variables & data flow.
A parallel block runs several independent run steps at once: the engine starts every child together and the step completes only when all of them have — a single failed child fails the whole step. It is single-level by design: the list holds plain run: steps, never a nested parallel or loop. Because there is no single “previous” among concurrent siblings, ${prev} is empty right after the block — give any child whose result a later step needs an id: and read it as ${steps.<id>.…}.
- parallel:
- id: local-take
run: ai.classify
with:
provider: local
text: "${trigger.body_text}"
categories: [invoice, complaint, spam, other]
- id: cloud-take
run: ai.classify
with:
provider: deepseek
text: "${trigger.body_text}"
categories: [invoice, complaint, spam, other]
- run: database.write
with:
table: classification_audit
row:
local_category: "${steps.local-take.category}" # by id — not ${prev}
cloud_category: "${steps.cloud-take.category}"
mode: async. Fire steps off without waiting and join their results with wait_for: (see mode — saga or async above). That is the supported way to run work concurrently today.A loop step repeats a block of steps a fixed number of times — the one counted-iteration construct in the grammar. Like parallel, it is a single entry in steps, but instead of a run it carries a loop block: a counted range, a required cap, an optional early exit, and a body of ordinary steps.
steps:
- loop:
for: n # counter, read as ${loop.n} in the body
from: 1 # first value (inclusive)
to: 5 # last value (inclusive); a number or ${...}
max: 5 # REQUIRED hard cap (1-10000) - guarantees it ends
until: # optional early exit, checked after each pass
status.eq: 200
do: # body: ordinary run: steps, run in order each pass
- run: webhook.send
with:
endpoint: health-probe
data: { attempt: ${loop.n} }
max (1–10000) is always required, so a loop can never run unbounded; if the range is larger than the cap, the run stops with a clear error rather than silently trimming it.until exits early. It uses the same operators as a trigger filter and is checked after each pass — ideal for “poll until ready, but no more than N times”.run steps only — no nesting, no parallel inside it — and it counts up by one. Anything larger — a network sweep, time-based repetition, retry-on-failure — belongs elsewhere.Full guide. Worked examples, the exact rules, when to use a loop versus a schedule or a daemon, and every limitation are on Loops & iteration.
A wait_for step pauses the run until a specific response event arrives, then carries that event’s payload forward like any other step result. It is the join half of the fan-out pattern: in async mode your run steps fire without waiting, and wait_for is how you later collect the one result you actually need. (It is only meaningful under mode: async — in saga mode every run already waits for its own result.)
mode: async
steps:
- run: ai.classify # fires, does not wait
id: cls
with:
text: ${trigger.envelope.body_text}
- run: database.write # fires immediately too (not waiting for cls)
with:
table: inbox_log
row: { subject: ${trigger.subject} }
- wait_for: # now join only the classify result
event: Fact.AI.Classified # the response event to wait for (required)
match: { causation: ${cls} } # tie it to the cls step (required)
timeout_ms: 30000 # optional; defaults to the step timeout
id: cls_result # optional id -> ${steps.cls_result} / ${prev}
event — required. The response fact to wait for, named the usual Fact.<Area>.<Type> way (for example Fact.AI.Classified).match — required. It must carry causation: ${<run-id>}, pointing at the id of the earlier fire-and-forget run step whose result you are joining. This is what ties the awaited fact to the action that produced it.timeout_ms — optional. How long to wait before giving up; it defaults to the standard step timeout. If the awaited event never arrives in time, the run fails with Fact.Playbook.Failed, exactly as any other failed step would.id — optional. Give the wait_for an id and later steps can read the joined result through ${steps.<id>} or, if it is the previous step, ${prev}.The pattern to remember: fire several independent run steps at once, then add one (or a few) wait_for steps to join only the results you need. N actions firing together plus one join is high concurrency, instead of N actions waited on one after another.
Fan-out lives on the Patterns page. The full fan-out / join recipe, with worked examples of when async pays off, is on Playbook patterns.
Playbooks are intentionally simple. They orchestrate; they do not compute. That is why the grammar leaves out almost everything you would expect from a programming language:
when: or if:. To branch, filter on the trigger, or write a second playbook.for_each over arbitrary data and no general-purpose iteration. The one exception is the bounded loop step — a counted range with a mandatory cap (see Loops & iteration). For per-item work over a list, a daemon operation still handles the list internally.Fact.Playbook.Failed; if you need bounded retries, a loop with an until exit can poll a few times, and anything richer belongs in a daemon. (Note that waiting is supported — that is exactly what the wait_for step does.)mode field chooses how steps are sequenced (saga or async), but it does not roll earlier steps back; transactional all-or-nothing logic belongs inside a single daemon operation.${vars.X} or ${env.X}. The grammar is not a programming language, by design.This is a feature, not a gap. Anything that needs branching, open-ended looping, retries, or transactions lives in daemon code, where it can be written, tested, and reviewed properly. Playbooks stay short and auditable: read the YAML and you know exactly what will happen, in what order, with no hidden logic. If a workflow seems to need more than the grammar allows, the right move is a richer daemon operation, not a more complex playbook.