Automation in Binions is built on one simple chain: events trigger playbooks that run daemon actions. Before you write a single line of YAML, it pays to hold that whole picture in your head — what an event is, what a playbook is and is not, and why the playbook language is kept deliberately small and declarative. This page is that foundation. Everything hands-on builds on the ideas here.
Note. This page is conceptual — it explains the why. For the vocabulary as a quick glossary, see Core concepts. For a field-by-field tour of a real playbook file, see Anatomy of a playbook.
Binions is an event-driven system. Nothing in it calls anything else directly. Instead, the things that do the work — small, single-purpose background services called daemons — talk to each other by publishing and subscribing to events on a shared internal event bus.
An event is just a typed message that says “something happened”. It might come from one of your apps, from a piece of hardware, from a schedule ticking over, or from another daemon finishing its job. A daemon that cares about that kind of event picks it up and reacts.
The shape of every event is the same. Each one travels in a small, standard envelope — the same outer fields no matter what the event is about — and the event-specific data lives in a payload. Three envelope ideas matter most for the mental model:
| event_type | The dotted name of the event, such as Fact.Mail.Received. This is what a playbook matches against to decide whether to run. |
| correlation_id | A single id shared by every event and action that belongs to one workflow run, so you can follow the whole run from start to finish. |
| causation_id | Points back at the exact event or action that caused this one. It is how a playbook can wait for the specific result of one action it dispatched, even when many are in flight at once. |
Why build it this way? Because loose coupling buys you a great deal. You can add, remove, or replace a daemon without touching the others. Each daemon does its own job at its own pace. If one is briefly down, the events it cares about wait on the bus until it recovers. And because every interaction is an event, the whole system is inspectable — you can watch it happen.
The big idea. Apps and hardware emit events; playbooks turn those events into actions. That one pattern covers everything from invoicing to factory-floor monitoring. For the bigger picture of how the pieces fit, see Architecture overview.
A playbook is a short YAML file that describes a workflow: when something happens, do these things. It is a description, not a program. It holds no general-purpose code and no error-handling boilerplate. You say what you want; the orchestrator works out how to make it happen.
Here is a minimal one. When mail arrives, write a row to the database.
name: log-incoming-mail
description: "Record every incoming message in the database."
trigger:
event: Fact.Mail.Received
steps:
- id: save
run: database.write
with:
table: incoming_mail
row:
from: ${trigger.from}
subject: ${trigger.subject}
That is a complete, valid playbook. Read it top to bottom and you already know exactly what it does. Nothing is hidden. There is no build step and no deploy pipeline — you drop the file in the playbooks folder and the orchestrator picks it up.
A playbook document has a handful of top-level fields:
| name | A unique name for the playbook. |
| description | An optional human-readable note about what it does. |
| enabled | Optional on/off switch (on by default), so you can park a playbook without deleting it. |
| trigger | What starts the playbook — an event to listen for, plus an optional simple filter. |
| mode | Optional execution mode, saga or async (default saga). It decides whether steps wait for each other or fire concurrently — covered under How a playbook runs. |
| steps | An ordered list of things to do, each one a single step. |
The list of steps is where the work happens, and each step is exactly one of four kinds:
| run | Run one daemon operation (always in daemon.operation form), passing parameters under with:. This is the everyday step. |
| parallel | A single-level block of run: actions with no data dependency on each other — the engine starts them together and waits for every one; a single failure fails the step. Because there is no single “previous” among the children, read their results by id: (${steps.<id>.…}) rather than ${prev}. |
| loop | A bounded, counted repetition of a small body of run: steps — with a mandatory hard cap so it can never run away. |
| wait_for | Block until one specific result event arrives. Used to join a result you dispatched earlier (see async mode below). |
Give a step an id: and later steps can refer to its result with ${steps.<id>.X}. That is the entire surface area you need to learn.
The playbook language is kept deliberately small. There are only a few top-level fields and only four kinds of step, and that smallness is completely intentional. The four step kinds — run, parallel, loop, and wait_for — cover sequencing, bounded repetition, and joining results, and there is nothing else to learn.
Just as important is what is deliberately left out:
if or when that branches on the result of a step. The decision lives inside the daemon — you give it the input, it returns the answer.==, no &&, no now() or length(). The only dynamic feature is ${...} interpolation, and it does field access only.${vars.X}, no ${env.X}, and you cannot embed a script in a playbook. (A loop does expose its own counter as ${loop.<name>} inside its body, but that is the full extent of it.)while.To feel the payoff, picture the same workflow in a heavier, fully-featured automation tool. It can sprawl into nested branches, open-ended loops, and embedded code snippets:
# NOT how Binions works — shown only for contrast.
# A heavier tool might let you write logic like this:
on: mail_received
jobs:
process:
if: ${{ message.has_attachments && message.from.endswith('@acme.com') }}
steps:
- for: attachment in message.attachments
run: |
data = extract(attachment)
if data.amount > 1000:
route('finance')
else:
route('general')
That file is a small program. To know what it does, you have to trace control flow, evaluate the condition, and reason about an open-ended loop over arbitrary inline code. In Binions, the same intent stays flat and readable: branching on a result and the inner extraction logic are pushed into the daemons that are built to do them, while sequencing and any bounded repetition stay visible in the playbook.
# How Binions does it — flat, declarative, readable in seconds.
name: invoice-from-accountant
description: "Mail with an invoice -> extract the figures -> store them."
trigger:
event: Fact.Mail.Received
filter:
from.endswith: "@acme.com"
has_attachments: true
steps:
- id: extract
run: ai.extract
with:
text: ${trigger.envelope.body_text}
fields:
- { name: supplier_name, hint: "company issuing the invoice" }
- { name: amount_gross, type: decimal }
- id: save
run: database.write
with:
table: invoices
row: ${steps.extract.result}
No condition to evaluate and no inline code to reason about. The work of pulling out the figures and deciding what counts as an invoice is done by the ai daemon — tested, versioned code — and the playbook simply hands its result to the next step.
Where logic belongs. The playbook owns sequencing: run this, then that, these together, repeat this a bounded number of times, wait for that result. Decision-making — classifying, branching on a value, working through an open-ended list — belongs inside a daemon operation, which is real, tested code. When you reach for a condition or an unbounded loop, a richer daemon operation is the right home for it. A counted, capped loop, on the other hand, is a first-class playbook step — see Looping in playbooks.
Staying small and declarative pays off in concrete ways:
Daemons expose generic, technical operations — never business-specific ones. An operation names a capability, not an outcome. The verb is a technical action; the business meaning is supplied by the playbook.
| The AI daemon | exposes ai.extract — not ai.extract_invoice. |
| The AI daemon | exposes ai.classify — not ai.detect_spam_complaint. |
| The webhook daemon | exposes webhook.send — not webhook.send_customer_welcome. |
So where does the business meaning live? In the playbook — specifically in which operations it calls, in what order, and with what parameters. The same generic ai.classify becomes spam detection, invoice sorting, or language detection purely through its parameters:
# Same generic verb, three different business meanings — all in the parameters.
steps:
- id: triage
run: ai.classify
with:
text: ${trigger.envelope.body_text}
categories: [spam, not_spam]
- id: sort_doc
run: ai.classify
with:
text: ${trigger.envelope.body_text}
categories: [invoice, receipt, contract, other]
- id: detect_lang
run: ai.classify
with:
text: ${trigger.envelope.body_text}
categories: [english, polish, german, french]
Think of a daemon operation as a function in a standard library — read, write, transform — and the playbook as the program that composes those functions for one specific purpose.
This is exactly why the platform scales without constant rework:
ai.extract works for invoices, contracts, and resumes alike. A business-specific operation would work exactly once.ai.extract (say, OCR for scanned PDFs), every playbook that already calls it benefits without a single edit.A quick test. If an operation name contains a noun from your business domain — invoice, customer, refund — that noun belongs in a playbook's parameters, not in the daemon's vocabulary. See The verb vocabulary for the operations daemons actually expose.
There is one more split worth knowing up front, because it shapes how every real playbook looks. Anything sensitive or environment-specific — a credential, or a connection to an external system — is registered once, in its own kind of playbook. From then on, ordinary playbooks refer to that resource by the name it was given, and never contain the raw secret.
Playbooks therefore come in two kinds, in two folders:
${secret.KEY}. They run on platform start, so they re-apply themselves every boot.So a mailbox is registered once, with its password pulled from the secret store:
# provisioning/register-mailbox-invoices.yaml
name: register-mailbox-invoices
description: "Register the invoices mailbox once, 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
host: mail.example.com
username: invoices@example.com
password: ${secret.INVOICES_MAILBOX_PASSWORD}
And every business playbook just refers to that mailbox by its alias — no host, no password in sight:
# business/store-invoices.yaml — references the mailbox by NAME only.
trigger:
event: Fact.Mail.Received
filter:
via.eq: invoices
steps:
- id: save
run: database.write
with:
table: invoices
row:
from: ${trigger.from}
subject: ${trigger.subject}
The result: your business playbooks stay safe to read, safe to share, and safe to keep in version control, because they hold names rather than secrets. This is a whole topic on its own — see Provisioning vs business logic.
With those ideas in place, here is the full life of one run, from event to completion:
Fact.Mail.Received — onto the bus.trigger of every playbook, applies each one's optional filter, and starts the playbooks that match.with: parameters. Whether it waits for each result before moving on depends on the playbook's mode — see the two modes just below.${prev.X} for the step just before, or ${steps.<id>.X} for any named step — so each step can build on the last.loop: repeats its body the agreed number of times; a wait_for: blocks until the specific result it names arrives. (A parallel: block runs independent steps side by side and waits for them all.)Fact.Playbook.Completed event describing the outcome — which can itself trigger another playbook. If any step fails, the run stops and the orchestrator emits Fact.Playbook.Failed instead.How a run treats its run: steps is set by the optional top-level mode field, which defaults to saga:
run: step waits for its own result before the next step starts, and that result is what ${prev} carries forward. This is the behaviour every playbook gets unless you say otherwise, and it is exactly right for the common “do this, then this, then this” workflow where each step depends on the one before.mode: async and run: steps become fire-and-forget: the orchestrator dispatches the action and moves straight on to the next step without waiting for a result. To collect a result you add an explicit wait_for: step that joins one specific response. This lets a single playbook fan out many independent actions at once and then wait only for the few results it actually needs.The contrast is easiest to see side by side. In saga mode the second action would not start until the first returned; in async mode both are dispatched together and you join the one you care about with wait_for::
mode: async
steps:
- id: cls
run: ai.classify # fires, does not wait for the result
with:
text: ${trigger.envelope.body_text}
categories: [invoice, receipt, other]
- run: database.write # fires immediately too — not waiting for the classify
with:
table: incoming
row: { from: ${trigger.from} }
- id: cls_result
wait_for: # now join only the classify result
event: Fact.AI.Classified
match:
causation: ${cls}
A wait_for: step names the result event it expects and matches it by causation id — the id of the earlier fire-and-forget step — so it picks out exactly the right response even when many are outstanding. It can carry its own id:, so the joined result flows forward just like any other step. (If the awaited result does not arrive in time the run fails, the same as any other failed step.)
Why async exists. A handful of fire-and-forget actions plus one or two
wait_for:joins turns what would have been a string of serial waits into one concurrent fan-out. Reach for it when a workflow dispatches several independent actions and only needs to regroup at the end. See Common patterns for worked fan-out/join examples.
Whichever mode a single playbook uses, the platform as a whole processes work concurrently, so you never pay for someone else's slow step:
For you as an author this means a single simple run completes in about a tenth of a second, and the platform keeps that responsiveness even under heavy, sustained load.
That completion step hints at the event naming convention, which is worth a moment because you will read these names constantly. Every event name follows the pattern <Kind>.<Domain>.<Specific>. The events that flow on the bus and trigger your playbooks are facts — the Fact.* family. A fact is a past-tense, read-only statement that something already happened, broadcast to anyone who cares:
| Fact.Mail.Received | A new message arrived (also fires for MQTT and AMQP messages through the mailbox). |
| Fact.Schedule.Fired | A schedule ticked over. |
| Fact.System.Boot | The platform started — the trigger used by provisioning playbooks. |
| Fact.Playbook.Completed | A playbook run finished — a fact you can build the next workflow on. |
Note. You almost always trigger a playbook on a
Fact.*event, because facts are the things that have happened and are safe for anyone to react to. Behind the scenes daemons also exchange other kinds of event — for example, requests for a daemon to do something — but as a playbook author you write against facts. Await_for:step likewise waits for aFact.*result.
Throughout the whole run, one correlation id stays attached to every event and action. That single thread is what lets you trace one execution across every daemon it touched — invaluable when you want to see exactly what happened, in order, even while many other runs are progressing alongside it. You will lean on it constantly when you reach Testing and debugging playbooks.
Runs can chain. Because a finishing playbook emits its own
Fact.Playbook.Completed, that fact can be another playbook's trigger. You compose larger automations out of small ones without ever writing glue code.
That is the whole mental model. Facts arrive on the bus. The orchestrator matches them to playbooks. Playbooks call generic daemon operations — in order, together, repeated in a bounded loop, or joined with a wait — passing results forward, with many runs progressing concurrently. Logic lives in daemons; intent lives in playbooks; secrets live in named provisioning. Hold onto that, and every page that follows is just the detail.