Binions keeps two jobs apart. Provisioning playbooks create the durable things your automation leans on — a mailbox, a table, a bucket, a route — while business playbooks react to events and use those things by name. A third, smaller kind, teardown playbooks, removes them cleanly when you no longer need them. Learn this split and the rest of the playbook system falls into place.
If you have ever set up a real system by hand, you know the mess: the database password ends up copied into three scripts, nobody can say what the system actually does without reading all of it, and one careless re-run drops a table you needed. Binions avoids that with a single organizing idea — set up your resources once, then write small playbooks that just react. The setup half is where credentials and creation live. The reacting half stays clean, readable, and safe to share.
A picture helps. Think of provisioning as laying the table: you put out the plates and glasses once. Business playbooks are serving the meal: you do that many times, and you never re-lay the table just to serve another course.
The one-line version. Provisioning registers a resource (and is the only place a secret appears). Business logic refers to that resource by its name or alias. Teardown unregisters it. Same playbook grammar for all three — only the folder and the trigger differ. The step vocabulary spans four variants —
run:,parallel:,loop:, andwait_for:— thoughparallel:is reserved in the grammar and not yet executed (aparallelstep currently fails the run; usemode: asyncwithwait_for:for concurrency). This page focuses on the provisioning/business split; see Anatomy of a playbook for the complete grammar.
Every playbook lives in one of three folders. The folder tells you, at a glance, which lifecycle phase a file belongs to — and it is how the platform decides when to run the file.
/opt/binions/playbook-service/playbooks/
├── provisioning/ # SETUP · credentials · run once at boot
│ ├── register-office-mailbox.yaml
│ ├── register-table-invoices.yaml
│ └── register-uploads-bucket.yaml
├── business/ # REACT · day-to-day logic · run on events
│ ├── invoice-from-accountant.yaml
│ ├── newsletter-monday-morning.yaml
│ └── high-value-order-alert.yaml
└── teardown/ # CLEANUP · unregister / rotate · rarely
├── unregister-office-mailbox.yaml
└── rotate-api-keys-monthly.yaml
The playbook service loads all three folders, but it fires them at different moments:
The folder is not just tidiness. A “create the bucket” file dropped into business/ would never run at boot — it would sit there waiting for an event that never comes. Put creation in provisioning/, reactions in business/.
A provisioning playbook has exactly one job: make a named resource exist. It is always triggered by the same event — Fact.System.Boot — filtered to the playbook-service component so it runs only on the node that owns the playbooks. Because boot happens on every start, these playbooks re-run safely: the register_* verbs do an upsert, so a second run never creates a duplicate.
Here is the simplest one. It registers an office mailbox — an IMAP/SMTP account — under the alias office. Notice this is the only place credentials appear, and they come through ${secret.KEY} rather than being typed inline:
# provisioning/register-office-mailbox.yaml
name: register-office-mailbox
description: "Register the office mailbox on every boot."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: mail.register_mailbox
with:
alias: office
imap:
host: imap.company.example
port: 993
username: office@company.example
password: ${secret.OFFICE_IMAP_PASS}
smtp:
host: smtp.company.example
port: 465
password: ${secret.OFFICE_SMTP_PASS}
idle: true
The same shape registers a database table. The resource is given a plain name — invoices — that business playbooks will later use without ever seeing the schema again:
# provisioning/register-table-invoices.yaml
name: register-table-invoices
description: "Register the invoices table on every boot."
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: amount_gross, sql_type: decimal }
- { name: currency, sql_type: text }
- { name: invoice_number, sql_type: text }
- { name: received_at, sql_type: timestamptz }
Object storage is a little different. A storage bucket (S3, SFTP, FTP, MinIO, local filesystem, …) is configured on the platform itself — it is not registered from a playbook step. Once a bucket is configured, business playbooks simply refer to it by its bucket: name on any data.* step (upload, download, list, delete, transform); omitting bucket: falls back to the default bucket. So there is no provisioning playbook for a bucket — you reference the configured name directly:
# business/store-upload.yaml — referencing a configured bucket by name
steps:
- id: save
run: data.upload
with:
bucket: supplier-edi # a bucket configured on the platform
key: edi/${trigger.message_id}.json
body: ${trigger.body_text}
One more pattern worth seeing — registering a schedule. The scheduler daemon turns a cron expression into a recurring event, Fact.Schedule.Fired, which a business playbook can then react to. Schedules need no secret, but they are still provisioning because they register a named resource at boot:
# provisioning/register-schedule-nightly.yaml
name: register-schedule-nightly
description: "Register a 02:00 daily schedule the report playbook reacts to."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: scheduler.register_schedule
with:
name: nightly-report
cron_expr: "0 2 * * *" # every day at 02:00
Secrets live here, and only here. A credential is referenced as
${secret.KEY}— resolved at run time from the host's secret store — and it is allowed only in a provisioning playbook. After registration, the resource is known by its alias, so business playbooks never touch the secret again. (How${secret.KEY}resolves is covered on the Secrets page.)
Provisioning must be idempotent. Because Fact.System.Boot fires on every start, each step has to be safe to run again. The register_* verbs are built for exactly this: they upsert rather than blindly create, so re-registering the office mailbox does not make a second one.
Business playbooks are where the actual work happens. They react to events and lean on the resources provisioning already created — always by name or alias, never by a credential or a connection string. A business playbook contains zero secrets, which is what makes it safe to read, review, and share.
The smallest one watches the office mailbox and saves any invoice from the accountant. It refers to the mailbox by its alias (via.eq: office) and to the table by its name (table: invoices) — nothing else:
# business/invoice-from-accountant.yaml
name: invoice-from-accountant
description: "Extract invoices arriving in the office inbox and store them."
trigger:
event: Fact.Mail.Received
filter:
via.eq: office # the mailbox alias from provisioning
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 }
- { name: amount_gross, type: decimal }
- { name: currency }
- { name: invoice_number }
- id: store
run: database.write
with:
table: invoices # just the name — no schema, no credentials
row: ${steps.extract.result}
- id: alert
run: webhook.send
with:
endpoint: slack-alerts # an endpoint alias, not a URL + token
body: ${steps.extract.result}
A business playbook does not have to react to a live event from the outside world — it can react to a scheduled one. This one fires on the nightly schedule we registered earlier, runs a query, and emails the result. It refers to the schedule and the mailbox purely by name:
# business/nightly-report.yaml
name: nightly-report
description: "Email a summary of recent invoices every night."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: nightly-report # matches the registered schedule
steps:
- id: rows
run: database.query
with:
table: invoices
order_by: issued_on
order_dir: DESC
limit: 50
- id: summary
run: ai.inject
with:
model: gpt-4o-mini
prompt: "Summarise these recent invoices in a short paragraph: ${steps.rows.rows}"
- id: send
run: mail.send
with:
from_alias: office
to: [ finance@company.example ]
subject: "Nightly invoice summary"
body_text: ${steps.summary.text}
And one more — an alert. When a new sensor reading arrives over the sensor-telemetry alias and the value is too high, post a webhook. Filters here do the deciding — there are no if statements in playbooks, because the filter is the condition. Playbooks do support bounded iteration (loop:) and, in async mode, a join step (wait_for:) — see Anatomy of a playbook for the full grammar. Business playbooks lean most heavily on plain run: steps, keeping each file focused on one reaction:
# business/temperature-alert.yaml
name: temperature-alert
description: "Raise an alert when a sensor reports an over-temperature."
trigger:
event: Fact.Mail.Received
filter:
via.eq: sensor-telemetry # MQTT broker registered as a mailbox alias
source.kind.eq: mqtt
steps:
- id: notify
run: webhook.send
with:
endpoint: oncall-pager
body: ${trigger.body_text}
Notice that every business playbook above names resources in plain words — office, invoices, slack-alerts, nightly-report — and contains no credentials at all. That is exactly what makes business logic safe to paste into a code review or show to a customer.
Teardown playbooks are the mirror image of provisioning. They unregister a resource or rotate its credentials when it is no longer needed. They are optional — most setups never need one — but they keep things tidy at the end of a resource's life.
# teardown/unregister-office-mailbox.yaml
name: unregister-office-mailbox
description: "Remove the office mailbox alias when it is retired."
steps:
- id: remove
run: mail.unregister_mailbox
with:
alias: office
Use teardown sparingly. A table full of invoices you want to keep should not be unregistered. Teardown is for the things that are genuinely temporary — an expired customer mailbox, a deprecated route — or for scheduled credential rotation, such as a monthly rotate-api-keys-monthly.yaml. (The exact unregister_* / rotate verb depends on the daemon; mail.unregister_mailbox shown here is the canonical example.)
The three kinds form one clean lifecycle. Follow a single resource — the invoices table — through its life:
Fact.System.Boot. Every provisioning playbook runs. The mailbox, the table, the bucket, the schedule — all now exist. (You can also run a single provisioning playbook on demand from the console, without waiting for a restart.)invoices.table: invoices, via.eq: office — with no secrets in sight.Every registration and removal also emits an audit event, so there is a permanent record of when each resource appeared and disappeared. The handoff between phases is always a name: provisioning creates a thing and names it, business logic refers to that name, teardown removes it by name. Here is what each daemon registers and how business logic refers back to it:
| Daemon | What it registers | Provisioning verb | Business refers to it by |
|---|---|---|---|
mailbox | an IMAP/SMTP mailbox (or MQTT/AMQP broker) | mail.register_mailbox | a trigger filter via.eq, or from_alias on a mail.send step |
database | a table with its schema | database.register_table | table: "<name>" |
datatransporter | a storage bucket (S3/SFTP/FTP/…) | configured on the platform (no playbook verb) | bucket: "<name>" on any data.* step |
webhookcaller | an outbound HTTP endpoint | webhook.register_endpoint | endpoint: "<name>" |
traefiklinker | an inbound HTTP/WS route | traefik.register_route | a trigger on requests to route.eq |
scheduler | a cron-style schedule | scheduler.register_schedule | a trigger on Fact.Schedule.Fired with name.eq |
The clearest way to see provisioning and business side by side is the IoT telemetry example, which ships both halves in one file. The provisioning half registers an MQTT broker as a mailbox alias (with the only secret); the business half stores each reading that arrives:
# --- provisioning half: register the MQTT broker as a mailbox alias ---
name: register-mqtt-sensor-broker
description: "Register an MQTT broker under the sensor-telemetry alias."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: mail.register_mailbox
with:
alias: sensor-telemetry
protocol: mqtt # default is "imap"
mqtt:
host: mosquitto.internal
port: 8883
username: binions-sensor
password: ${secret.MQTT_SENSOR_PASSWORD}
topics:
- sensor/+/temperature
---
# --- business half: persist each reading to SQL ---
name: sensor-telemetry-ingest
description: "Store every sensor reading that arrives on sensor-telemetry."
trigger:
event: Fact.Mail.Received
filter:
via.eq: sensor-telemetry
steps:
- id: save
run: database.write
with:
table: sensor_readings # registered by its own provisioning playbook
row:
payload: ${trigger.body_text}
observed_at: ${trigger.received_at}
Consistent file names make the folders self-documenting — you can tell what a file does before you open it. These are the patterns the canonical playbooks follow:
| Folder | Filename pattern | What it means |
|---|---|---|
| provisioning | register-<resource-alias>.yaml | Register a specific named resource — e.g. register-office-mailbox.yaml. |
| provisioning | register-<resource-alias>-on-<event>.yaml | Reactive registration on a business event — e.g. register-customer-mailbox-on-signup.yaml. |
| business | <outcome>-from-<source>.yaml | A reaction: what it produces and where it comes from — e.g. invoice-from-accountant.yaml. |
| business | <outcome>-<cadence>.yaml | A scheduled reaction — e.g. newsletter-monday-morning.yaml. |
| teardown | unregister-<resource-alias>.yaml | Remove a resource — e.g. unregister-office-mailbox.yaml. |
| teardown | rotate-<resource-class>-<freq>.yaml | Periodic credential rotation — e.g. rotate-api-keys-monthly.yaml. |
One more habit that pays off: keep the name: field inside the file in step with the filename. So register-table-invoices.yaml opens with name: register-table-invoices. It keeps logs and audit events easy to trace back to a file.
The two-folder split (plus teardown) is the single most important organizing idea in the whole playbook system. It buys you several concrete things:
${secret.KEY} — never in the business logic you read every day. The blast radius of a credential is one folder, not the whole system.slack-alerts endpoint — can be used by many business playbooks without duplicating its configuration.There is a small price: one resource means one extra provisioning file, and a business playbook will refuse to run if the resource it names was never registered. In practice that is a feature — the failure is loud and clear (“resource not registered”) rather than a silent misconfiguration.
A rule of thumb. If a step creates something or needs a secret, it belongs in
provisioning/. If it reacts to an event and refers to things by name, it belongs inbusiness/. If it removes or rotates something at end of life, it belongs inteardown/. When in doubt, ask: does this run at boot, on an event, or when retiring a resource? The answer is the folder.