A real automation is rarely a single playbook. It is a small, tidy set of playbooks working together: a few that register the resources, one that describes the actual behaviour, and one that takes everything back down again when you are finished. On this page we build the flagship Binions example — an accountant emails invoices, and Binions reads them, files the PDFs, records the figures, and tells the team — from an empty folder all the way to a production-shaped, hands-off pipeline. We go slowly and show every file in full.
What “complete” means here. Provisioning playbooks make the resources exist, one business playbook reacts to events, and a teardown playbook removes things cleanly. The cookbook of shorter recipes lives on the example workflows page; this page is one big system, explained end to end.
Your accountant sends invoices by email, usually as a PDF attachment. Today someone on the team opens each one, reads off the supplier and the amount, saves the PDF somewhere shared, types a row into a spreadsheet, and posts a note in the finance channel. It is dull, it is easy to get wrong, and it never stops.
We want Binions to do all of it, unattended. When a message arrives from the accountant carrying an attachment, Binions should:
This is exactly the kind of pipeline Binions runs end to end across several services at once: the mailbox picks the message up, the AI step reads the body, the original is filed in object storage, a row is written to the database, and an outbound notification fires. Each service does its part concurrently, so the whole chain completes quickly — fast enough to feel instant from the moment the mail arrives.
Built for throughput. The platform processes work concurrently from end to end — a slow step never blocks the others, and many runs can be in flight at the same time. So this pipeline stays fast even when invoices arrive in a burst; performance scales with the host’s CPU rather than hitting an artificial one-at-a-time ceiling.
Binions keeps its playbooks in three folders, by purpose. Everything lives in git, so the whole automation is a handful of plain-text files you can read, review, and re-apply.
playbooks/
provisioning/
register-mailbox-invoices.yaml # the inbox to watch
register-table-invoices.yaml # where the figures go
register-bucket-invoice-files.yaml # where the PDFs go
register-endpoint-finance-slack.yaml # who to tell
business/
invoice-from-accountant.yaml # the actual behaviour
teardown/
unregister-mailbox-invoices.yaml # take the inbox down cleanly
The order matters. You apply the provisioning/ folder first, because the business playbook refers to those resources by the name each one registers. Then the business/ folder. The teardown folder only comes into play when you are decommissioning the automation.
The naming tells the story. Provisioning files read as
register-<resource>; business files read as<outcome>-from-<source>. Glance at the folder and you can see the setup and the behaviour at once.
Provisioning playbooks are the setup layer. Each one registers a single named resource. They all share the same trigger — Fact.System.Boot filtered to the playbook service — so they re-run automatically every time the platform starts. The register operations are idempotent (they upsert, so running them twice does not create duplicates), which is what makes that safe. This is also the only place a ${secret.KEY} reference is allowed. Here are all four, in full.
This registers the inbox Binions will watch over IMAP, under the short alias invoices. The credentials come from the operator’s secret store, never from the file itself. idle: true asks the mailbox service to hold an IMAP IDLE connection, so new mail is noticed almost immediately rather than on a slow poll.
# provisioning/register-mailbox-invoices.yaml
name: register-mailbox-invoices
description: "Register the invoices@acme.example inbox on every boot."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: mail.register_mailbox
with:
alias: invoices
protocol: imap
imap:
host: imap.acme.example
port: 993
username: invoices@acme.example
password: ${secret.INVOICES_IMAP_PASS}
idle: true
This registers the database table the figures will land in, declaring its columns up front. The business playbook will later refer to it by the name invoices alone — it never sees the schema.
# provisioning/register-table-invoices.yaml
name: register-table-invoices
description: "Create the invoices table on every boot (idempotent)."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: database.register_table
with:
table: invoices
columns:
- { name: supplier_name, sql_type: text }
- { name: invoice_number, sql_type: text }
- { name: amount_gross, sql_type: numeric }
- { name: currency, sql_type: text }
- { name: from_email, sql_type: text }
- { name: received_at, sql_type: timestamptz }
The object-storage bucket for the original attachments — here named invoice-files — is configured on the platform itself: its backend (for example a MinIO server) and access credentials live in the data transporter’s own configuration, not in a playbook. There is therefore no provisioning playbook for the bucket. Business playbooks simply name it with bucket: invoice-files on any data.* step — exactly as the data.upload step in the workflow below does.
This registers where notifications go, under the alias finance-slack. Notice that we never write the real webhook URL in the file — it is held as a secret. The business playbook will fire at it by name.
# provisioning/register-endpoint-finance-slack.yaml
name: register-endpoint-finance-slack
description: "Register the finance Slack channel as a webhook endpoint."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: webhook.register_endpoint
with:
endpoint: finance-slack
url: ${secret.SLACK_FINANCE_WEBHOOK}
method: POST
Never inline a secret. Anything sensitive — a mailbox password, a storage key, a webhook URL — is referenced as
${secret.KEY}and stored by the operator outside the playbook. That keeps the business file safe to read in code review and even to show a customer.
Now the behaviour. This single workflow ties the four registered resources together by name. It has one trigger on top and a short steps list below. Each entry in steps is one of four shapes — a run (a single verb), a parallel block (reserved in the grammar but not yet executed — a parallel step currently fails the run; use mode: async with wait_for: for concurrency), a loop (a bounded, counted iteration), or a wait_for join — so even a complete pipeline stays easy to read. This example uses the simplest of those, a straight sequence of run steps. Here it is in full, and then line by line.
# business/invoice-from-accountant.yaml
name: invoice-from-accountant
description: "Read invoices from the accountant, file them, record them, notify finance."
trigger:
event: Fact.Mail.Received
filter:
via.eq: invoices
from.endswith: "@accountant.example"
has_attachments: true
steps:
- id: extract
run: ai.extract
with:
text: ${trigger.envelope.body_text}
model: gpt-4o-mini
fields:
- { name: supplier_name, hint: "company that issued the invoice" }
- { name: invoice_number, hint: "invoice or document number" }
- { name: amount_gross, type: decimal, hint: "total including taxes" }
- { name: currency, hint: "ISO 4217 code, e.g. PLN/EUR/USD" }
- id: store
run: data.upload
with:
bucket: invoice-files
uid: ${trigger.uid}
- id: write_invoice
run: database.write
with:
table: invoices
row:
supplier_name: ${steps.extract.result}
from_email: ${trigger.from}
mail_uid: ${trigger.uid}
ai_extract: ${steps.extract.result}
received_at: ${trigger.received_at}
- id: notify
run: webhook.send
with:
endpoint: finance-slack
body:
invoice_id: ${steps.write_invoice.id}
from: ${trigger.from}
extract: ${steps.extract.result}
event: Fact.Mail.Received means Binions runs this playbook whenever a new message arrives. The platform emits that fact for the mailbox we registered.via.eq: invoices ties the run to our registered inbox; from.endswith: "@accountant.example" keeps it to mail from the accountant; and has_attachments: true requires at least one attachment. Mail that fails any test is simply ignored — no run, no row.ai.extract is the AI step. It reads the email body, made available as ${trigger.envelope.body_text}, and returns the fields you ask for. The result becomes available to later steps as ${steps.extract.result}.data.upload puts the attachment from this message (referenced by its mail uid) into the invoice-files bucket, so the original document is always kept.database.write inserts a row into the invoices table. This is where the figures become queryable data you can report on later.webhook.send fires at the finance-slack endpoint, carrying the new row’s id and the extracted values, so a human sees the result immediately.How a step reads an earlier step. Each step is given an
id, and later steps reach back into a named result with${steps.<id>.X}. The previous step’s result is also available as${prev.X}. The exact field names inside each result (for example whether extracted fields sit underresultor are spread out, and the precisewith:argument names fordata.upload) belong to each daemon — confirm them against the Daemons reference for your installation.
The pipeline above runs in the default saga mode: each run step waits for its own result before the next one starts, and that result flows forward as ${prev}. That is exactly what you want here — you cannot write the database row until the AI step has read the figures. A playbook is in saga mode unless you say otherwise, so every workflow you have seen so far behaves this way without you doing anything.
When the steps do not depend on each other, you can run them concurrently instead. Set mode: async at the top of the playbook and each run step becomes fire-and-forget: it emits its action and moves on immediately, without blocking on a result. To collect a result you add an explicit wait_for step that joins on the response fact you care about. This lets one playbook fan out many actions at once and then wait only for the few results it needs — high throughput instead of a long serial chain.
mode: async
steps:
- id: extract
run: ai.extract # fires, does not wait
with: { text: ${trigger.envelope.body_text} }
- run: data.upload # fires immediately too, not waiting for extract
with:
bucket: invoice-files
uid: ${trigger.uid}
- wait_for: # now join only the extraction result
event: Fact.AI.Extracted
match: { causation: ${extract} }
id: extracted
Pick the mode to match the work. Use the default saga mode when each step feeds the next, and async with
wait_forjoins when independent actions can run side by side. Both keep the platform’s concurrency working for you — independent runs are always processed in parallel, while actions on the same resource stay strictly in order.
When the automation is no longer needed, you want to remove it cleanly rather than leaving a mailbox being polled and a trigger firing into nothing. Teardown playbooks are the third folder, and they mirror provisioning: where setup registers a resource, teardown unregisters it.
# teardown/unregister-mailbox-invoices.yaml
name: unregister-mailbox-invoices
description: "Stop watching the invoices inbox when the automation is retired."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: unregister
run: mail.unregister_mailbox
with:
alias: invoices
The clean order of removal is behaviour first, then resources: delete the business playbook so nothing new fires, then run the teardown playbooks to unregister the mailbox, the endpoint, and (if you choose) the bucket and table. Whether you keep the stored PDFs and the database rows for the record is a deliberate decision, not an accident.
Confirm the exact teardown verbs. The pattern is firm: provisioning uses
<daemon>.register_*, teardown uses the matching<daemon>.unregister_*. The precise operation name for each resource (for instance the exact verb to drop a registered bucket or table, versus simply leaving the data in place) is in the Daemons reference.
A handful of small files would not be production-shaped on their own. What makes this automation safe to run unattended is a set of properties the platform gives you — you do not write any of this into the YAML.
provisioning/ folder on every boot without fear of a second mailbox or a duplicate table. Setup is a “make sure this exists” operation, not a “create this now” one.Fact.Playbook.Failed. Nothing is silently dropped: that failure fact is itself an event, so another playbook can react to it (alert someone, record the miss, or trigger a retry of its own). There is no hidden step-level retry and no dead-letter file for playbook runs — failures are explicit, and you decide what happens next. If you genuinely want a step retried, you express that yourself, for example a loop with an until condition.You write the happy path; you choose what happens on failure. The playbook grammar has no conditionals (
if/else) or rollback steps by design — but it does give youloopsteps, the saga/async modes, and a reservedparallelstep (not yet executed — usemode: asyncwithwait_for:for concurrency). Branching lives in trigger filters and separate playbooks; compensation, when you need it, is daemon logic or a follow-up playbook reacting toFact.Playbook.Failed. The hard distributed-systems behaviour is implemented once, correctly, in the platform, instead of being re-invented in every workflow.
Step back and look at what we built: six small text files in a git repository. That is the entire automation. There is no canvas hosted somewhere, no separate server to maintain, no glue code drifting out of sync with reality.
This has real, practical consequences:
provisioning/, then business/”. Because provisioning is idempotent and runs on Fact.System.Boot, the platform even re-applies it for you on every restart.The shape always scales the same way. A “complete” automation is not made of bigger playbooks — it is the same small, readable files, composed: a few registered resources, one or more business playbooks against them, and a matching teardown. Once you can build this one, every larger system you build looks exactly the same.