Playbooks are plain text files, so testing them is fast and predictable. There is no build step and nothing to compile — you write a short YAML file, check it, drop it into a folder, trigger it, and watch the events fly past. This page walks the whole loop end to end, with the exact commands you run and what their output means. One tool does most of the work: binions-cliconsole, the operator command line that talks to the live event bus.
The debugging loop in four words. Validate the file, drop it in, trigger a run, then watch the events. Every step below maps to one of those four, and none of them restarts the platform.
Always start by checking the file. binions-cliconsole validate parses every playbook under /opt/binions/playbook-service/playbooks/ and reports problems before they ever reach the running system. It is read-only — it never touches a live run, so it is safe to run as often as you like. A file may hold several ----separated playbooks (handy for keeping a provisioning + business pair together); each document is validated on its own and reported as [doc 1], [doc 2], … — note the live engine still loads one playbook per file.
# Parse and check every playbook on disk
binions-cliconsole validate
Validation enforces the rules that make a playbook a valid Binions playbook:
name, description, enabled, trigger, an optional mode (saga or async, defaulting to saga), and steps. Anything else at the top level is rejected.run:, parallel:, loop:, or wait_for:. Mixing two in a single step fails. Constructs like for_each or when: are not part of the grammar and are rejected by design.Plain validate checks the grammar only — it does not know whether a verb or argument name is real. Add --dry-run and the same command also applies the static feasibility checks the engine itself enforces when it accepts a file: every run: must name a real generic operation (an invented or business-named verb fails), every ${steps.<id>} must point at a step the playbook actually declares, ${loop.<var>} must sit inside its loop, a wait_for join must reference a real fire-and-forget step, and only the five interpolation namespaces are accepted. All offline, before the file ever reaches a host:
binions-cliconsole validate --dry-run
# OK provisioning/register-office-mailbox.yaml
# OK business/invoice-from-accountant.yaml
# ERROR business/temp-alert.yaml: unknown verb 'modbus.read_temperature'
# (verbs are generic capabilities, e.g. modbus.read)
Good to know. Even the dry run checks the playbook, not the world around it — it cannot know whether a mailbox alias is registered or an external server is reachable. But the engine guards its own gate: when a file arrives (at boot, on reload, or via a deploy), anything that could never execute — an unknown verb, a bad reference, a
${secret.…}that is not in the secret store, a step targeting a daemon the engine has no connection to — is rejected on entry with a concrete error list, the previous good version keeps serving, and aFact.Playbook.Rejectedevent announces it (wire an admin alert to that event and broken deploys page you instead of failing silently days later).
Because the validator accepts four step kinds, it helps to know what each one is for before you read a failure message:
run: | Emit one generic daemon action, optionally with with: arguments. In saga mode it waits for its response fact and exposes the result as ${prev}; in async mode it fires and moves on without waiting. |
parallel: | A single-level group of run: steps the engine starts together and joins — the step completes when every child has, and one failed child fails the whole step. Children may not nest another parallel: or loop:. There is no single “previous” among concurrent siblings, so ${prev} is empty right after it — give a child an id: and read ${steps.<id>.…}. |
loop: | A bounded, counted iteration with a mandatory max cap and an optional until early-exit. Its body is plain run: steps. |
wait_for: | Used in async mode to join — it blocks until one specific response fact arrives, matched by the causation of an earlier fire-and-forget run: step. |
The top-level mode: field decides how run: steps behave. saga (the default) runs steps in sequence, each waiting for its own result before the next begins, so every existing playbook keeps working with no change. async lets run: steps fire without waiting, so one playbook can fan out many actions at once and then collect only the results it needs with wait_for:. The Anatomy of a playbook and Patterns pages cover both modes in full.
Deploying a playbook is just copying a file into the right folder. Playbooks live in three folders under /opt/binions/playbook-service/playbooks/: provisioning/ (register resources), business/ (your day-to-day logic), and teardown/ (clean-up). Put each file where it belongs, set sensible ownership, and the platform takes it from there.
# Copy a new business playbook into place
sudo install -o playbooksvc -g playbooksvc -m 0640 \
invoice-from-accountant.yaml \
/opt/binions/playbook-service/playbooks/business/
# Validate it (optional, read-only)
binions-cliconsole validate \
/opt/binions/playbook-service/playbooks/business/invoice-from-accountant.yaml
The playbook daemon watches the directory and loads new or changed files on its own — within a couple of seconds, with no restart. A file in provisioning/ runs the moment it loads; a file in business/ loads and then waits for its trigger.
No reload step. You don’t signal the daemon or run a reload command — dropping the file in is the whole deployment, and removing a file unloads its playbook the same way. If you ever need to force a refresh,
binions-cliconsole emit-control Control.Playbook.Reloadreloads everything on disk.
To confirm the daemon picked up your file, list what is loaded:
# Show every loaded playbook and whether it is enabled
binions-cliconsole ls playbooks
There are two ways to make a playbook run: cause the real event, or emit a test event by hand. Hand-emitting is faster and is the everyday way to test.
For a schedule-driven playbook, you do not have to wait for the clock — fire the schedule event yourself:
# Pretend the daily revenue schedule just ticked
binions-cliconsole emit Fact.Schedule.Fired --schedule daily-revenue-report
For a mail-driven playbook you can send a real test email to the watched mailbox, or emit a synthetic Fact.Mail.Received with just the fields your trigger.filter looks at:
# Trigger a mail playbook with a synthetic event
binions-cliconsole emit Fact.Mail.Received \
--via office \
--from accounts@accountancy.example \
--has_attachments true
The fields you pass with --field value become the event payload — the same payload a real event would carry, the same fields a filter matches and a step interpolates with ${trigger.…}.
Tip. Match your test event to the playbook’s filter. If the trigger is
via.eq: officeandfrom.endswith: "@accountancy.example", your emitted event must include those fields — otherwise the filter excludes it and nothing runs. The exact field names for each event are listed in the Daemons reference.
You can trigger many runs at once. The platform processes work concurrently, so firing several test events back to back does not queue them behind one another — a slow step in one run never blocks the others, and the engine happily keeps many runs in flight at the same time. Operations that touch the same resource (the same schedule, the same table, the same route) are still kept in order, so concurrency never reshuffles work on a single resource.
Once you have triggered a run, watch the event stream. The quickest look is the recent events list (most recent last):
# The quickest view of what just happened
binions-cliconsole ls events
To follow a run as it unfolds, keep the listing open with --follow — it prints the recent entries, then tails the live stream like tail -f for the bus. Stop it with Ctrl-C:
# Follow the live event stream, then trigger something in another shell
binions-cliconsole ls events --follow
When you need to watch one daemon’s own stream, skip the URL plumbing: --service <daemon> resolves that daemon’s event-bus connection straight from its installed configuration (an explicit --redis-url still wins when you pass one):
# Watch the mailbox daemon's stream, resolved from its own config
binions-cliconsole ls events --follow --service mailbox-service
A healthy run in the default saga mode emits a predictable, in-order sequence. Running ls events right after triggering and seeing this chain end with Completed is exactly what success looks like:
binions-cliconsole ls events
# Fact.Playbook.Started invoice-from-accountant
# Fact.Playbook.StepCompleted extract_data
# Fact.Playbook.StepCompleted save
# Fact.Playbook.Completed invoice-from-accountant
Each event tells you precisely how far the run got:
| Event | What it means |
|---|---|
Fact.Playbook.Started | The trigger matched and the run began. |
Fact.Playbook.StepCompleted | A step finished successfully (carries the step id). |
Fact.Playbook.Failed | A step failed or a wait_for: step timed out; the run stops. The event records the step, the error, the correlation id, and a timestamp. |
Fact.Playbook.Completed | The whole playbook finished successfully. |
Reading an async run. In
asyncmode the chain looks different on purpose. Severalrun:steps fire without waiting, so you may see multipleStepCompletedevents arrive close together — and not always in source order. Await_for:step holds the run internally until its joined fact arrives; when it does, the run continues and the next step emits its ownStepCompleted. If the joined fact never arrives within the timeout the run emitsFact.Playbook.Failed. When tracing a fan-out playbook, expect interleaving across concurrent runs — filter bycorrelation_idto follow one run. See Patterns for the fan-out / join shape.
For the orchestrator’s own logs — useful when the daemon itself, not just a step, is misbehaving — read its systemd journal:
# The playbook daemon's recent logs
journalctl -u binions-playbook -n 50 --no-pager
# Follow live while you trigger a run
journalctl -u binions-playbook -f
A failed step halts its run — there is no per-step retry and no dead-letter file for playbooks. The run stops at the failing step and emits Fact.Playbook.Failed, which carries everything you need to diagnose it. A wait_for: step that times out fails the run the same way. The event records the failing step, the error, the correlation_id, and a timestamp, and it lands on the playbook daemon’s durable audit log — so nothing is ever lost silently.
| What you see | Cause | Fix |
|---|---|---|
A step error in Fact.Playbook.Failed naming a missing resource (e.g. mailbox:office) | A business playbook referenced a name that no provisioning playbook registered. | Run the matching provisioning/register-*.yaml first — or emit Fact.System.Boot to re-run all provisioning. |
| An interpolation error in the failure record | A ${steps.<id>.…} reference pointed at a step id that does not exist. | Check every ${steps.<id>.…} matches a real step id. |
No Started event at all | The event reached the playbook but the trigger.filter excluded it (often expected, not always an error). | Loosen or correct the filter, or fix the fields on your test event. |
Fact.Playbook.Failed with a verb or argument error | A step returned an error from its daemon — a bad with: argument or an unreachable target. | Inspect the failed step in the audit log (below) and fix the file. |
Fact.Playbook.Failed with a timeout on a wait_for: step | An async wait_for: never received its joined fact within the configured timeout. | Confirm the fire-and-forget run: actually emits the awaited fact, and that the match causation field lines up with the earlier step’s id. |
Because the audit log is one JSON object per line, jq is the natural tool:
# The most recent failed runs, newest last
jq 'select(.type=="Fact.Playbook.Failed")' \
/var/log/binions/playbook-service/events.jsonl | tail
# Only the failure for one run
jq 'select(.correlation_id=="019e30a0-8955-76a1-8266-5bf73c4a0148" and .type=="Fact.Playbook.Failed")' \
/var/log/binions/playbook-service/events.jsonl
No
Fact.Playbook.Failedevents is good news. It means no step has failed. If a playbook seems to “do nothing”, check here first — a step that quietly errored out leaves its trace as one of these events.
One run can touch several daemons — a mail playbook might pull a message, extract data with AI, then write a row to the database. Every event in that single run shares one correlation_id, a UUID stamped on the envelope. That id is the thread you pull to follow a workflow end to end. A wait_for: join matches the response fact it is waiting on by its causation id, so even an async fan-out stays traceable.
Each daemon also keeps a durable, append-only audit log at /var/log/binions/<svc>/events.jsonl — one JSON object per line, with the full payloads. To stitch a single run together across every daemon, filter all of those logs by the correlation id:
# Pull the id off the Started line, then reconstruct the run in order
jq -r 'select(.correlation_id=="019e30a0-8955-76a1-8266-5bf73c4a0148")
| "\(.occurred_at) \(.event_type)"' \
/var/log/binions/*/events.jsonl | sort
Sort by time, not by file. Because work runs concurrently, log lines from different daemons interleave. Sorting on
occurred_at(as above) gives you the true order of a single run even when several runs were in flight at once.
If the optional Jaeger tracing collector is enabled, paste the same correlation_id into its Tags field and you get a visual waterfall timeline — ideal when a run is slow and you want to see which step ate the time. Tracing is best-effort: the platform runs identically whether or not it is on. See Observability for how to turn it on and read it.
A handful of mistakes account for almost every failed first playbook. Each has a quick fix:
via: office, table: invoices) that no provisioning playbook ever registered — the run fails naming the missing resource. Run the matching provisioning/register-*.yaml first; business playbooks only ever refer to resources by name.${secret.KEY} is allowed only in provisioning playbooks. Put credentials in the provisioning/ file that registers the resource, and keep business/ playbooks credential-free and safe to read.validate --dry-run up front (and the engine’s own gate on arrival); a misspelled with: field surfaces as Fact.Playbook.Failed when the run reaches that step. Fix the file and save it back — the daemon reloads it on its own.run:, parallel:, loop:, or wait_for:. Putting a run: and a wait_for: under the same step, or reaching for for_each / when:, fails validate — split the work into separate steps.wait_for: with nothing to wait on. A wait_for: only makes sense under mode: async, paired with an earlier fire-and-forget run:. If its joined fact never arrives, the run times out and fails — check the event and match causation./opt/binions/playbook-service/playbooks/. A change saved anywhere else has no effect — make sure you edit the file in the live folder.The reliable rhythm. Edit →
validate --dry-run→ copy into the folder →emita test event →ls events --follow. Follow that order and most problems are caught before they ever run.