This page collects runnable, real-world examples that tie the rest of the Developer section together — from a one-file playbook to a complete custom daemon. Each example uses only constructs that exist in the current release, so you can adapt them directly.
The everyday way to use Binions is a YAML playbook. The example below reacts to an incoming invoice email, extracts fields with AI, and writes a row to the database. A playbook has a trigger, an optional top-level mode (saga or async — default saga), and a list of steps. Each step uses exactly one of four forms: run: (execute an operation), parallel: (fan out multiple run: steps at once), loop: (bounded counted iteration), or wait_for: (join an async result). The example below uses the default saga mode: each run: step waits for the previous one to complete before proceeding, and the result is available via ${prev} or by step id.
name: invoice-to-sql
description: "Extract invoice fields from email and store them."
trigger:
event: Fact.Mail.Received
filter:
from.endswith: "@supplier.example"
has_attachments: true
steps:
- id: extract
run: ai.extract
with:
input: ${trigger.envelope.body_text}
fields: [invoice_number, amount, due_date]
- id: save
run: database.write
with:
table: invoices
row:
number: ${steps.extract.result.invoice_number}
amount: ${steps.extract.result.amount}
due_date: ${steps.extract.result.due_date}
Note the generic verbs. There is no
mail.extract_invoice— you composeai.extract+database.write. The same two verbs handle receipts, purchase orders, or any other document.
When a playbook needs to fire several operations and only wait for specific results, set mode: async. In async mode every run: step is fire-and-forget — the engine emits the action and moves to the next step immediately without waiting. A wait_for: step re-joins by blocking until a specific response fact arrives, matched by the causation id of the step that fired it.
name: classify-and-archive
description: "Classify an email with AI while writing to the audit log concurrently, then act on the classification."
mode: async
trigger:
event: Fact.Mail.Received
steps:
- id: cls
run: ai.classify
with:
input: ${trigger.envelope.body_text}
categories: [invoice, complaint, enquiry]
- run: database.write # fires immediately; does not wait for cls
with:
table: mail_audit
row:
mail_id: ${trigger.envelope.aggregate_id}
received_at: ${trigger.timestamp}
- wait_for: # join: block until ai.classify responds
event: Fact.AI.Classified
match: { causation: ${cls} }
id: cls_result
- run: webhook.send
with:
url: "https://crm.example/api/categorise"
body:
mail_id: ${trigger.envelope.aggregate_id}
category: ${steps.cls_result.result.category}
saga vs async. In
sagamode the secondrun:would have waited for the first to finish before firing. Inasyncmode both fire at once and you usewait_for:to collect only the results you need. All existing playbooks without an explicitmode:continue to work unchanged — they default tosaga.
After dropping a playbook into the playbooks directory there is nothing else to run — the engine watches the directory and loads new or changed files on its own, within a couple of seconds and with no restart. Validate first and watch the events to confirm it ran:
# validate a playbook file, then watch recent events
binions-cliconsole validate ./my-playbook.yaml
binions-cliconsole ls events
No reload command needed for file drops. Dropping the file into the playbooks directory is the whole deployment; the daemon picks it up automatically.
emit-controlis for other runtimeControl.*operations on a daemon (for examplebinions-cliconsole emit-control Control.Playbook.Reload); plainemitinjectsAction.*,Fact.*, andLog.*events.
This is the JSON a mailbox daemon publishes when a message arrives — the exact envelope a FactHandler would receive:
{
"event_id": "0192f7a4-9c31-7e2b-bf10-7a0f3d9c11a2",
"event_kind": "Fact",
"event_type": "Mail.Received",
"event_version": 1,
"producer": "mailbox-service",
"timestamp": "2026-05-31T10:14:07.512Z",
"correlation_id": "0192f7a4-9c31-7e2b-bf10-7a0f3d9c0001",
"aggregate_id": "inbox/INV-7781",
"metadata": { "via": "imap-invoices" },
"payload": { "from": "accounts@supplier.example",
"subject": "Invoice INV-7781", "has_attachments": true }
}
If a playbook is not enough, write a daemon. Define the payloads, implement a handler, and register it. First, the data models:
use common_events::EventEnvelope;
use common_microservice::{HandlerContext, HandlerOutput, ActionHandler};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use async_trait::async_trait;
use anyhow::Result;
#[derive(Debug, Deserialize, JsonSchema)]
struct PlaceOrder { order_id: String, total_pennies: u64 }
#[derive(Debug, Serialize, JsonSchema)]
struct OrderPlaced { order_id: String, total_pennies: u64 }
Then the handler — a pure function that returns what should change and what to emit:
struct PlaceOrderHandler;
#[async_trait]
impl ActionHandler for PlaceOrderHandler {
type Action = PlaceOrder;
fn event_type() -> &'static str { "Order.Place" }
async fn handle(&self, ctx: &HandlerContext, a: PlaceOrder,
_env: &EventEnvelope<Value>) -> Result<HandlerOutput> {
let mut fields = std::collections::HashMap::new();
fields.insert("total".into(), json!(a.total_pennies));
Ok(HandlerOutput::ok()
.state_set(ctx.state.keys().state("order", &a.order_id), fields)
.fact("Order.Placed",
OrderPlaced { order_id: a.order_id.clone(),
total_pennies: a.total_pennies },
&a.order_id)
.log_info("order placed", json!({ "order": a.order_id })))
}
}
Finally, main wires it to the bus:
use common_microservice::Microservice;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
Microservice::builder("orders-service", env!("CARGO_PKG_VERSION"))
.own_redis(env!("REDIS_URL"))
.own_actions("orders") // consume actions:orders
.register_action(PlaceOrderHandler) // handle Action Order.Place
.build().await?
.run().await?; // serves /health, /metrics; runs until SIGTERM
Ok(())
}
Sending the action Order.Place on actions:orders now causes the daemon to store the order and broadcast Order.Placed on events:orders — reliably, because the emit goes through the outbox, and exactly once, because the dispatcher claims the event id before running the handler.
Because handlers are pure, testing one needs no Redis — build a context with a mock clock and id generator, call the method, and assert on the returned HandlerOutput:
#[tokio::test]
async fn place_order_emits_fact() {
let ctx = test_context(); // mock clock + ids
let out = PlaceOrderHandler
.handle(&ctx, PlaceOrder { order_id: "1001".into(), total_pennies: 4200 },
&sample_envelope())
.await
.unwrap();
assert_eq!(out.emit_facts.len(), 1);
assert_eq!(out.emit_facts[0].event_type, "Order.Placed");
}