The playbook service is the brain of Binions — the orchestrator that runs every playbook. It is the one daemon that doesn’t do a job of its own. Instead it loads the playbooks you write, watches the internal event stream, and whenever an event matches a playbook it walks that playbook’s steps, handing each step to whichever daemon owns the work — send an email, run a query, call an AI model, post a webhook — and threading the results through. Every other daemon is a specialist; the playbook service is the conductor that tells them when to play.
The big idea. You never write code or wire daemons together by hand. You describe what should happen in a short YAML file, and the playbook service turns that description into actions across the platform — matching the trigger, filling in the values, and dispatching each step to the daemon that owns it. By default a run is sequential and threads each result into the next step; when you need throughput, a playbook can fire steps off concurrently and join their results explicitly.
The playbook service reads every .yaml file under its playbooks directory at startup, and reads changed files again on every reload or deploy. Each file is one playbook, built from four parts:
mode: saga or mode: async, defaulting to saga. The mode decides whether steps wait for one another or fire concurrently — described in full below.When a matching event arrives, the orchestrator interpolates the arguments (substituting values from the event and from earlier steps), emits each step as an action addressed to the right daemon, threads the results forward, and emits lifecycle facts so you can follow the whole run end to end. That’s the entire job: match, interpolate, dispatch, thread, repeat. A file only joins that rotation if it can actually run — every playbook is checked on entry and rejected if it references verbs, steps, secrets, or peers that don’t exist. That check, the feasibility gate, is described below.
Deliberately, a playbook has no if/else, no general expression language, and no arbitrary scripting. There is no hidden code inside the YAML. The one piece of control flow it does offer is a bounded loop: step for counted iteration — a fixed, capped range, not open-ended logic. If a workflow needs real branching, parsing, or transformation, that complexity lives inside the specialist daemons, not in the playbook. Playbooks stay short, readable, and reviewable; the daemons stay powerful. This is what keeps automations easy to audit.
Good to know. A playbook is a description, not a program. It says “extract these fields, then save this row, then send this webhook” — there is no
if/else, no general scripting, and no environment access. It can repeat a step a fixed number of times with a boundedloop:, but it never runs arbitrary code. The key property is “no free-form logic”, not “no control flow at all”.
| What it is | The orchestrator that loads and runs every playbook |
| What it reads | YAML playbooks from a provisioning/, business/, and teardown/ folder |
| How a playbook is built | A name, a trigger, an optional mode, and ordered steps |
| Step shapes | Exactly one of four per step: run:, parallel:, loop:, or wait_for: |
| How a step targets a daemon | run: <prefix>.<operation> — for example run: database.write |
| Execution model | Sequential by default (saga); fire-and-forget with explicit joins in async mode |
| Concurrency | Many runs in flight at once; a slow step never blocks unrelated work; per-resource ordering preserved |
| Flood control | Optional per-trigger debounce_ms and max_concurrent — excess matching events are dropped, never queued |
| Logic in the playbook | No if/else or scripting — only a bounded loop: for counted iteration; everything else lives in the daemons |
| On failure | The run stops at the failing step and a Fact.Playbook.Failed is emitted — no retry, no dead-letter queue |
| On a bad playbook file | Rejected on entry — the last good version keeps serving and Fact.Playbook.Rejected is emitted |
| How you operate it | Validate (grammar or a full dry-run), reload, trigger, cancel, and list runs from the command-line console |
Every playbook starts from the same parts. Here is the smallest possible complete playbook — one trigger, one step:
name: log-new-signup
description: Write a row to the audit table whenever a signup event arrives.
trigger:
event: Fact.Http.Received
filter:
route.eq: signup
steps:
- id: record
run: database.write
with:
table: signups
row:
email: ${trigger.body.email}
seen_at: ${trigger.received_at}
name identifies the playbook; description documents intent for whoever reads it next.event to listen for, with an optional filter that must match before the playbook fires, and two optional flood-control attributes (debounce_ms, max_concurrent) described below. See Triggers & scheduling.mode: saga | async (default saga) that controls how steps relate to one another in time.run: action, a parallel: group, a bounded loop:, or a wait_for: join. A step may carry an id so later steps can reference its output.The full grammar — every field, every shape — is documented in Playbook anatomy.
Each step is exactly one of these four shapes — mixing more than one in a single step is rejected by the validator.
| Shape | What it does |
|---|---|
run: | Runs one daemon action — <prefix>.<operation> with an optional with: block of arguments. This is the workhorse step. |
parallel: | A single-level group of run: actions that are dispatched together rather than one after another. No nesting — you can’t put a loop or another parallel block inside it. |
loop: | A bounded counted loop — for a counter, from/to an inclusive integer range, with a mandatory max hard cap and an optional until early-exit. Its do: body holds plain run: steps only. |
wait_for: | A join used in async mode — it blocks until one specific response fact arrives, matching on the causation id of an earlier fire-and-forget step. |
A bounded loop refuses to misbehave: the max cap is mandatory, and a range wider than the cap aborts the run loudly rather than quietly truncating. A typical loop reads like this:
steps:
- loop:
for: i
from: 1
to: 5
max: 100
do:
- run: webhook.send
with:
endpoint: paging-endpoint
body:
attempt: ${loop.i}
A playbook’s mode decides how its steps relate in time. The two modes share the same step shapes — they differ only in whether each run: waits for its result before the next step begins.
saga (the default). Steps run in order, and every run: step waits for its own response fact before the next step starts. That response is what ${prev} carries forward. This is the original, intuitive behaviour, and every playbook that doesn’t set a mode gets it — existing playbooks keep working unchanged.async. run: steps become fire-and-forget — the orchestrator emits the action and moves straight on without waiting for a result. When you do need a result, you add an explicit wait_for: step that joins on the earlier action’s causation id. This lets one playbook fan out many actions at once and then join only the results it actually needs.Here is the same idea both ways. In saga mode the second step would wait for the first; in async mode both fire at once and a wait_for: joins the result you care about:
mode: async
steps:
- id: cls
run: ai.classify # fires, does not wait
with:
text: ${trigger.envelope.body_text}
categories: [invoice, complaint, spam, other]
- run: database.write # fires immediately too, not waiting for cls
with:
table: inbound
row:
from: ${trigger.envelope.from.email}
- wait_for: # now join only the classify result
event: Fact.AI.Classified
match:
causation: ${cls}
id: cls_result
When to reach for async. Stay with the default
sagamode for ordinary “do this, then this” workflows — it reads top to bottom and threads results automatically. Switch toasyncwhen a playbook needs to start several independent actions at the same time and join only the ones it depends on: many fire-and-forget actions plus one or a fewwait_for:steps give you high concurrency instead of a long chain of serial waits.
The playbook service processes work concurrently. It decouples accepting triggers from running them, so it can keep many playbook runs in flight at once, bounded by configurable concurrency caps and limited mainly by host CPU rather than an artificial serial ceiling. The same is true of the specialist daemons: each dispatches the actions it receives through a bounded worker pool, so a slow operation — a long network scan, an SSH command, a big query — no longer holds up unrelated work.
Concurrency never reorders operations that touch the same thing. Actions targeting the same resource — the same schedule name, the same page, the same edge route, the same PLC — are still serialised in the order they arrived; only independent actions run side by side. The practical effect is that a simple single-step run completes in about a tenth of a second, throughput scales with the host as you raise the concurrency caps, and one slow run never stalls the others. And if one particular trigger can arrive in floods, the playbook itself can be debounced or capped with the trigger governor described below.
Concurrency vs. ordering. Running concurrently is about throughput, not about changing the meaning of a single playbook. Within one run,
sagastill waits step by step andasyncstill joins exactly where you put await_for:. Across many runs, work overlaps freely — while preserving order per resource.
Playbooks live in three sibling folders, and the folder a playbook sits in tells you when it runs and what it is allowed to do. Every .yaml file under the playbooks directory is loaded regardless of folder — the folders are about role and convention, not about loading.
| Folder | When it runs | What it’s for |
|---|---|---|
| provisioning/ | At startup, on Fact.System.Boot | Register a resource once — an AI provider, a webhook endpoint, a mailbox alias. Re-runs harmlessly on every boot. The only place secrets may be used. |
| business/ | On real events, as they happen | Your day-to-day automations. They refer to resources by the alias that a provisioning playbook registered, and never hold secrets themselves. |
| teardown/ | When an operator triggers them | Unregister or rotate a resource — the clean-up counterpart to provisioning. |
Provisioning playbooks share a standard trigger: the event Fact.System.Boot with the filter component.eq: playbook-service, so they fire exactly when this daemon starts. They are written to be idempotent — registering the same resource again on the next boot is a no-op. This split — secrets and one-time setup in provisioning/, everyday logic referring to aliases in business/ — is the heart of how Binions keeps credentials out of your day-to-day automations. The reasoning behind it is covered in Provisioning vs business playbooks.
A trigger is an event name and, optionally, a filter. The event is matched exactly. The filter is a small map of conditions, and every condition must match (they are combined with AND) — with one reserved key, or:, that adds a list of alternative branches, described just below. A condition key may use a dotted path to reach into nested fields of the event — for example body.customer.email.endswith. A bare key with no operator suffix means “equals”. If a field named in a filter is missing from the event, it is treated as null for matching.
| Operator | Matches when the field… |
|---|---|
.eq / .ne | equals / does not equal the value (a bare key with no suffix is .eq) |
.gt / .ge | is greater than / greater than or equal to the value |
.lt / .le | is less than / less than or equal to the value |
.contains | contains the value (substring or membership) |
.startswith / .endswith | begins / ends with the value |
.in / .not_in | is / is not one of a list of values |
.is_null / .is_not_null | is null / is present — a field the event lacks counts as null, so .is_null also matches a missing field |
has_attachments | is a boolean test for whether the event payload carries attachments |
A few worked filters — each line is one condition, and all listed conditions must hold:
# Only emails delivered into the accounts inbox that carry an attachment
trigger:
event: Fact.Mail.Received
filter:
via.eq: accounts-inbox
has_attachments: true
# Only large orders from a known set of regions
trigger:
event: Fact.Http.Received
filter:
route: order # bare key = .eq
body.total.ge: 100
body.region.in: [UK, IE, FR]
body.customer.email.endswith: "@bigcorp.example"
That is the complete operator set. The operators above — the comparison, membership, and null tests plus the
has_attachmentsboolean — are all the filter language offers, and theor:branch list below is its only structural form: there is nonot, and branches cannot nest. Beyond that, filter on the envelope fields the event actually carries, and let a daemon inspect the contents in a step. A field your filter names but the event lacks is treated as null, so an.eqagainst a missing field simply doesn’t match — and.is_nulldoes.
or:Top-level conditions AND together — which is exactly right until one playbook needs to catch two or three variants of the same event. For that, the filter reserves one key: or:. Its value is a flat list of branches, each branch an ordinary condition map whose entries must all hold. The whole filter matches when every top-level condition matches and at least one branch matches:
name: or-filter-mail-triage
description: >
Invoices reach triage whether the subject says faktura, invoice,
or the sender is the accounting domain with an attachment.
trigger:
event: Fact.Mail.Received
filter:
via.eq: crm-inbox # top-level condition — always required
or: # ...AND at least one of these branches:
- subject.contains: "faktura"
- subject.contains: "invoice"
- { from.email.endswith: "@accounting.example", has_attachments: true }
debounce_ms: 2000
max_concurrent: 4
steps:
- id: persist
run: database.write
with:
table: triage
row:
subject: ${trigger.envelope.subject}
sender: ${trigger.envelope.from.email}
The third branch shows that a branch may hold several conditions — they AND together inside the branch. The shape is deliberately flat: an or: nested inside a branch, an empty or: list, an empty branch, or a branch that is not a map are each a deterministic validation error, caught before the playbook ever loads — never silently ignored. There is no not and no deeper nesting. When a trigger really has three or more fully independent shapes rather than variants of one intent, keep them as separate playbooks — each stays short enough to read at a glance.
Some events arrive in bursts — a chattering sensor, a mail loop, a retrying upstream. Two optional trigger attributes, both off by default, keep a bursty trigger from flooding the platform. They sit alongside event and filter, as in the triage example above:
debounce_ms: N — leading-edge debounce. The first matching event spawns a run immediately; a matching event that arrives less than N milliseconds after the previous spawned run is dropped — logged and counted, but no run starts. Because the window is measured from the last run actually spawned, a steady stream of events yields at most one run per N milliseconds.max_concurrent: N — a hard cap on in-flight instances of this playbook, queued and running counted together. While the cap is reached, further matching events are dropped, not queued — a governed playbook never builds an invisible backlog.In the triage example, debounce_ms: 2000 spaces runs at least two seconds apart and max_concurrent: 4 keeps at most four triage runs in flight at once. Dropped events show up in the logs and the metrics, so you can see exactly what the governor is absorbing. One deliberate exception: a manual trigger or replay from the console bypasses the governor — the operator asked for that run, so it happens.
Each run: value is a lowercase <prefix>.<operation>. The prefix names a daemon; the operation names the action. The orchestrator resolves that into the daemon’s real action and emits it — for instance run: database.write becomes Action.Database.Write addressed to the database daemon. The prefixes the resolver understands are:
| Prefix | Daemon it addresses |
|---|---|
database | The database daemon — queries and writes (see SQL database service) |
mail | The mailbox daemon — send and receive email (see Mailbox service) |
ai | The AI daemon — extract, classify, summarise (see AI injector service) |
webhook | The webhook daemon — call outbound HTTP endpoints (see Webhook caller service) |
scheduler | The scheduler daemon — timers and cron-style ticks (see Scheduler service) |
data | The data-transport daemon — move and transfer files (see Data transporter service) |
analytics | The analytics daemon — aggregate and analyse (see Data analyser service) |
traefik | The edge daemon — HTTP routes at the edge (see Traefik linker service) |
modbus | The industrial daemon — read and write PLCs (see MODBUS service) |
show | The page daemon — pages, templates, and assets on the built-in web server (see Showman service) |
One prefix is deliberately missing: the orchestrator itself. The playbook service exposes no verbs of its own — there is no playbook. prefix to run:. Operators drive it from outside, through the console’s control-plane actions (trigger, cancel, list) described in the Events section below, never from a playbook step.
Mind the prefix names. The prefix is the verb the resolver knows, not the daemon’s file name. Email is
mailbox), and analytics isanalytics. Always lead with the playbook verb form —run: mail.send,run: analytics.calculate_stats— and the platform maps it to the daemon’s underlying action for you. The complete catalogue — all sixty-four generic operations, plus showman’s twelveshow.*page verbs — is in Verb vocabulary.
Arguments inside with: can pull values from the running workflow using ${…} placeholders. These are the namespaces, and nothing else is accepted — a playbook naming any other namespace is rejected at load:
| Placeholder | Resolves to |
|---|---|
${trigger.X} | A field from the event that started the playbook — e.g. ${trigger.envelope.body_text} |
${prev.X} | A field from the result of the immediately preceding step |
${steps.<id>.X} | A field from a named earlier step — e.g. ${steps.extract.result} |
${secret.KEY} | A stored secret, by key (uppercase letters, digits, underscores). Provisioning playbooks only. |
${loop.<counter>} | The current iteration counter inside a loop: body — available only within the do: block of that loop. |
One rule decides the resulting type. If a value is exactly one placeholder on its own, the original type is preserved — a number stays a number, an object stays an object, a list stays a list. If the placeholder is embedded inside other text, the whole value becomes a string. So row: ${steps.extract.result} passes a structured object straight through, while subject: "Invoice ${trigger.envelope.from.email}" produces a string. And step results are read straight off the fact the daemon emitted — ${prev.X} and ${steps.<id>.X} address the payload’s own fields directly. The one exception is ai.extract, which nests its extracted fields under result — hence ${steps.extract.result.invoice_number}.
steps:
- id: extract
run: ai.extract
with:
text: ${trigger.envelope.body_text} # whole field, type preserved
fields:
- { name: supplier, type: text }
- { name: invoice_number, type: text }
- { name: total, type: decimal }
- { name: due_date, type: date }
- id: save
run: database.write
with:
table: invoices
row: ${steps.extract.result} # structured object, passed as-is
- id: greeting
run: mail.send
with:
from_alias: accounts-outbound
to:
- ${trigger.envelope.from.email}
subject: "Received invoice ${steps.extract.result.invoice_number}" # embedded -> string
Secrets are quarantined.
${secret.KEY}resolves only insideprovisioning/playbooks — the place that registers a resource once at boot. Everydaybusiness/playbooks refer to that resource by its alias and never see the secret value. Placeholders like${env.X},${vars.X}, or any function call are rejected outright. See Secrets for how keys are stored.
Here is a real, end-to-end business playbook: when an invoice lands in the accounts mailbox, extract its figures with AI, save them to the database, and notify a downstream endpoint. It runs in the default saga mode, so each step waits for its result before the next begins — threading the output of each into the next, with no logic of its own.
name: invoice-from-accountant
description: >
When an invoice email arrives in the accounts inbox, pull out the key
figures, store them, and notify the bookkeeping endpoint.
trigger:
event: Fact.Mail.Received
filter:
via.eq: accounts-inbox
steps:
- id: extract
run: ai.extract
with:
text: ${trigger.envelope.body_text}
fields:
- { name: supplier, type: text }
- { name: invoice_number, type: text }
- { name: total, type: decimal }
- { name: currency, type: text }
- { name: due_date, type: date }
- id: store
run: database.write
with:
table: invoices
row: ${steps.extract.result}
- id: notify
run: webhook.send
with:
endpoint: bookkeeping-endpoint # an alias registered at boot
body:
invoice: ${steps.extract.result}
record_id: ${steps.store.id}
Notice that bookkeeping-endpoint is an alias, not a URL or a token. It was registered once by a provisioning playbook at boot — the only place a secret (here, the endpoint’s credentials) is allowed:
name: register-bookkeeping-endpoint
description: Register the bookkeeping webhook endpoint once, at startup.
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: webhook.register_endpoint
with:
alias: bookkeeping-endpoint
url: https://books.example/api/invoices
auth_token: ${secret.BOOKKEEPING_TOKEN}
More patterns — fan-out and join, loops, scheduled jobs, multi-daemon chains — are collected in Example workflows, and a guided build is in Write your first playbook.
Parsing cleanly is not enough — a playbook must also be runnable. Every time a playbook enters the service — at boot, on a reload, or through a hot or MCP deploy — it passes a feasibility gate that checks what grammar alone cannot: does every reference in the file point at something that actually exists? A playbook that fails any check is rejected on entry and never starts serving. The gate rejects:
${steps.<id>} referring to a step id the playbook never declares;${loop.<var>} used outside its own loop body;wait_for: whose match.causation does not name a run: step;run: step targeting a daemon this service has no configured peer connection ([peer_redis]) for;${secret.KEY} naming a key that is missing from the secrets store.A rejection never takes your automation down. If an earlier version of the playbook was already serving, that last good version keeps serving, untouched, and the service emits Fact.Playbook.Rejected carrying the playbook name, the file, the concrete errors[], the source of the load, and kept — whether a previous version stayed live. A deploy over MCP gets the same errors back in its reply, so whoever deployed sees exactly what to fix. And because the rejection is an ordinary fact, it is also a trigger event — a one-step playbook turns every bad deploy into an alert:
name: alert-on-rejected-playbook
description: Mail the administrator whenever a playbook fails the entry checks.
trigger:
event: Fact.Playbook.Rejected
steps:
- id: page
run: mail.send
with:
from_alias: ops-out
to: ["admin@example.com"]
subject: "Playbook rejected: ${trigger.name}"
body_text: |
File: ${trigger.file}
Source: ${trigger.source}
Errors: ${trigger.errors}
Previous good version still serving: ${trigger.kept}
The same checks are available offline: binions-cliconsole validate --dry-run <file> runs the identical static analysis without touching the running service, so a playbook that passes a dry run will not bounce off the gate on deploy. Plain validate checks the grammar only. The validator reads multi-document files (playbooks separated by ---) and reports its findings per document.
The daemon is configured in a single TOML file. It declares where playbooks live, where per-key secret files live, its own dedicated Redis, which event streams to subscribe to, the health endpoint, and one peer-Redis block per daemon it may dispatch to.
# /opt/binions/playbook-service/config/application.toml
playbooks_dir = "/opt/binions/playbook-service/playbooks"
# Per-key files under here resolve ${secret.KEY} in provisioning playbooks.
secrets_dir = "/opt/binions/playbook-service/secrets/playbook-secrets"
# This daemon's own Redis: state, transactional outbox, idempotency claims.
[own_redis]
host = "127.0.0.1"
port = 6398
password_file = "/opt/binions/playbook-service/secrets/redis.password"
# One subscriber task per (redis, stream) pair the orchestrator listens on.
[triggers]
subscriptions = [
{ redis = "own", stream = "events:system" }, # Fact.System.Boot, etc.
{ redis = "mailbox-service", stream = "events:mailbox" },
]
# Health and metrics HTTP server.
[healthcheck]
listen_addr = "127.0.0.1:9108"
# One peer block per target daemon whose actions this playbook may emit.
[peer_redis."database-service"]
host = "127.0.0.1"
port = 6392
password_file = "/opt/binions/playbook-service/peer-secrets/database-service/redis.password"
[peer_redis."aiinjector-service"]
host = "127.0.0.1"
port = 6394
password_file = "/opt/binions/playbook-service/peer-secrets/aiinjector-service/redis.password"
# [otel] — optional: enable distributed tracing.
The shape is the same for every daemon — a Redis block with a password_file, a healthcheck.listen_addr serving health and metrics, and an optional tracing block. What is unique to the orchestrator is the [triggers] subscription list and the one-block-per-target [peer_redis] map — it needs a connection to every daemon it might dispatch a step to. A playbook that targets a daemon with no matching [peer_redis] block is rejected by the feasibility gate at load, so the gap surfaces immediately rather than mid-run. On a fresh install the service subscribes to the system, showman, scheduler, and mailbox event streams out of the box, so playbooks can trigger directly from HTTP requests (Fact.Http.Received), browser frames (Fact.Showman.WsMessage), schedules (Fact.Schedule.Fired), and inbound mail (Fact.Mail.Received) without extra wiring. See Daemon configuration for the common settings.
The orchestrator emits a lifecycle fact at each stage — from loading a file to finishing a run — so you can trace exactly what happened. Every event carries a correlation id that ties a whole playbook run together.
Fact.System.Boot — emitted by this daemon on startup; it is what fires the provisioning playbooks.Fact.Playbook.Rejected — a playbook file failed the feasibility gate at load time (boot, reload, or deploy). It carries the playbook name and file, the concrete errors[], the source of the load, and kept — whether a previous good version stayed in service. Like any fact, it can trigger a playbook of its own — the admin alert shown above.Fact.Playbook.Started — a matching event arrived and a run has begun.Fact.Playbook.StepCompleted — emitted after each individual step finishes successfully, so you can follow a run step by step.Fact.Playbook.Completed — every step ran successfully and the run finished.Fact.Playbook.Failed — a step failed (or a wait_for: timed out); the fact carries the failed step’s index and the reason.Fact.Playbook.Cancelled — an in-flight run was aborted by an operator.Between those, the orchestrator emits each step as an Action.<Domain>.<Verb> to the owning daemon. In saga mode it consumes the daemon’s result fact before continuing; in async mode it moves on immediately and rejoins results at a wait_for:, which matches on the earlier action’s causation id. Operators can drive the daemon directly — outside any playbook — with three control-plane actions from the console:
Action.Playbook.Trigger) — fire a named playbook by hand with a payload you supply, for testing or manual runs. Manual triggers (and replays) bypass the trigger governor — the operator asked for that run.Action.Playbook.Cancel) — abort an in-flight run by its correlation id.Action.Playbook.List) — get a snapshot of the runs currently in flight.On failure, a run stops — it does not retry. When a step fails, the executor halts the run at that step and emits
Fact.Playbook.Failedwith the step index and reason. Await_for:that times out fails the run the same way. Steps are not retried automatically, and there is no dead-letter queue collecting failed runs — you diagnose from the failure fact and the correlated logs, fix the cause, and re-trigger. Build retry or compensation into the daemons or a follow-up playbook if you need it. See Testing & debugging.
The daemon runs as a notify-type systemd unit with automatic restart and a watchdog, paired with its dedicated Redis instance. It exposes the standard HTTP endpoints — plus two of its own:
/health/live | Liveness — the process is up |
/health/ready | Readiness — playbooks loaded and Redis connected |
/metrics | Prometheus-style metrics |
/playbooks | The set of playbooks currently loaded |
/runs | Runs in flight, with their correlation ids |
Validate a playbook before you put it live — plain validate checks the grammar, and validate --dry-run additionally runs the same static feasibility checks the daemon applies at load, so a file that passes a dry run will not bounce off the gate. Then reload the running service to pick up new or changed files from the console:
# Grammar check (read-only)
binions-cliconsole validate /opt/binions/playbook-service/playbooks/business/invoice-from-accountant.yaml
# Grammar plus the load-time feasibility checks — the gate, offline
binions-cliconsole validate --dry-run /opt/binions/playbook-service/playbooks/business/invoice-from-accountant.yaml
# Reload the set of playbooks from disk
binions-cliconsole emit-control Control.Playbook.Reload
Provisioning runs on load; business waits for a trigger. When the playbooks reload, a file in
provisioning/runs immediately; one inbusiness/loads and then waits for its trigger to fire. A file that fails the feasibility gate does not load at all — the version already serving keeps running. Full console usage is in CLI console service and CLI console administration.