In this guide you’ll build a real, working automation from scratch — not a toy. You’ll register the resources it needs, write the logic that reacts to an event, deploy the files, run it for real, and confirm it worked by reading the platform’s own event log. By the end you’ll have a complete provisioning + business pair on disk and the muscle memory to write your next one in minutes.
Before you start. You need Binions already installed and running on your host — see Your first automation for the five-minute tour, and First boot if the platform isn’t up yet. This page is the deeper, end-to-end version of that intro: same example, the whole lifecycle. You should be comfortable editing a file over SSH.
A small but genuinely useful automation: an invoice catcher. When an email lands in a watched mailbox, Binions reads the body, asks an AI daemon to pull out a few key fields (supplier, invoice number, amount), and writes them as a row in a database table. No copy-paste, no manual data entry. This exact pipeline — mail in, AI extract, database write — was the first end-to-end workflow ever proven on the platform, and a real run completes in about five seconds.
It comes in two halves, and that split is the heart of how Binions is organised:
Why two files, not one? Keeping setup (with secrets) separate from logic (names only) means your business playbooks stay clean and shareable, and your whole platform configuration can live in git as plain text. It’s the same idea as infrastructure-as-code, applied to automations. More on this in Provisioning vs business playbooks.
First we register the resources. Provisioning playbooks all share the same trigger: they fire on Fact.System.Boot, filtered to the playbook service. That means they re-run automatically every time the platform starts, and the register operations are idempotent — they upsert rather than duplicate, so running them again is always safe.
Create a file called register-accounts-inbox.yaml. It tells the mailbox daemon how to reach your inbox and gives it a short alias the business playbook can use later:
name: register-accounts-inbox
description: "Register the accounts mailbox as an alias on every boot."
trigger:
event: Fact.System.Boot # fires once when the platform starts
filter:
component.eq: playbook-service # only react to our own boot signal
steps:
- id: register
run: mail.register_mailbox # provisioning verb: set up a mailbox
with:
alias: accounts-inbox # the NAME business playbooks will use
protocol: imap # default; mqtt and amqp are also supported
imap:
host: imap.example.com
port: 993
username: accounts@example.com
password: ${secret.ACCOUNTS_IMAP_PASSWORD}
idle: true # watch in real time (IMAP IDLE)
Line by line:
name / description — a human-readable label. The file name and the name usually match.trigger — the fixed provisioning trigger. Every file in provisioning/ uses this same pair so it runs at boot and re-runs idempotently.run: mail.register_mailbox — one generic verb that sets up a mailbox. The same verb handles email, MQTT, and AMQP — you choose with protocol.alias: accounts-inbox — the only part the business side cares about. From now on, “the accounts mailbox” is just accounts-inbox.${secret.ACCOUNTS_IMAP_PASSWORD} — a reference to a stored credential, never the password itself. Secrets live in a protected file on the host and are resolved at run time. ${secret.KEY} is only allowed in provisioning playbooks — the business half can never see it. See Secrets & credentials for how to add one.Now the destination. Create register-table-invoices.yaml to define the table the business playbook will write into:
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 # provisioning verb: define a table
with:
table: invoices # the NAME business playbooks will use
columns:
- { name: from_email, sql_type: text }
- { name: subject, sql_type: text }
- { name: mail_uid, sql_type: text }
- { name: ai_extract, sql_type: jsonb }
- { name: received_at, sql_type: timestamptz }
Flagging the column shape. The
columnslist above is illustrative — it shows the idea (a name and a type per column), andjsonbis the natural type for the AI’s structured output. The exact keys aregister_tablestep accepts belong to the database daemon; confirm the precise field names in the Daemons reference before relying on them.
With the mailbox and table registered, the logic is short. Create invoice-catcher.yaml. Notice there are no secrets and no connection details here — only the names from Step 1:
name: invoice-catcher
description: "When mail arrives, extract invoice fields with AI and save them."
trigger:
event: Fact.Mail.Received # a new message landed
filter:
via.eq: accounts-inbox # ...in the mailbox we registered
steps:
- id: extract
run: ai.extract # hand the body to the AI daemon
with:
text: ${trigger.envelope.body_text}
fields:
- { name: supplier_name, hint: "company that issued the invoice" }
- { name: invoice_number, hint: "invoice or document number" }
- { name: amount_gross, hint: "total amount including taxes" }
- id: write_invoice
run: database.write # save a row into the invoices table
with:
table: invoices
row:
from_email: ${trigger.from}
subject: ${trigger.subject}
mail_uid: ${trigger.uid}
ai_extract: ${steps.extract.result}
received_at: ${trigger.received_at}
Reading it top to bottom:
trigger — fire on every Fact.Mail.Received event, but only for messages that arrived via the accounts-inbox alias. The via.eq filter is what links this logic to the mailbox you provisioned.extract — pass the email body (${trigger.envelope.body_text}) to the AI daemon and ask for three named fields. You describe what to pull out; the daemon does the work. The result lands at ${steps.extract.result}.write_invoice — insert one row. ${trigger.from}, ${trigger.subject}, ${trigger.uid} and ${trigger.received_at} come straight off the email; ${steps.extract.result} reuses the AI output from the previous step.The expression language — and the four step forms. There are only a handful of placeholders:
${trigger.X}reads a field from the event,${steps.<id>.X}reuses a named step’s result, and${prev.X}is shorthand for the immediately preceding step. Playbooks have no conditional branches — the trigger filter is the only decision point. For bounded iteration theloop:step is available, and in async mode await_for:step lets you fan out actions and join only the results you need. Each step uses exactly one of four forms —run:,parallel:,loop:, or (in async mode)wait_for:. (Aparallel:block runs independent steps together and waits for them all; give its children anid:and read their results with${steps.<id>.…}.) This guide focuses onrun:steps; see Anatomy of a playbook for the others and for themode:field.
Playbooks live on your host under /opt/binions/playbook-service/playbooks/, split into folders by kind. Copy each file into its home: the two provisioning files into provisioning/, and the business file into business/:
# Provisioning — registers the mailbox and the table
sudo cp register-accounts-inbox.yaml \
/opt/binions/playbook-service/playbooks/provisioning/
sudo cp register-table-invoices.yaml \
/opt/binions/playbook-service/playbooks/provisioning/
# Business — the day-to-day logic
sudo cp invoice-catcher.yaml \
/opt/binions/playbook-service/playbooks/business/
That’s the whole “deployment.” A playbook is just a file in the right folder — there’s nothing to compile and no build step. The three folders each have a clear job:
| Folder | What goes here |
|---|---|
provisioning/ | Files that register resources at boot — mailboxes, tables, buckets, schedules. The only place secrets appear. |
business/ | The logic that reacts to events. Refers to resources by name. No secrets, ever. |
teardown/ | Optional files that unregister a resource or rotate a key when it’s no longer needed. |
It’s worth validating the files before copying them into place. Plain validate checks the grammar — a malformed filter, a missing field — and adding --dry-run also catches a typo in a verb name, a reference to a step that does not exist, or a missing secret, all without running anything:
binions-cliconsole validate --dry-run \
/opt/binions/playbook-service/playbooks/business/invoice-catcher.yaml
If the file is well-formed, validate reports success. Binions watches the playbooks directory and picks up new or changed files on its own — within a couple of seconds, with no restart and no reload command. A file in provisioning/ runs the moment it loads; a file in business/ loads and then waits for its trigger.
Now make it happen. Send a test email to the mailbox you registered (any subject, any body that looks vaguely like an invoice). That triggers the whole chain. To watch it unfold, open the event stream in one terminal — every step the platform takes is recorded as an event:
# Live, human-readable stream of platform events
binions-cliconsole ls events
# Or tail the raw event log and pretty-print it
tail -f /var/log/binions/events.jsonl | jq
# Or follow the playbook service's own journal
journalctl -u binions-playbook --since "5 minutes ago" --no-pager
A successful run reads like a little story. In the event stream you’ll see the mailbox notice the message, the playbook start, each step finish, and the playbook complete — a sequence like this:
Fact.Mail.Received — the message arrived via=accounts-inbox.Fact.Playbook.Started — invoice-catcher matched and began.Fact.AI.Extracted — the AI returned the fields you asked for.Fact.Database.Inserted — a new row landed in invoices, with its id.Fact.Playbook.Completed — status=Completed. Done.The signal you’re looking for is a Fact.Playbook.Started followed by a Fact.Playbook.Completed with status=Completed. Every event carries the same correlation id, so you can trace one email all the way from arrival to the database row. To confirm the data really landed, look in the table:
binions-cliconsole emit Action.Database.Query \
--table invoices --order-by 'id desc' --limit 1
Flagging the query command. The exact flags for an ad-hoc query through the console vary — the line above is illustrative. The reliable check is the event log: a
Fact.Database.Insertedevent with a rowidconfirms the write. For the precise arguments, see the database entry in the Daemons reference.
Most first-run problems fall into a few buckets, and the platform tells you which one in plain language.
Fact.Playbook.Started, the trigger didn’t match. Check that the via.eq alias in the business playbook is exactly the alias you used in mail.register_mailbox — they have to match character for character.Fact.Playbook.Failed with a reason like resource_not_registered and missing: "mailbox:accounts-inbox" (or table:invoices), the business playbook ran before its resource was provisioned. Reload to re-register the provisioning playbooks — then send another test mail.Fact.Playbook.Failed event and halts the rest of the steps, so nothing runs in a half-finished state. Find the failure on the event log:# Show the most recent failed playbook runs
binions-cliconsole ls events | grep Fact.Playbook.Failed
Each Fact.Playbook.Failed event includes the step that failed, the arguments it was given, and the error — usually enough to spot a typo at a glance. Because every event in a run shares one correlation id, you can follow that id back through the log to see exactly how far the run got. For a structured walkthrough of reading logs, replaying a run, and isolating a bad step, see Testing & debugging.
One field-name gotcha. When a later step reuses an earlier one,
${steps.<id>.X}points at that step’s result payload, not a wrapper around it. After a write step, the new row id is at${steps.write_invoice.id}— not${steps.write_invoice.result.id}. If an interpolation comes back empty, it’s often one level too deep.
You now have a working pair. Here are small, safe variations to try next — each is a one- or two-line change:
from.endswith: "@accountant.com" and has_attachments: true. All filters under one filter: must match together; when you need alternatives — a subject containing either “invoice” or “faktura”, say — add a flat or: list of branches to the filter, and the mail must also match at least one branch.run: webhook.send with the new row id in the body.protocol: mqtt) and the same Fact.Mail.Received trigger will fire for sensor messages — the business playbook barely changes.Fact.Schedule.Fired — perfect for a nightly report.mode: async at the top of the playbook, issue several run: steps (they fire without waiting), then add a wait_for: step to join the result you need. See Playbook patterns for a complete fan-out example.For complete, copy-ready recipes that combine these ideas, browse Example workflows.