This is the cookbook — a long shelf of complete, real-world Binions playbooks you can read, learn from, and adapt. Every playbook is built from a trigger and a list of steps, and each step is one of four kinds: a single run: verb, a parallel: block that runs independent run: steps together and waits for them all, a loop: block for bounded counted iteration, or a wait_for: step that joins the result of an earlier fire-and-forget action. An optional top-level mode: field (saga, the default, or async) controls whether each run: step waits for its own result before the next step starts. Once you can read one recipe, you can read them all. Copy a block, change the aliases to match your own setup, and you have a working starting point.
The recipes are grouped by domain: business automation, integrations, industrial & IoT, and storage & data. Pick the one closest to what you need and work outward from there.
How to read these. Each recipe has three parts: a one-line scenario, the full YAML, and a short line-by-line note. Many come as a pair — a provisioning playbook that registers resources once (a mailbox, a table, a schedule, a bucket), and a business playbook that does the recurring work and refers to those resources by the alias the provisioning step gave them. See Provisioning vs business playbooks for why they are split.
Credentials live in one place. Only provisioning playbooks may reference a secret, always as
${secret.KEY}. Business playbooks never see a password or a URL — they use the alias instead (via: invoices-inbox,table: invoices,endpoint: slack-alerts). That keeps every business playbook safe to read, review, and share.
Adapt, don’t memorise. Table names, aliases, hosts, schedule names, register addresses, and AI field hints below are illustrative — swap in your own. The grammar and the verbs stay the same; only the arguments change. Where an argument is platform-specific (especially the precise keys inside a MODBUS or scheduler step), we say so inline; confirm those against your installed setup and check any playbook with
binions-cliconsole validate --dry-runbefore you ship it — plainvalidatechecks grammar only, while--dry-runalso statically proves that every verb, step reference, and secret actually resolves.
Before the recipes, it helps to know the full vocabulary of a step. Most examples below use just the first two — the simplest tool that does the job — but all four are available whenever you need them.
run: | A single generic verb (ai.extract, database.write, webhook.send …). The workhorse — the great majority of steps are a run:. |
parallel: | A single-level group of independent run: steps started together; the step waits for every child, and one failure fails it. Give children an id: and read results as ${steps.<id>.…} (right after a parallel:, ${prev} is empty). |
loop: | Bounded counted iteration inside the playbook (for: / from: / to:, a mandatory max: safety cap, and an optional until: early-exit). The body is a list of plain run: steps. |
wait_for: | Joins one specific response fact. In async mode you fire several actions without waiting, then add a wait_for: step to collect only the results you actually need. |
Saga vs async, in one sentence. In the default saga mode every
run:step waits for its own result before the next step starts, and${prev}carries that result forward — perfect for the read → transform → deliver chains you see throughout this page. Switch to async mode when one playbook needs to fan many actions out at once and then join only the answers it cares about withwait_for:. Most everyday automations stay in saga; reach for async only for high-fan-out work.
Back-office work: turning documents into rows in a database, capturing form submissions, sending scheduled mail, and keeping people informed. These lean on mail.*, ai.*, database.*, analytics.*, scheduler.*, and webhook.send.
Scenario. An accountant emails an invoice. Binions reads it, pulls out the figures with AI, files it to a database, and posts a heads-up to a Slack channel. This is the flagship business flow.
First, the provisioning playbook — it registers the mailbox, the destination table, and the Slack endpoint once, on every boot. This is the only file that holds secrets.
name: register-invoice-resources
description: "Mailbox + invoices table + Slack endpoint."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- run: mail.register_mailbox
with:
alias: invoices-inbox
imap:
host: imap.example.com
port: 993
username: invoices@example.com
password: ${secret.INVOICES_IMAP_PASS}
- run: database.register_table
with:
name: invoices
columns:
- { name: supplier_name, sql_type: text }
- { name: amount_gross, sql_type: double }
- { name: invoice_number, sql_type: text }
- { name: received_at, sql_type: timestamptz }
- run: webhook.register_endpoint
with:
alias: slack-finance
url: ${secret.SLACK_FINANCE_WEBHOOK}
method: POST
headers:
- ["Content-Type", "application/json"]
Then the business playbook — it reacts to each invoice that lands in that mailbox.
name: invoice-from-accountant
description: "Extract invoice fields, persist, notify finance."
trigger:
event: Fact.Mail.Received
filter:
via.eq: invoices-inbox
from.email.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: invoice_number }
- run: database.write
with:
table: invoices
row:
supplier_name: ${steps.extract.result.supplier_name}
amount_gross: ${steps.extract.result.amount_gross}
invoice_number: ${steps.extract.result.invoice_number}
received_at: ${trigger.received_at}
- run: webhook.send
with:
endpoint: slack-finance
body:
text: "New invoice ${steps.extract.result.invoice_number} from ${steps.extract.result.supplier_name}: ${steps.extract.result.amount_gross}"
invoices-inbox, from the accountant’s domain, and that actually has an attachment. All three conditions must hold.ai.extract — one generic verb pulls the named fields out of the message body text using AI. In the default saga mode this step waits for its result before the next step runs, so later steps can read it as ${steps.extract.result.*}.database.write — writes one row to the invoices table, mapping each extracted field to a column. ${trigger.received_at} stamps when it arrived.webhook.send — posts to the slack-finance endpoint registered earlier. The same verb talks to Slack, Teams, Discord, or any REST webhook — only the registered URL differs.Scenario. The previous recipe reads the invoice from the message body — but often the real invoice is the PDF attached to it. When the attachment policy offloads an attachment to object storage, the stored file announces itself as a Fact.Data.Transported event carrying its bucket and key. This playbook picks up every PDF under the mail/ prefix, pulls the text out of it in-platform, extracts typed fields with AI, and files a row.
name: invoice-pdf-to-sql
description: |
Parse an offloaded PDF attachment's text layer, extract typed
fields with a fallback provider chain and a cost cap, persist.
trigger:
event: Fact.Data.Transported
filter:
key.startswith: "mail/"
key.endswith: ".pdf"
steps:
- id: parse
run: data.parse
with:
bucket: ${trigger.bucket}
key: ${trigger.key}
max_text_bytes: 262144
- id: extract
run: ai.extract
with:
text: ${steps.parse.text}
providers: [primary, local]
max_cost_usd: 0.05
on_mismatch: fail
fields:
- { name: no, type: text }
- { name: total, type: decimal }
- { name: sender, type: text }
- id: persist
run: database.write
with:
table: invoices
row:
no: ${steps.extract.result.no}
total: ${steps.extract.result.total}
sender: ${steps.extract.result.sender}
Fact.Data.Transported with a flat bucket / key pair, and the two key filters narrow it to PDFs from the mail path. No polling, no folder scans.data.parse reads the text layer — the format is auto-detected (extension, then magic bytes; PDF, DOCX, and plain text are supported), and max_text_bytes caps how much text comes back — longer documents are truncated on a character boundary and flagged truncated: true. Be honest about scans: there is no OCR, so a scanned, image-only PDF fails loudly with no_text_layer. Route those to an external OCR service with webhook.send instead.fields[].type (text, int, decimal, bool, date) is enforced by coercion, so a European “1 234,56” arrives as a real number, and on_mismatch: fail fails loudly instead of writing a bad row. providers: [primary, local] is an ordered fallback chain over provider instances from the AI daemon’s configuration — if the first errors at runtime, the call falls through to the next. max_cost_usd is a pre-flight cost cap: if the estimate exceeds it, or the model has no known price, the step refuses to spend.ai.extract nests its output — extracted values are read as ${steps.extract.result.*}; every other verb’s result fields are read directly off the step.Scenario. A “contact us” form posts to a Binions HTTP route. Binions saves the ticket and emails the customer back to confirm it was received.
name: support-ticket-from-form
description: "Persist a form submission, acknowledge by email."
trigger:
event: Fact.Http.Received
filter:
route.eq: support-form
steps:
- run: database.write
with:
table: tickets
row:
email: ${trigger.body.email}
subject: ${trigger.body.subject}
message: ${trigger.body.message}
- run: mail.send
with:
from_alias: support-outbox
to:
- ${trigger.body.email}
subject: "We received your request"
body_text: "Thanks for getting in touch. We have logged your request and will reply shortly."
Fact.Http.Received + route.eq — the form posts to a Traefik route registered under the alias support-form in a provisioning playbook. The submitted fields arrive as ${trigger.body.*}.database.write — stores the ticket. The tickets table and the support-outbox mailbox are both registered once at boot.mail.send — sends the acknowledgement to whatever address the customer entered, through the named outbox. No SMTP credentials appear here.Add smart routing. Drop an
ai.classifystep before the write withcategories: [billing, technical, sales, other], then store${steps.classify.category}alongside the message so the right team picks it up first.
Scenario. Greet every new customer the moment they sign up — the signup posts to an HTTP route, and the playbook reads the new customer’s details from the request and sends a welcome email straight away.
name: welcome-new-customer
description: "Send a welcome email as soon as someone signs up."
trigger:
event: Fact.Http.Received
filter:
route.eq: signup
steps:
- run: mail.send
with:
from_alias: office
to:
- ${trigger.body.email}
subject: "Welcome aboard"
body_text: "Hi ${trigger.body.name}, thanks for signing up!"
Scenario. On a schedule, total up the most recent invoices and report the figures. The database returns the rows; analytics.calculate_stats does the maths (there is no SUM in a query — you compute over the rows you fetched).
name: revenue-summary
description: "Total recent invoices and report the figures."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: revenue-summary
steps:
- id: rows
run: database.query
with:
table: invoices
columns: [ amount_gross ]
order_by: received_at
order_dir: DESC
limit: 500
- id: stats
run: analytics.calculate_stats
with:
data: ${steps.rows.rows}
field: amount_gross
ops: [ { name: sum }, { name: avg }, { name: count } ]
- run: mail.send
with:
from_alias: office
to: [ finance@example.com ]
subject: "Revenue summary"
body_text: "Recent invoice totals: ${steps.stats.results}"
- run: webhook.send
with:
endpoint: slack-finance
body: { text: "Revenue summary posted." }
Grouped totals, computed by the database. When the report is a breakdown — revenue per category, tickets per status, readings per device — let database.aggregate do the grouping and summing on the server instead of pulling every row back.
name: "Revenue by category"
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: revenue-by-category
steps:
- id: totals
run: database.aggregate
with:
table: invoices
group_by: [category]
aggregates:
- { fn: sum, col: total, as: revenue }
- { fn: count, col: "*", as: invoices }
where: { paid.eq: true, issued_at.within: 30d }
having: { revenue.gt: 1000 }
order_by: revenue
order_dir: DESC
- run: webhook.send
with:
endpoint: slack-finance
body: { text: "Top categories (30d): ${steps.totals.rows}" }
Scenario. Pull the most recent activity, have the language model turn it into a short readable digest, and email it.
name: activity-digest
description: "Summarise recent activity and email it."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: activity-digest
steps:
- id: rows
run: database.query
with:
table: activity_log
order_by: created_at
order_dir: DESC
limit: 200
- id: summary
run: ai.inject
with:
model: gpt-4o-mini
prompt: "Summarise these recent activity-log entries in a short digest: ${steps.rows.rows}"
- run: mail.send
with:
from_alias: office
to: [ team@example.com ]
subject: "Activity digest"
body_text: ${steps.summary.text}
loop:Scenario. Each morning you want to walk a small, fixed set of dunning stages — send the stage-1 reminder, then stage-2, then stage-3 — querying and mailing each stage in turn. When the number of repetitions is known and bounded, a loop: step expresses it directly in the playbook rather than in a query or a daemon.
name: dunning-reminder-stages
description: "Send each dunning stage's reminders in turn."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: dunning-daily
steps:
- loop:
for: stage
from: 1
to: 3
max: 10
do:
- id: due
run: database.query
with:
table: invoices_overdue
columns: [ email, name ]
where:
stage: ${loop.stage}
- run: mail.send
with:
from_alias: billing-outbox
to:
- ${steps.due.rows}
subject: "Payment reminder (stage ${loop.stage})"
body_text: "Stage ${loop.stage} reminders for overdue invoices."
for / from / to — the counter stage runs over the inclusive range 1 to 3, and the body reads it as ${loop.stage}. The range can also be set from earlier data (it is resolved once, when the loop starts).max is mandatory — it is a hard safety cap on iterations. If the requested range ever exceeds max, the run stops loudly rather than running away. Add an optional until: condition to leave the loop early once a result tells you there is nothing left to do.run: steps — a loop holds a list of ordinary verbs (no parallel or nested loop inside it). For an unbounded set keyed off a query, you would still let a single query do the selection; reach for loop: when you genuinely want explicit, counted repetition in the playbook. When each row deserves its own independent run instead, use the per-item fan-out in the next recipe.Scenario. The counted loop above walks a fixed number of stages. This recipe handles the other shape: an unbounded set of rows where each row deserves its own run. On the daily tick, one playbook queries the open invoices, filters down to the segment it cares about, and explodes the survivors into individual facts; a second playbook triggers once per fact and sends one mail per invoice.
name: overdue-scan-daily
description: |
On the daily tick: read open invoices, keep the VIP segment,
then explode the survivors into per-item facts.
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: overdue-scan-daily
steps:
- id: overdue
run: database.query
with:
table: invoices
where:
status: overdue
limit: 500
- id: vips
run: analytics.filter
with:
data: ${steps.overdue.rows}
where:
segment: vip
- id: fan_out
run: analytics.emit_items
with:
items: ${steps.vips.rows}
The per-item playbook triggers on each exploded element — its filter uses dot-paths that reach into the item:
name: overdue-remind-one
description: "PER-ITEM: one run, one invoice, one mail."
trigger:
event: Fact.Analytics.ItemEmitted
filter:
item.invoice_no.is_not_null: true
item.status.eq: overdue
steps:
- id: remind
run: mail.send
with:
from_alias: crm-outbound
to:
- ${trigger.item.customer_email}
subject: "Payment reminder — invoice ${trigger.item.invoice_no}"
body_text: |
Our records show invoice ${trigger.item.invoice_no}
(amount: ${trigger.item.amount}) is still unpaid.
Batch ${trigger.batch_id}, item ${trigger.index}.
analytics.filter speaks the same language as triggers — its where: uses exactly the trigger-filter grammar (the same evaluator, so the two can never drift apart): segment: vip, amount.gt: 1000, even an or: list of alternatives. An empty result is a normal outcome, not an error.analytics.emit_items is the pivot — it emits one Fact.Analytics.ItemEmitted per element, each carrying the item itself plus its index, the total, and a shared batch_id, then closes the first playbook’s saga with a summary Fact.Analytics.ItemsEmitted. The cap is 1000 items per call — going over fails loudly rather than silently truncating.item.status.eq: overdue reaches into the emitted element, so only rows you meant to act on spawn a run. Each invoice then gets its own run: one slow SMTP conversation or one failure never touches the other reminders.overdue-scan-daily schedule (for example cron_expr: "0 7 * * *"), just like the other scheduled recipes on this page.Scenario. Payment terms live in a table, each row with its own due date. Instead of scanning every morning for “anything due today”, let the data drive the scheduler: a daily refresh reads the pending terms and registers a one-shot schedule per term, timed to the row’s own due_at and carrying a payload; when a one-shot fires, a small reminder playbook mails the customer. Fired one-shots deregister themselves, so the scheduler never accumulates spent entries.
Provisioning registers the daily refresh cadence:
name: register-terms-refresh
description: "Daily 06:00 Europe/Warsaw refresh cadence."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: cadence
run: scheduler.register_schedule
with:
name: refresh-payment-terms
cron_expr: "0 6 * * *"
timezone: Europe/Warsaw
Three business playbooks form the chain — they can share one file, separated by ---. The refresh queries and fans out, the per-item playbook registers one one-shot per term, and the reminder fires when a term comes due:
name: refresh-payment-terms
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: refresh-payment-terms
steps:
- id: terms
run: database.query
with:
table: payment_terms
where:
status: pending
limit: 500
- id: fan_out
run: analytics.emit_items
with:
items: ${steps.terms.rows}
---
name: schedule-one-term
trigger:
event: Fact.Analytics.ItemEmitted
filter:
item.due_at.is_not_null: true
item.status.eq: pending
steps:
- id: one_shot
run: scheduler.register_schedule
with:
name: "term-${trigger.item.id}"
at: ${trigger.item.due_at}
timezone: Europe/Warsaw
payload:
invoice_no: ${trigger.item.invoice_no}
email: ${trigger.item.customer_email}
---
name: send-term-reminder
trigger:
event: Fact.Schedule.Fired
filter:
name.startswith: "term-"
steps:
- id: remind
run: mail.send
with:
from_alias: crm-outbound
to:
- ${trigger.payload.email}
subject: "Payment due — invoice ${trigger.payload.invoice_no}"
body_text: |
Invoice ${trigger.payload.invoice_no} is due today.
at: makes a schedule data-driven — a one-shot fires at a single instant, given as RFC-3339 (2026-07-05T10:00:00+02:00) or as a naive YYYY-MM-DDTHH:MM[:SS] resolved in the schedule’s timezone. Here it comes straight off the row: at: ${trigger.item.due_at}.timezone keeps local time honest — an IANA zone (Europe/Warsaw) keeps the 06:00 cron at 06:00 local through both daylight-saving transitions, and naive at: instants resolve in the same zone. An instant that falls into the spring-forward gap is rejected loudly at registration rather than silently shifted.payload travels with the fire — whatever you attach at registration comes back on the one-shot’s Fact.Schedule.Fired, so the reminder playbook reads ${trigger.payload.email} and ${trigger.payload.invoice_no} without another query.Fact.Schedule.Deregistered. Each term registers under its own name, and the reminder playbook matches the whole family with name.startswith: "term-".Connecting Binions to the outside world — receiving HTTP, calling other services’ REST APIs, and bridging legacy SOAP systems. These use Traefik routes plus webhook.send and data.transform.
Scenario. Another system (a payment provider, a CI tool, a partner) posts an event to Binions. Binions records it, then posts a notice to Slack.
name: payment-event-ingest
description: "Record an inbound payment event and notify finance."
trigger:
event: Fact.Http.Received
filter:
route.eq: payments-inbound
steps:
- run: database.write
with:
table: payment_events
row:
provider: ${trigger.body.provider}
amount: ${trigger.body.amount}
reference: ${trigger.body.reference}
received_at: ${trigger.received_at}
- run: webhook.send
with:
endpoint: slack-finance
body:
text: "Payment ${trigger.body.amount} received (ref ${trigger.body.reference})."
${trigger.body.*} — the inbound JSON is read field by field. Field names are illustrative; match them to what your sender posts. The payments-inbound route and slack-finance endpoint are registered at boot.Scenario. When a new customer is created (an inbound webhook from your app), push them to an external CRM’s REST API.
name: sync-customer-to-crm
description: "Persist a new customer and sync to the CRM."
trigger:
event: Fact.Http.Received
filter:
route.eq: customer-created
steps:
- run: database.write
with:
table: customers
row:
email: ${trigger.body.email}
name: ${trigger.body.name}
- run: webhook.send
with:
endpoint: crm-contacts
body:
email: ${trigger.body.email}
full_name: ${trigger.body.name}
source: binions
webhook.send is the universal REST client — the crm-contacts endpoint (URL, method, auth headers) is registered once at boot; here you just name it and hand it a body.name becomes the CRM’s full_name. Reshaping names at the boundary is normal integration work.Scenario. A legacy on-premise ERP only speaks SOAP. When an order webhook arrives, Binions builds a SOAP envelope, calls the ERP, and forwards the response on for processing. This is the canonical legacy-bridge flow — SOAP is just an XML envelope sent over HTTP, so no new daemon or verb is needed.
Provisioning registers the SOAP endpoint with the right headers, so business playbooks never repeat them:
name: register-soap-endpoint
description: "Register the legacy ERP SOAP endpoint as an alias."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- run: webhook.register_endpoint
with:
alias: erp-customer
url: https://erp.example.com/services/CustomerService
method: POST
headers:
- ["Content-Type", "text/xml; charset=utf-8"]
- ["SOAPAction", "\"http://example.com/erp/GetCustomerByID\""]
- ["Accept", "text/xml"]
The business playbook fires the SOAP request when matching mail arrives, then forwards the reply to an internal endpoint:
name: soap-erp-lookup
description: "Look up a customer in the legacy ERP over SOAP."
trigger:
event: Fact.Mail.Received
filter:
from.email.endswith: "@accounting.example.com"
steps:
- id: call-erp
run: webhook.send
with:
endpoint: erp-customer
body:
envelope_v_1_1: |
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<ns:GetCustomerByID xmlns:ns="http://example.com/erp">
<ns:CustomerEmail>${trigger.envelope.from.email}</ns:CustomerEmail>
</ns:GetCustomerByID>
</soap:Body>
</soap:Envelope>
- run: webhook.send
with:
endpoint: internal-processor
body:
mail_from: ${trigger.envelope.from.email}
soap_response_preview: ${steps.call-erp.response_body_truncated}
webhook.send — the transport is plain HTTPS and the format is an XML envelope in the body. The Content-Type: text/xml and SOAPAction headers were set once on the erp-customer endpoint, so the business playbook just supplies the envelope.${trigger.envelope.from.email} drops the sender’s address straight into the XML. A secret-bearing version of the envelope (WS-Security username token) belongs in a provisioning-side template, never inline here.call-erp, so the next step forwards ${steps.call-erp.response_body_truncated} on for processing. The whole legacy bridge is one short playbook with zero glue code. Args kept illustrative — the exact envelope and field names map to your ERP’s WSDL.Scenario. When a lead arrives, you want to enrich it from two independent services — an AI classifier and a CRM lookup — and you do not want the slower call to hold up the faster one. Switch the playbook to async mode: fire both calls without waiting, then join only the results you need with wait_for:.
name: enrich-incoming-lead
description: "Classify and look up a new lead concurrently, then persist."
mode: async
trigger:
event: Fact.Http.Received
filter:
route.eq: lead-created
steps:
- id: classify
run: ai.classify # fires, does not wait for a result
with:
text: ${trigger.body.message}
categories: [hot, warm, cold]
- run: webhook.send # fires immediately too, alongside the classify
with:
endpoint: crm-contacts
body:
email: ${trigger.body.email}
name: ${trigger.body.name}
- id: label
wait_for: # now join just the classifier's answer
event: Fact.AI.Classified
match:
causation: ${classify}
timeout_ms: 30000
- run: database.write
with:
table: leads
row:
email: ${trigger.body.email}
priority: ${steps.label.category}
mode: async — in this mode each run: step is fire-and-forget: it emits its action and the playbook moves straight on, so the classify and the CRM push start together instead of one after the other.wait_for: joins one result — the step blocks until the matching response fact arrives, identified by match: { causation: ${classify} }, where ${classify} is the id of the earlier fire-and-forget step. An optional timeout_ms bounds the wait; if it lapses, the run fails cleanly rather than hanging.wait_for: step carries an id, the final write reads its payload as ${steps.label.category}. Firing N actions and joining only the few you need is what gives async playbooks their throughput.You rarely need async. The default saga mode is the right choice for almost every recipe on this page, because each step naturally depends on the one before it. Reach for
async+wait_for:only when a single run genuinely fans out to many independent actions and you want them all in flight at once.
Binions speaks factory-floor protocols too. These read sensors over MQTT and PLC registers over MODBUS, archive readings, and act on thresholds — using mail.register_mailbox (MQTT mode), modbus.read, and modbus.write.
MODBUS arguments vary by device. The exact field names inside a
modbus.read/modbus.writestep (function code, register address, unit id) depend on your PLC’s register map, and a write only succeeds if the target register is on the daemon’s configured allow-list. The shapes below are illustrative — args kept illustrative; check the Daemons reference and your device map for the precise arguments.
Scenario. A fleet of IoT sensors publishes readings to an MQTT broker. Binions ingests every message and persists it to a table for dashboards and alerting. This is the canonical IoT pipeline.
Provisioning registers the broker as a mailbox in MQTT mode (and the table elsewhere). The wildcard topic sensor/+/temperature matches every sensor:
name: register-mqtt-sensor-broker
description: "Register an MQTT broker as a mailbox alias on every boot."
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- run: mail.register_mailbox
with:
alias: sensor-telemetry
protocol: mqtt # default is imap; mqtt is a supported mode
mqtt:
host: mosquitto.internal
port: 8883
client_id: binions-sensor-ingest
username: binions-sensor
password: ${secret.MQTT_SENSOR_PASSWORD}
topics:
- sensor/+/temperature
- sensor/+/humidity
The business playbook fires on each MQTT message (which arrives as a Fact.Mail.Received event through the broker mailbox), derives the sensor id from the topic, and stores the reading:
name: sensor-telemetry-ingest
description: "Persist each sensor reading to SQL."
trigger:
event: Fact.Mail.Received
filter:
via.eq: sensor-telemetry
steps:
- run: database.write
with:
table: sensor_readings
row:
payload: ${trigger.body_text} # the raw MQTT message, stored as-is
topic: ${trigger.source.topic}
ingested_at: ${trigger.received_at}
protocol: mqtt and a top-level topics: list turns every published message into a Fact.Mail.Received event. The same trigger you use for email handles sensors.${trigger.body_text}, so this playbook writes it straight into a column without an intermediate step. The topic is available as ${trigger.source.topic}.body_json, so a step reads ${trigger.body_json.temp} directly with no AI round-trip. For free-form text, add an ai.extract step (as in the invoice recipes) and read its results as ${steps.extract.result.*}.Scenario. A reactor’s temperature sits on a PLC register that Binions polls. When the value crosses a threshold, the polling loop emits a Fact.Modbus.ValueChanged event and the playbook emails the operations team. This is the canonical sensor-to-mail flow.
name: reactor-temp-alert
description: "Email ops when reactor temperature crosses the limit."
trigger:
event: Fact.Modbus.ValueChanged
filter:
alias.eq: reactor-1
address.eq: 9
new_value.gt: 8000
steps:
- run: mail.send
with:
from_alias: ops-outbox
to: [ maintenance@example.com ]
subject: "Reactor temperature high"
body_text: "reactor-1 register 9 reported ${trigger.new_value} (threshold 8000)."
new_value.gt: 8000 means the playbook only runs when the reading actually crosses the limit. The event bus does the gating before the playbook ever starts.${trigger.new_value} — the changed reading is read straight off the event. The register address and raw threshold value map to your device. Args kept illustrative.thresholds (with a deadband) on the register subscription itself and trigger on Fact.Modbus.ThresholdCrossed with direction.eq: enter — the daemon then emits exactly one fact per alarm-episode edge.Scenario. Archive every register transition across the whole PLC fleet into a time-series table for later analysis. No filter — it captures all changes.
name: modbus-telemetry-to-sql
description: "Persist every MODBUS register change, fleet-wide."
trigger:
event: Fact.Modbus.ValueChanged
steps:
- run: database.write
with:
table: plc_telemetry
row:
device_alias: ${trigger.alias}
register_address: ${trigger.address}
value: ${trigger.new_value}
observed_at: ${trigger.polled_at}
Fact.Modbus.ValueChanged from any device. One short playbook archives the entire fleet.polled_at timestamp; you just map them into columns. This is a read-only flow, so no write allow-list is involved.plc_telemetry, a scheduled database.query can compute trends or feed ai.inject for a shift report.Scenario. A target setpoint is managed in a SQL config table. Every five minutes Binions reads the desired value and writes it to the PLC, keeping the equipment in step with config. This is a canonical write flow.
name: modbus-scheduled-setpoint
description: "Propagate the SQL setpoint to the PLC every 5 minutes."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: setpoint-sync-5min
steps:
- id: read-setpoint
run: database.query
with:
table: line_config
columns: [ target_value ]
where: { line_id: line-1 }
single: first
- run: modbus.write
with:
alias: line-1-plc
operation: write_single_register
address: 19
value: ${steps.read-setpoint.row.target_value}
single: first, so the result carries a flat row and the write reads ${steps.read-setpoint.row.target_value} (zero rows is a loud error, never a silent no-op). Operators change the database; the line follows.verify: true to read the register back after the write and fail loudly on a mismatch (the success fact then carries verified: true). The single register address here is illustrative. Args kept illustrative.setpoint-sync-5min with cron_expr: "*/5 * * * *" in a provisioning playbook.Writing to physical equipment. A
modbus.writechanges real hardware. Keep write permissions tight (only the registers you intend to control), and archive every change — pairing a write flow with the telemetry archive above gives you a full audit trail of who changed what and when. Writes to the same register are always applied in order, even when many runs are in flight at once.
Moving files into object storage and reshaping data between steps — using data.upload, data.download, and data.transform. (For reading files back out of storage as text, see the “Invoice PDFs to structured rows” recipe above, which parses a stored PDF with data.parse.)
Scenario. On a schedule, build a short text report with the language model and store it in a bucket. data.upload takes a text body (or base64 body_b64), so anything you can produce as text — a generated report, a rendered template — can be archived.
name: archive-report
description: "Generate a report and store it in object storage."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: archive-report
steps:
- id: rows
run: database.query
with: { table: orders, order_by: created_at, order_dir: DESC, limit: 200 }
- id: report
run: ai.inject
with:
model: gpt-4o-mini
prompt: "Write a short status report from these recent orders: ${steps.rows.rows}"
- run: data.upload
with:
bucket: reports
key: "reports/${trigger.fired_at}.txt"
body: ${steps.report.text}
You write playbooks as if each one runs on its own, and you never have to think about scheduling them — but it is worth knowing that Binions runs them efficiently in the background.
Fact.Playbook.Failed; there is no silent retry of an individual step and no separate dead-letter file to chase. A wait_for: that times out fails the run the same way. Validate before you ship with binions-cliconsole validate --dry-run, and reload after editing with binions-cliconsole emit-control Control.Playbook.Reload. A playbook that parses but could never execute is rejected on entry — the previous good version keeps serving, and a Fact.Playbook.Rejected fact says exactly why.