Good playbooks are small, readable, and safe to run again. A Binions playbook has four kinds of step — run:, parallel:, loop:, and wait_for: — plus a top-level mode: field (saga or async). The quality of a playbook comes from how you use them, not from clever tricks. This page collects the habits that keep playbooks tidy: each one is a single principle with a short example, and where it helps, a less-good version to compare against.
The golden rule. A playbook describes what should happen, never how. Conditionals (
if/when) and free-form expressions belong inside a daemon. Bounded counted repetition usesloop:; anything that requires branching on intermediate results belongs in the daemon. Keep the YAML readable.
A healthy playbook is a handful of steps. Each step calls exactly one daemon operation. If a playbook grows much past five steps, that is a signal — not that your YAML needs to be cleverer, but that one of the daemons should do more of the work in a single, richer operation.
data.parse reads the document’s text layer and ai.extract pulls the fields you name out of it — your playbook calls each once.This tries to do everything in YAML: fetch, classify, branch, move, save, notify — far too many moving parts, and it leans on branching the language does not have.
# DON'T: too many steps, and it assumes if/when conditionals that the grammar does not support
steps:
- id: classify
run: ai.classify
with: { text: ${trigger.envelope.body_text} }
- id: extract
run: ai.extract
with: { text: ${trigger.envelope.body_text}, model: gpt-4o-mini, fields: [ {name: supplier_name}, {name: amount_gross} ] }
# ...then a branch on the classification, a move, a save, a webhook...
# six or more steps, plus if/when branching the grammar does not support
One generic extract operation does the heavy lifting (it reads the message body and pulls the fields you ask for), one step saves the result, and one webhook tells the team. Three small steps, nothing to learn.
name: invoice-to-sql
description: "Save an accountant's invoice to the database and ping Slack."
trigger:
event: Fact.Mail.Received
filter:
via.eq: office
from.endswith: "@accountant.example.com"
has_attachments: true
steps:
- id: extract
run: ai.extract
with:
text: ${trigger.envelope.body_text}
model: gpt-4o-mini
fields:
- { name: supplier_name }
- { name: amount_gross, type: decimal }
- { name: currency }
- { name: invoice_number }
- run: database.write
with:
table: invoices
row: ${steps.extract.result}
- run: webhook.send
with:
endpoint: slack-alerts
body: ${steps.extract.result}
Good to know. Three steps is still tiny — the limit is a guideline, not a hard cap. The point is to notice when a playbook is doing work a daemon should own.
${prev} chains themUnless you say otherwise, a playbook runs in saga mode: the steps execute in order, and each run: step waits for its own response before the next one starts. The result of the step just finished is available to the next as ${prev}, and any earlier step you gave an id: stays reachable as ${steps.<id>.X}. This is the default for a reason: it is the easiest model to reason about, and it is what most playbooks want.
${prev}. The freshly completed step’s payload is always ${prev}; name a step with id: when you need to reach back further than the immediately previous one.# Saga (the default): each step waits, ${prev} carries the result forward
steps:
- run: ai.extract
with:
text: ${trigger.envelope.body_text}
model: gpt-4o-mini
fields:
- { name: supplier_name }
- { name: amount_gross, type: decimal }
- run: database.write # waits for the extract above to complete
with:
table: invoices
row: ${prev.result} # the payload of the step just finished
Good to know. Sequential here means within one run. Across runs, the platform processes work concurrently: a slow step in one run never blocks other runs, the engine keeps many runs in flight at once, and operations that target the same resource still happen in order. You get tidy per-run sequencing and high overall throughput at the same time.
mode: asyncWhen you want a single playbook to kick off several actions at once and then collect only the results you actually need, switch the whole playbook to async mode. In async mode a run: step is fire-and-forget: it emits the action and moves on immediately, without waiting for a response. To collect a result, you add an explicit wait_for: step that blocks for one specific response fact. Many actions fired at once, joined by one or two waits, is far faster than the same actions taken one after another.
run: step returns control instantly, so independent actions all start together. You only pay the round-trip cost on the results you actually wait_for:.id:. A wait_for: step joins on the causation of the action it is waiting on, so the run: step you want to collect must carry an id:.wait_for: step names the response event: it expects and a match: tying it back to the fired step via causation: ${<step-id>}.Here the classification and the database write both start immediately; the playbook then joins only the classification result. In saga mode the write would have waited for the classify; in async both fire at once.
mode: async
name: triage-incoming
trigger:
event: Fact.Mail.Received
filter:
via.eq: support
steps:
- id: cls
run: ai.classify # fires; does not wait
with:
text: ${trigger.envelope.body_text}
- run: database.write # fires immediately too, not waiting for cls
with:
table: tickets
row: { subject: ${trigger.envelope.subject} }
- id: cls_result
wait_for: # now join only the classify result
event: Fact.AI.Classified
match: { causation: ${cls} }
timeout_ms: 30000 # optional; defaults to the step timeout
- run: webhook.send
with:
endpoint: slack-alerts
body: ${steps.cls_result.result}
Key idea. Reach for async when one playbook needs to set several things in motion and the order between them does not matter — the high-throughput counterpart to a sequential saga. A
wait_for:that never receives its fact ends the run withFact.Playbook.Failedwhen its timeout elapses, just like any other failure.
loop:When you need to repeat the same operation a fixed, counted number of times — page through a feed, send a known batch, retry a register across a known list — use a loop: step. It is the one control-flow construct the grammar offers, and it is deliberately bounded: a loop always has a counter with a clear start, end, and a mandatory hard cap, so it can never run away.
for: names the counter; inside the body it is available as ${loop.<name>}. from: and to: are inclusive integer bounds.max: is mandatory. It is a hard cap between 1 and 10000. If the computed range would exceed it, the run aborts loudly — it never silently clamps. This is your safety net against an accidental runaway.run: steps. A loop body accepts only run: steps — no nested loop, no parallel: inside. Keep each iteration simple.until: condition is checked after each iteration (do-while), so the loop can stop early when a result says it is done. An optional step id: lets later steps reference the loop.# Page through up to five batches of a feed; stop early when a batch comes back empty
steps:
- loop:
for: page # counter name; body reads it as ${loop.page}
from: 1 # inclusive
to: 5 # inclusive
max: 100 # MANDATORY hard cap (1..10000); range over it aborts
until: { count.eq: 0 } # optional post-iteration early exit
do: # body: run: steps only
- run: data.download
with:
source: orders-feed
page: ${loop.page}
Watch out.
loop:is for repetition over a known count, not for branching. There is still noif/whenon step results and no expression language — if an iteration needs to decide something at runtime, that decision belongs inside the daemon operation it calls.
Daemon operations are technical verbs, never business nouns. You will see ai.extract, ai.classify, data.upload — you will never see mailbox.process_invoice. The business meaning lives in the parameters you pass, not in the name of the verb.
ai.extract pulls invoice figures today and contract dates tomorrow — you just change the fields: list.ai.extract (say, an OCR fallback for scanned PDFs), every playbook that already uses it benefits without a single edit.| Avoid (business noun) | Prefer (technical verb + parameters) |
mailbox.process_invoice | ai.extract with fields: [ {name: supplier_name}, {name: amount_gross} ] |
ai.summarise_complaint | ai.inject with a summary prompt and the complaint text |
webhook.send_invoice_notification | webhook.send to the slack-alerts endpoint with the data |
A quick test: if a verb name contains a word from your business (invoice, contract, order, ticket), it is too specific. Rename the intent into parameters and use the plain technical verb. See The verb vocabulary for the full menu.
Your day-to-day business playbooks should be safe to read aloud, paste into a chat, or show a client. They achieve that by referring to everything — mailboxes, tables, endpoints, AI templates — by a short alias. The actual passwords, URLs, and tokens live only in provisioning playbooks, behind the one allowed secret reference: ${secret.KEY}.
via: office, table: invoices, endpoint: slack-alerts — and never see a credential.${secret.KEY} may appear only in a provisioning playbook. Anywhere else it is a mistake.# provisioning/register-office-mailbox.yaml (secrets live here, and only here)
name: register-office-mailbox
description: "Register the office mailbox as the alias 'office'."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- run: mail.register_mailbox
with:
alias: office
imap:
host: imap.example.com
port: 993
username: office@example.com
password: ${secret.OFFICE_IMAP_PASS}
# business/invoice-to-sql.yaml (no credentials anywhere — safe to share)
trigger:
event: Fact.Mail.Received
filter:
via.eq: office
steps:
- run: ai.extract
with:
text: ${trigger.envelope.body_text}
model: gpt-4o-mini
fields:
- { name: supplier_name }
- { name: amount_gross, type: decimal }
Security habit. If you ever feel the urge to paste a password or an API URL into a business playbook, stop — register it in a provisioning playbook instead and reference the alias. Your secrets stay in one auditable place. More on this in Secrets & credentials.
Give each named resource its own provisioning file: one mailbox, one table, one bucket, one route, one schedule per file. Every registration operation is a verb ending in register_*, and these are designed to be idempotent — running them again does an upsert, not a duplicate. That is exactly why provisioning playbooks trigger on Fact.System.Boot: they safely re-apply themselves every time the platform starts.
provisioning/* playbooks recreates the entire platform configuration. That is infrastructure-as-playbooks.Consistent file names make the folder self-explanatory:
| Pattern | Example | Meaning |
register-<alias>.yaml | register-office-mailbox.yaml | Register one named resource. |
register-<alias>-on-<event>.yaml | register-customer-mailbox-on-signup.yaml | Register reactively when a business event fires. |
unregister-<alias>.yaml | unregister-office-mailbox.yaml | Remove a resource (teardown). |
<outcome>-from-<source>.yaml | invoice-from-accountant.yaml | A business reaction: what arrives, and from where. |
<outcome>-<cadence>.yaml | newsletter-monday-morning.yaml | A scheduled business reaction. |
A trigger fires for every matching event, so make the match as narrow as the job. Combine several conditions under one filter: — all of them must be true — so the playbook only wakes up when it genuinely should. Filtering in the trigger is cheaper and clearer than letting the playbook start and then deciding it had nothing to do.
.eq .ne .contains .startswith .endswith .gt .ge .lt .le .in .not_in .is_null .is_not_null, plus the predicate has_attachments.or: list inside the filter holds alternative branches — the event must match every top-level condition and at least one branch. Reserve separate playbooks for three or more genuinely independent shapes, where distinct files read better than one crowded filter.# DON'T: fires for every message in every mailbox
trigger:
event: Fact.Mail.Received
# DO: only invoices, only from the accountant, only in the office mailbox
trigger:
event: Fact.Mail.Received
filter:
via.eq: office # top-level conditions all AND together...
from.endswith: "@accountant.example.com"
has_attachments: true
or: # ...and at least one branch must match
- subject.contains: "invoice"
- subject.contains: "faktura"
parallel:, or simply in sequenceWhen several steps have no data dependency on each other — neither needs the other’s result and the order genuinely does not matter — put them in a parallel: block: the engine starts the children together and moves on only when every one has finished, and a single failed child fails the step, so nothing half-happens silently. Keep it single-level (children are plain run: steps — no nested parallel: or loop:), and remember there is no single “previous” among concurrent siblings: right after a parallel:, ${prev} is empty — give any child whose result you need an id: and read ${steps.<id>.…}. For two quick steps the sequential cost is small, so listing them one after another is also fine; reach for mode: async with wait_for: when you want fire-and-forget fan-out with selective joins rather than a strict start-together-join-all block.
After extracting the data, saving it to the database and posting to Slack have nothing to do with each other; listing them one after another keeps the playbook simple and correct.
steps:
- id: extract
run: ai.extract
with:
text: ${trigger.envelope.body_text}
model: gpt-4o-mini
fields:
- { name: supplier_name }
- { name: amount_gross, type: decimal }
- run: database.write
with:
table: invoices
row: ${steps.extract.result}
- run: webhook.send
with:
endpoint: slack-alerts
body: ${steps.extract.result}
Watch out. Right after a
parallel:block,${prev}does not point at any child — there is no single “previous” among concurrent siblings. Give each child whose result you need anid:and read it as${steps.<id>.…}in the steps that follow.
You do not write error branches in YAML — the platform handles failure for you. When a step fails, Binions stops the run and emits a Fact.Playbook.Failed event carrying the run’s correlation id, so nothing is silently lost — you can find the failure on the event log and re-run the playbook once the cause is fixed. Your job is simpler but important: make each step safe to run more than once.
on_failure or retry block, and a failed step is not automatically retried or sent to a dead-letter file — if a workflow seems to need that, the right move is a richer daemon operation.Tip. Before you ship a playbook, ask “what happens if this step runs twice?” If the answer is “harmless”, you are done. If it is “a duplicate”, give the write a stable identifier so the daemon can recognise the repeat. For how to trace a failed run by its correlation id, see Testing & debugging.
Playbooks live in git and evolve like any other code — but how you evolve them matters. The guiding idea: grow daemon operations functionally, not by inventing business-named verbs.
ai.extract, language detection inside ai.classify — rather than a new process_invoice-style verb. Existing playbooks keep working and quietly get better.extract-invoice-v2). Each business playbook chooses its version explicitly, and the old one stays available until everything has moved over.provisioning/* files as the source of truth: change the file and re-apply, rather than editing the live resource directly.Good to know. A new business workflow should almost never require a daemon change. If adding a workflow means editing a daemon, that is a hint the verb was too specific — revisit the parameters instead.
The usual traps. Most broken playbooks come from trying to make YAML do a programming language’s job. Steer clear of these:
if/when branching on step results and no expression language (==, &&, now(), length()). Bounded counted iteration uses loop:; anything that requires a runtime decision belongs in the daemon. Interpolation is limited to ${trigger.X}, ${prev.X}, ${steps.<id>.X}, ${secret.KEY} (provisioning only), and ${loop.<for>} inside a loop body.loop: must always have a max:; if the range exceeds it the run aborts loudly rather than running away. Never reach for a loop to poll something indefinitely — trigger on the event instead, or join a fired action with wait_for:.mailbox.process_invoice ties a daemon to your business and makes its vocabulary grow the wrong way. Use a technical verb and carry the meaning in parameters.parallel:. The block is single-level by design — its children are plain run: steps, never another parallel: or a loop:. And because one failed child fails the whole step, keep the children genuinely independent.