Every playbook is built from a small, fixed set of generic verbs. A verb names one thing a daemon can do — send an e-mail, run a query, call a web API, read a sensor — written as <prefix>.<operation>. There are exactly sixty-four of them, plus twelve presentation verbs for the page server. The same handful covers invoicing, support tickets, IoT telemetry, and factory control. This page lists every verb, the arguments it takes, and what it hands back to the next step.
The key idea. Verbs are capabilities, not business recipes. There is no
ai.extract_invoice— there isai.extract, and you tell it what to extract through arguments. Business meaning lives entirely in the parameters you pass.
extract, classify, write, send, read. It never carries a word like invoice or order; that meaning is in the arguments.<prefix>.<operation>. The prefix routes to the daemon that does the work; the operation is the action. Three prefixes differ from their daemon’s name: the mailbox daemon answers to mail.*, the dataanaliser to analytics.*, and the scheduler to scheduler.*.register_*.protocol: or backend: field, not a new verb. The same mail.register_mailbox registers IMAP, MQTT, AMQP, Redis, WebSocket, SSE, or Kafka; every database.* verb reaches PostgreSQL, SQLite, MySQL/MariaDB, MongoDB, or SQL Server.${prev.…} (or ${steps.<id>.…} if you named it). The Returns column below names the field you read. For the full mechanics, see Variables & data flow.Why so few verbs? A generic verb grows in capability without changing its name — and the build enforces it: a CI gate fails any change to the verb count or routing without a reviewed contract update. New use cases mean new playbooks, not new verbs. Arguments below are taken from the daemons themselves;
validatechecks a playbook’s shape but not its argument names, so this page is where you confirm them.
database.* (11)Durable, structured storage — the platform’s system of record. Where & when: any time a playbook must remember a fact or look one up. PostgreSQL by default; add backend: sqlite | mysql | mssql | mongo (with path: for SQLite or dsn: for the others) to target another engine with the same verbs — or register the target once with database.register_connection and reference it everywhere as connection: <alias>, so credentials never repeat in business playbooks.
| Verb | What it does & when to use it | Arguments | Returns |
|---|---|---|---|
database.register_table (prov) | Declare a table and its columns once, on boot. Idempotent. (columns may be omitted on the schemaless mongo backend.) | name, columns: [ { name, sql_type, nullable?, default? } ], indexes? | — |
database.register_connection (prov) | Register a named connection once — it connects eagerly (a bad DSN fails right here), and every other verb can then say connection: <alias>. Even a second PostgreSQL server is one registration away. | alias, backend, dsn (or path for SQLite) — secrets via ${secret.…} | ${prev.alias}, ${prev.replaced} |
database.write | Insert one row. | table, row: { col: value } | ${prev.id} |
database.query | Read rows back — structured fields, never raw SQL. Optionally join related tables, alias columns, de-duplicate, paginate, or ask for exactly one row. | table, columns?, join?, where?, order_by?, order_dir?, limit? (default 100), offset?, distinct?, single: first|one | ${prev.rows}, ${prev.count}; with single: also the flat ${prev.row.<col>} |
database.update | Change matching rows. | table, set, where (required) | affected count |
database.upsert | Insert, or update on conflict. | table, on_conflict: [cols], row | — |
database.delete | Remove matching rows. | table, where (required) | affected count |
database.transaction | Several writes as one atomic unit. A later op can reference an earlier write’s generated columns as "@tx:<op>.<column>" — parent-and-child rows in one commit (PostgreSQL/SQLite). | ops: [ { kind: write|update|delete, … } ] | — |
database.exists | Cheap yes/no: does any row match? | table, where? | ${prev.exists} |
database.count | How many rows match. | table, where? | ${prev.count} |
database.aggregate | Group and summarise — totals, averages, and counts per category, computed by the database. | table, group_by?, aggregates: [ { fn, col, as } ], where?, having?, order_by?, limit? | ${prev.rows}, ${prev.count} |
Column types accepted by register_table (case-insensitive, common SQL synonyms allowed): text, int, bigint, double, numeric, boolean, jsonb, timestamptz, uuid.
# Provisioning: declare the table once (filter conditions are exact matches)
- run: database.register_table
with:
name: invoices
columns:
- { name: supplier, sql_type: text }
- { name: total, sql_type: numeric }
- { name: paid, sql_type: boolean, nullable: false, default: "false" }
indexes:
- { name: idx_supplier, cols: [supplier] }
---
# Business: write, then read back the most recent five
- run: database.write
with:
table: invoices
row: { supplier: ${trigger.from}, total: ${steps.extract.result.total} }
- run: database.query
with:
table: invoices
where: { paid: false }
order_by: total
order_dir: DESC
limit: 5
Reading is more than exact matches. A where filter supports comparisons, text and set tests, null checks, time windows, and or groups; database.query can join a related table and alias its columns; and database.aggregate groups and totals rows on the server. Everything is still structured YAML — never raw SQL.
# Join orders to their customer, alias the columns, filter and sort
- run: database.query
with:
table: orders
join:
- table: customers
type: left # inner (default) or left
on: { left: orders.customer_id, right: customers.id }
columns:
- { col: orders.id, as: order_id }
- orders.total
- { col: customers.name, as: customer }
where: { orders.status.eq: paid }
order_by: orders.total
order_dir: DESC
---
# Group and total: revenue per category, biggest first
- run: database.aggregate
with:
table: orders
group_by: [category]
aggregates:
- { fn: sum, col: amount, as: revenue }
- { fn: count, col: "*", as: order_count }
where: { status.eq: paid }
having: { revenue.gt: 1000 }
order_by: revenue
order_dir: DESC
---
# Richer filters: comparisons, time windows, and or-groups
- run: database.query
with:
table: orders
columns: [id, total]
where:
status.eq: active
created_at.within: 24h # within / before / after take 30s, 15m, 24h, 7d, 2w
or:
- plan.eq: pro
- { trial.eq: true, credits.gt: 0 }
One row, flat. When a step needs exactly one value — a setpoint, a config row, the latest reading — add single: first (take the first row) or single: one (insist there is exactly one). The result then carries a flat row object, so the next step reads ${prev.row.<column>} directly; zero rows is a loud error instead of a null sneaking downstream. For paging through large sets, combine limit with offset; distinct: true de-duplicates the projected columns.
# Exactly one row, read flat by the next step
- id: target
run: database.query
with:
table: setpoints
order_by: id
order_dir: DESC
limit: 1
single: first # ${steps.target.row.value} — no array indexing
---
# Atomic parent + child: the line references the order's generated id
- run: database.transaction
with:
ops:
- kind: write
table: orders
row: { customer: ${trigger.body.customer} }
- kind: write
table: order_lines
row:
order_id: "@tx:0.id" # op 0's RETURNING id, resolved inside the transaction
sku: ${trigger.body.sku}
qty: ${trigger.body.qty}
Transaction references.
"@tx:<op>.<column>"points at an earlierwritein the sameopslist (0-based) and resolves against that write’s returned row before the transaction commits — foreign-key inserts without a second round-trip. Supported on PostgreSQL and SQLite; the other backends reject it with a clear error. A literal value that must start with@tx:is escaped by doubling the at-sign (@@tx:).
mail.* (8)The gateway to messaging and streaming feeds: e-mail over IMAP/SMTP and, through the same register verb, MQTT, AMQP, Redis pub/sub, WebSocket, SSE, and Kafka. Where & when: register a source once; each message it delivers fires a Fact.Mail.Received event your business playbooks trigger on, filtered by the alias (via). Beyond the live listener, five folder verbs work the mailbox like a filing cabinet — list folders, pull a folder’s messages on demand, move, flag, and file messages — so the mailbox itself can show the state of your process.
| Verb | What it does & when to use it | Arguments | Returns |
|---|---|---|---|
mail.register_mailbox (prov) | Register an inbox or feed under an alias. | alias, protocol: imap|mqtt|amqp|redis|ws|wss|sse|kafka, the matching settings block, topics?, smtp? | — |
mail.unregister_mailbox (teardown) | Stop listening on an alias and close its connections. | alias | — |
mail.send | Send an e-mail over SMTP — templated and threaded if you wish — or, on a broker-protocol mailbox, publish a frame instead. | e-mail: from_alias, to: […], subject, body_text and/or body_html or template_text/template_html + vars, in_reply_to?, references?, cc?, bcc? · broker publish: from_alias, payload, target (topic/channel/routing key) | ${prev} = send result |
mail.list_folders | List the folders of an IMAP mailbox. | alias | folder list |
mail.fetch | Pull a folder’s messages on demand — each one arrives as its own Fact.Mail.Received, exactly like the live listener’s, so per-message playbooks serve both paths. | alias, folder? (default INBOX), limit? (newest N, default 10, max 100), unseen_only?, since? (YYYY-MM-DD) | summary ${prev.count}, ${prev.batch_id} |
mail.move | Move a message to another folder (native move, with an automatic copy-and-delete fallback on older servers). | alias, uid, from_folder?, to_folder | move echo (method) |
mail.mark | Set or clear message flags. | alias, uid, folder?, flags: [seen|answered|flagged|deleted|draft], unset? | — |
mail.append | Write a message into a folder (stores, does not send) — audit copies, drafts, seeded folders. | alias, folder, subject+body_text or raw_b64, to? | — |
The settings block is named after the protocol. The common ones:
imap: { host, port?, username, password, mailbox_folder? } — default port 993, folder INBOX.mqtt: { host, port?, username?, password?, client_id?, keep_alive_secs?, topic_pattern? } — default port 1883; subscriptions go in the top-level topics: list. A topic_pattern with named segments (sensors/+device/+metric, trailing #rest) captures each incoming topic’s parts into Fact.Mail.Received.topic_params{}, so a filter can say topic_params.device.eq: pump-1 — no parsing step.amqp: { host, port?, username?, password?, vhost?, queue? } — or a single uri:.ws: { url, headers? } / sse: { url, headers? } / redis: { url } / kafka: { brokers, group_id, auto_offset_reset? }.smtp: { host, port?, username, password } — add this if the alias also sends mail.# Provisioning: an IMAP inbox (password from the secret store)
- run: mail.register_mailbox
with:
alias: faktury
protocol: imap
imap: { host: imap.example.com, username: faktury@example.com, password: ${secret.FAKTURY_IMAP_PASSWORD} }
---
# Provisioning: an MQTT telemetry feed — subscriptions live in topics:
- run: mail.register_mailbox
with:
alias: sensors
protocol: mqtt
mqtt: { host: mosquitto.internal, username: binions, password: ${secret.SENSOR_MQTT_PASSWORD} }
topics: [ "sensor/+/temperature" ]
---
# Business: react to a message and send a threaded reply
- run: mail.send
with:
from_alias: reports
to: [ ${trigger.from} ]
subject: "Re: ${trigger.subject}"
in_reply_to: ${trigger.envelope.message_id} # lands in the sender's thread
body_text: "Thanks — we received your message."
# Work a folder like a queue: pull unread, then per message flag + file it
- id: pull
run: mail.fetch
with:
alias: crm-inbox
folder: "Faktury"
unseen_only: true
limit: 20 # each message fires its own Fact.Mail.Received
---
# ...and in the per-message playbook (trigger: Fact.Mail.Received, folder.eq: Faktury):
- run: mail.mark
with: { alias: crm-inbox, uid: ${trigger.uid}, folder: ${trigger.folder}, flags: [seen] }
- run: mail.move
with:
alias: crm-inbox
uid: ${trigger.uid}
from_folder: ${trigger.folder}
to_folder: "Faktury/Zrobione"
Templated bodies render daemon-side. Give mail.send a template_text / template_html source plus a vars: map, and loops or conditionals live in the template while the playbook stays flat — an order confirmation renders its line-item table from ${steps.lines.rows} in one step. Rendering is strict: an undefined variable or broken template fails the send loudly, so a half-rendered invoice never leaves the platform. HTML output is escaped automatically.
Understanding a message is composition, not extra verbs. “Extract data from this e-mail” or “classify it” is answered by composing: trigger on
Fact.Mail.Received, thenai.extractorai.classify, thendatabase.write. The folder lifecycle — pull, flag, file — is built in, and every received message carries itsfolder, a flatfirst_attachmentmirror withattachments_count, and (for JSON broker frames up to 256 KiB) a parsedbody_jsonyou can read as${trigger.body_json.<field>}.
ai.* (5)A language model inside your workflow. Where & when: turning free text into structured data, sorting messages, or generating prose. Providers are named instances in the daemon’s configuration — Anthropic, any OpenAI-compatible endpoint (OpenAI, OpenRouter, DeepSeek, Ollama, vLLM, LM-Studio…), or Google — and a step picks one with provider:, chains fallbacks with providers: […], and may omit model: to use the instance’s default. Every call is metered per provider × model into a queryable usage ledger.
| Verb | What it does & when to use it | Arguments | Returns |
|---|---|---|---|
ai.inject | Generate text from a prompt — summaries, drafts, rewrites. | prompt, model?, system?, temperature?, max_tokens? (default 1024), provider? / providers?, max_cost_usd? | ${prev.text} |
ai.classify | Sort text into one of the categories you provide — the answer is guaranteed to be one of them. | text, categories: […], model?, provider? / providers?, max_cost_usd? | ${prev.category} |
ai.extract | Pull named fields out of free text or a document — typed, with the values coerced to real numbers, booleans and dates. | text, fields: [ { name, type?, hint? } ], on_mismatch? (fail default | null | raw), model?, provider? / providers?, max_cost_usd? | ${prev.result.<field>} |
ai.batch | Classify or extract up to 200 texts in ONE step — one aggregate result, per-item outcomes, and a running cost cap; a single bad item never kills the batch. | op: classify|extract, items: […], categories? / fields?, on_mismatch?, provider? / providers?, model?, max_cost_usd? | ${prev.results}, ${prev.ok}, ${prev.failed}, ${prev.cost_usd} |
ai.usage_report | Read the usage ledger — calls, tokens and USD per provider, model, or playbook run. | group_by: provider|model|correlation, since?, until? (YYYY-MM-DD) | ${prev.rows}, ${prev.total.cost_usd} |
# ai.extract — typed fields, a fallback chain and a pre-flight cost cap
- id: extract
run: ai.extract
with:
text: ${trigger.envelope.body_text}
providers: [primary, local] # try primary; any provider error falls through
max_cost_usd: 0.05 # pre-flight estimate must stay under 5 cents
on_mismatch: fail # a non-coercible value fails loudly at the source
fields:
- { name: total, type: decimal, hint: "grand total incl. tax" } # "1 234,56" -> 1234.56
- { name: supplier, type: text }
- { name: issued_on, type: date }
# ai.classify — route a support message (model comes from the instance default)
- run: ai.classify
with:
provider: local
text: ${trigger.envelope.body_text}
categories: [ billing, technical, sales ] # ${prev.category} is one of these
# ai.batch — N texts, one step, one aggregate result
- id: classify_all
run: ai.batch
with:
op: classify
items: ${trigger.body.texts}
categories: [billing, outage, feature, spam]
max_cost_usd: 0.25 # running cap across the whole batch
# ai.usage_report — what did this playbook run cost so far?
- id: cost
run: ai.usage_report
with:
group_by: correlation # no id = the current run
Typed extraction and loud failures. The canonical
fields[].typevaluestext,int,decimal,bool,dateare enforced: replies are coerced (European and US number formats normalise into real JSON numbers; dates accept RFC-3339 or YYYY-MM-DD), and a value that cannot be coerced tripson_mismatch. Every failure — timeout, rate limit, refused output, type mismatch, an unpriced model under a cost cap — is a reactableFact.AI.OperationFailedevent: a fallback playbook can page a human or reroute the work instead of the error dying in a queue. Cost caps are conservative: if the platform cannot price a provider/model pair, a capped call refuses to run rather than guess.
webhook.* (2)Outbound calls — HTTP/REST, SOAP (XML in the body), or gRPC — with retries, circuit breaking, and HMAC signing. Where & when: calling an external API, posting to chat/ops systems, or replying to an HTTP trigger. A registered endpoint can also carry a full OAuth2 setup the daemon refreshes for you. One send can fan out to up to 32 registered endpoints at once, and with expect_json: true an API’s reply becomes structured data the next step reads directly.
| Verb | What it does & when to use it | Arguments | Returns |
|---|---|---|---|
webhook.register_endpoint (prov) | Store an endpoint under an alias — URL, headers, auth. | name, url, auth? (e.g. oauth2), headers? | — |
webhook.send | Make one outbound call — to a named endpoint:, an inline url:, or a whole endpoints: group. | url? or endpoint? or endpoints: […] + mode?, method? (default POST), headers?, body as body? / body_b64? / form?+form_files?, expect_json?, timeout_ms?, sign?, protocol? (http|grpc|ssh) | ${prev.status}, ${prev.response_json.…} (with expect_json), ${prev.response_headers.…}, ${prev.response_body_truncated} |
Only a 2xx counts as delivered. A non-2xx response (or a timeout, or a blocked address) produces no delivery, so the step fails and the run halts — point a call at an endpoint you know returns success, or handle the failure path explicitly. To use an API’s answer, set
expect_json: true: the reply is parsed intoresponse_jsonfor direct interpolation, a non-JSON reply fails loudly, and an oversized body (256 KiB by default) fails as too large instead of silently truncating. Without it, the body still comes back truncated as a string for observability.
# Call a registered endpoint (its URL, headers, and secret stay hidden)
- run: webhook.send
with:
endpoint: slack-alerts
body: { text: "Nightly job finished" }
# An API as a data source: parse the reply, read its fields in the next step
- id: fetch
run: webhook.send
with:
url: "https://api.example.com/rates/latest"
method: GET
expect_json: true # ${steps.fetch.response_json.rates.EUR}
# Inline URL, signed with an HMAC whose key lives in an environment variable
- run: webhook.send
with:
url: "https://hooks.example.com/ingest"
body: ${prev.rows}
sign: { algorithm: hmac-sha256, key_env: HOOK_SIGNING_KEY, header: X-Signature }
# One alert, three systems: fan out to registered endpoints and wait for the summary
- id: fanout
run: webhook.send
expect: Webhook.GroupCompleted # the step completes on the group summary fact
with:
endpoints: [ops-primary, ops-standby, audit-sink]
mode: parallel # or sequential (declaration order, no stop-on-failure)
body: { source: "binions-heartbeat" }
# ${steps.fanout.ok} / ${steps.fanout.failed} / per-endpoint facts carry group_id
Bodies beyond JSON. body_b64 sends raw bytes (a PDF, an image; the content-type comes from headers, HMAC signing covers the decoded bytes), and form + form_files build a multipart/form-data request — text fields plus base64 file parts in one upload. The three body sources are mutually exclusive, and timeout_ms overrides the client-wide timeout per call. Multicast groups accept HTTP endpoints only, and each member still runs the full pipeline: registry resolve, OAuth refresh, address guard, circuit breaker, signing.
scheduler.* (6)Time-based triggers. Where & when: nightly jobs, periodic polls, recurring reports — and one-shots and whole calendars of dates, straight from your data. Register a schedule once; every firing emits a Fact.Schedule.Fired event your playbooks trigger on, filtered by name. Firings are quantised to the engine tick (about 30 s).
| Verb | What it does & when to use it | Arguments | Returns |
|---|---|---|---|
scheduler.register_schedule (prov) | Add a named schedule — cron, a plain fixed interval, a one-shot instant, or a calendar of dates. Upserts by name. | name, exactly one of cron_expr (also @every <N>[s|m|h|d]) / interval_seconds / at / dates: […] (up to 100), timezone? (IANA), jitter_secs?, payload? | — |
scheduler.deregister_schedule (teardown) | Remove a schedule. | name | — |
scheduler.pause_schedule / scheduler.resume_schedule | Toggle a schedule without removing it. | name | — |
scheduler.trigger_now | Fire a schedule immediately (manual run / replay). | name | — |
scheduler.list_schedules | List schedules with next/last firing times. | prefix? | schedule list |
cron_expris a cron expression with seconds. The six-field form puts seconds first —"0 0 3 * * *"is 03:00:00 every day. Five- and seven-field forms are also accepted. Anything you put underpayload:rides along into every firing as${trigger.payload.…}.
Local hours, one-shots and calendars. Add timezone: Europe/Warsaw and the cron grid keeps local wall-clock hours through both daylight-saving transitions; a timestamp that falls into the spring-forward gap is refused loudly at registration. at: registers a one-shot instant — RFC-3339 or a naive YYYY-MM-DDTHH:MM[:SS] resolved in the timezone, so a date can come straight from a database row (at: ${prev.row.termin}). dates: registers a whole calendar; each instant fires once, overdue ones catch up one per tick. Exhausted schedules — a fired one-shot, a finished calendar — deregister themselves (a Fact.Schedule.Deregistered event marks it), so no dead entries pile up. On a fleet, jitter_secs spreads a shared cron line: each fire becomes due a deterministic 0…N seconds past its grid instant, so fifty installs don’t strike the same second.
# Provisioning: a nightly schedule with a payload, at 03:00 LOCAL time
- run: scheduler.register_schedule
with:
name: nightly-digest
cron_expr: "0 0 3 * * *" # 03:00:00 daily
timezone: Europe/Warsaw # survives both DST changes
jitter_secs: 120 # fleet-friendly: due 0-120 s past the grid
payload: { audience: subscribers }
---
# Business: a one-shot straight from data — fires once, then removes itself
- run: scheduler.register_schedule
with:
name: "term-${trigger.item.id}"
at: ${trigger.item.due_at} # e.g. 2026-07-15T09:00:00 (naive, resolved in timezone)
timezone: Europe/Warsaw
payload:
invoice_no: ${trigger.item.invoice_no}
---
# Business: react to the firing
trigger:
event: Fact.Schedule.Fired
filter: { name.eq: nightly-digest }
steps:
- run: database.query
with: { table: orders, where: { shipped: false } }
data.* (11)Files, objects, and format conversion — the platform’s file bus. Where & when: archiving documents, moving blobs between S3/MinIO/SFTP/FTP/local storage (plus MongoDB GridFS and write-only HTTP ingest sinks), lifting the text out of a PDF or DOCX, sharing an object as an expiring link, or reshaping a payload between steps. Storage targets are referenced by a bucket: name; omit it to use the default bucket. Every file-carrying event exposes a flat {bucket, key} pair, and every data.* verb accepts them — that convention is what lets any source compose with any sink.
| Verb | What it does & when to use it | Arguments | Returns |
|---|---|---|---|
data.upload | Store an object/file. | key, bucket?, one of body / body_b64 / body_json, content_type? | upload result |
data.download | Fetch an object back. | key, bucket?, as_b64? | ${prev.body} |
data.list_objects | List objects under a prefix — sorted, if you ask, with the first match served flat (“the newest file” in one step). | bucket?, prefix?, max_keys?, sort: name|mtime|size, order: asc|desc | object list; ${prev.first.key} etc. |
data.delete_object | Remove an object. | key, bucket? | — |
data.move / data.copy | Move or copy an object between any two registered backends — watched-folder to archive, mail attachment to GridFS, markdown to a RAG sink. A move whose source delete fails is a loud error, never a silent duplicate. | from_bucket?, from_key, to_bucket?, to_key? (defaults to from_key) | ${prev.to_bucket}, ${prev.to_key}, ${prev.bytes}, ${prev.sha256} |
data.presign | Turn an object into a time-limited download URL — share a link, not an attachment. S3/MinIO backends only; others refuse loudly. | bucket?, key, expires_secs? (default 3600, max 7 days) | ${prev.url}, ${prev.expires_at} |
data.parse | Lift the text out of a stored document — PDF (text layer; scans have no text and fail loudly), DOCX, or plain text — ready for ai.extract. | bucket?+key or content_b64, format? (auto|pdf|docx|text), max_text_bytes? (default 1 MiB) | ${prev.text}, ${prev.format}, ${prev.truncated} |
data.transform | Convert a payload between formats. | format_from, format_to (json|csv|xlsx), one of body / body_b64 | ${prev.body} |
data.register_bucket (prov) | Bind a storage backend under a bucket: name. The registry is in-memory — re-register on every boot from a provisioning playbook. | bucket_id, backend (minio|s3|sftp|ftp|local_fs|mongo|http_ingest), endpoint?, host?, port?, root?, dsn?+database? (mongo), credentials? (access_key+secret_key), watch_dir?, region?, force_path_style? | — |
data.unregister_bucket (teardown) | Remove a bucket binding (stops its watch poller). | bucket_id | — |
Buckets are configured or registered, then named. The install’s default object store lives in the daemon’s configuration; additional backends are bound at run time with
data.register_bucket. That registry is in-memory: a daemon restart forgets every binding, so registration belongs in a provisioning playbook triggered byFact.System.Boot— provisioning playbooks re-run on every boot, healing the registry automatically. Registering binds a backend; it does not create the physical bucket. Awatch_dir:on a registered bucket starts a poller that emitsFact.Data.FileDiscoveredfor every new file. Two backends are write-oriented specialists:mongostores files in MongoDB GridFS (dsn+database; re-putting a key replaces its revisions), andhttp_ingestis a write-only sink that POSTs each object asmultipart/form-datato anendpoint(optional bearer token fromcredentials.secret_key) — the natural way to feed a RAG or indexing service; reads and lists on it refuse loudly. Neither supportswatch_dir.
# Convert queried rows to CSV, then archive the file
- id: rows
run: database.query
with: { table: invoices }
- id: csv
run: data.transform
with: { format_from: json, format_to: csv, body: ${steps.rows.rows} }
- run: data.upload
with: { bucket: exports, key: "invoices/${trigger.fired_at}.csv", body: ${steps.csv.body} }
# Provisioning — heal the in-memory bucket registry on every boot
name: register-storage-buckets
trigger:
event: Fact.System.Boot
filter: { component.eq: playbook-service }
steps:
- run: data.register_bucket
with:
bucket_id: mailbox-attachments
backend: minio
endpoint: "http://127.0.0.1:9000"
credentials:
access_key: ${secret.MINIO_ACCESS_KEY}
secret_key: ${secret.MINIO_SECRET_KEY}
# A stored PDF becomes typed data — parse the text layer, then extract
- id: parse
run: data.parse
with:
bucket: ${trigger.bucket}
key: ${trigger.key} # e.g. an offloaded mail attachment
max_text_bytes: 262144
- id: extract
run: ai.extract
with:
text: ${steps.parse.text}
fields:
- { name: total, type: decimal }
---
# Share a report as a 24-hour link instead of a heavy attachment
- id: link
run: data.presign
with: { key: "${trigger.key}", expires_secs: 86400 }
---
# Archive every offloaded .docx attachment (to_key defaults to the source key)
- run: data.move
with:
from_bucket: ${trigger.bucket}
from_key: ${trigger.key}
to_bucket: edi-archive
data.parsereads text layers, not pixels. There is no OCR: a scanned PDF has no text layer and fails loudly (no_text_layer) instead of returning garbage. Route scans to an external OCR service withwebhook.send+expect_jsonand carry on from its reply; born-digital PDFs, DOCX and plain text parse entirely inside the platform.
analytics.* (7)In-flight data processing over records you have collected — statistics and outliers, but also filtering, reshaping and per-item fan-out. Where & when: alerting thresholds, trend detection, ranking, projection, narrowing a result set with the familiar filter grammar, deriving new fields without an expression language, or exploding a list into one event per element. Each verb takes a data: (or items:) list of records — typically the rows from a preceding database.query.
| Verb | What it does & when to use it | Arguments | Returns |
|---|---|---|---|
analytics.calculate_stats | Descriptive statistics over a field — whole-set and, if you ask, per group. | data, field, ops: [ { name } ] — count, sum, avg, min, max, median, stdev, variance, percentile; group_by?: […] | ${prev.results}, flat ${prev.values.avg}; ${prev.groups} |
analytics.filter | Narrow an in-memory record set with the SAME grammar as trigger filters — the “where” for data that did not come from the database. | data, where: { field.op: value, … } | ${prev.rows}, ${prev.count}, ${prev.input_count} |
analytics.derive | Reshape records through a pipeline of named operations — cut an invoice number out of a subject line, normalise "1 234,56" into a number, compute a gross total, keep only the fields you need — no expression language, just verbs. | data, ops: [ { fn, … } ] (up to 64 ops / 10 000 records) | ${prev.rows}, ${prev.count}; flat ${prev.row.<field>} when one record remains |
analytics.emit_items | Explode a list into one Fact.Analytics.ItemEmitted per element — THE per-item pattern: a second playbook triggers once per item, each with its own run, retries and audit trail. Hard cap 1000. | items | summary ${prev.count}, ${prev.batch_id} |
analytics.detect_anomaly | Flag outliers in a series — each anomaly carries the ORIGINAL record and its index, ready to act on. | data, field, method (z_score|iqr), threshold? | ${prev.anomalies} (with record, record_index), ${prev.anomaly_count} |
analytics.rank | Order records by criteria. | data, criteria: [ { field, direction? } ], top_n? | ${prev.ranked} |
analytics.forecast | Project a series forward. | data, field, method (linear|moving_avg), horizon | ${prev.forecast}; flat ${prev.next} / ${prev.last} |
# Query a recent series, then scan it for outliers
- id: recent
run: database.query
with: { table: readings, order_by: observed_at, order_dir: DESC, limit: 200 }
- run: analytics.detect_anomaly
with: { data: ${steps.recent.rows}, field: value, method: z_score, threshold: 3.0 }
---
# Filter in memory, then fan out: one reminder playbook run per VIP invoice
- id: vips
run: analytics.filter
with:
data: ${steps.overdue.rows}
where:
segment: vip # bare key = equals; same operators as triggers
- run: analytics.emit_items
with:
items: ${steps.vips.rows} # each row -> Fact.Analytics.ItemEmitted
---
# Derive: subject line + EU-formatted amount -> typed, mail-ready fields
- id: shape
run: analytics.derive
with:
data: "${steps.q.rows}"
ops:
- { fn: regex_extract, as: invoice_no, of: subject, pattern: "INV-[0-9]{4}-[0-9]+" }
- { fn: to_number, as: net, of: amount_raw } # "1 234,56" -> 1234.56
- { fn: mul, as: gross, of: [net, 1.23] }
- { fn: round, as: gross, of: gross, decimals: 2 }
- { fn: pick, of: [invoice_no, gross] }
The derive toolbox. Operations are named, never expressions: numeric (
add sub mul div round abs coalesce), conversions (to_number to_string to_bool— European and US separators normalise), string (concat upper lower trim replace substring split pad length regex_extract regex_match), object (get set rename pick omit merge), array (unique sort slice flatten count_by join). Operands are dot-paths by default,{ lit: … }for literals. A typo’d operation fails at validation, a per-record miss yieldsnull(pair withcoalesce), and regex patterns run on a linear-time engine — a playbook can’t freeze the daemon with a pathological pattern.
Decisions belong in a verb, not an
if. A playbook has no conditional, so “alert only when something is wrong” is expressed by lettinganalytics.detect_anomalymake the judgement and forwarding its result — not by branching in the YAML.
traefik.* (4)Programs the edge router. Where & when: exposing an internal backend (including WebSocket/SSE/gRPC) at a public hostname — with declarative security (auth, IP allow-lists, rate limits), path routing, and weighted load-balancing over several backends. One verb registers a route for any of seven protocols.
| Verb | What it does & when to use it | Arguments | Returns |
|---|---|---|---|
traefik.register_route (prov) | Publish a backend through the edge — optionally guarded, path-scoped, and spread over weighted backends. | route: { name, host, backend, protocol?, entry_points?, tls?, path_prefix?, strip_prefix?, middlewares?, backends? } — protocol: http|https|ws|wss|sse|grpc|grpc-web | — |
traefik.unregister_route (teardown) | Remove a route. | name | — |
traefik.list_routes | List active routes. | — | route list |
traefik.reload | Force a router reconfiguration. | — | — |
# Expose an internal service at a public hostname
- run: traefik.register_route
with:
route:
name: hooks
host: hooks.example.com
backend: "http://127.0.0.1:9200"
protocol: https
---
# Guarded entry: office IPs only, 10 req/s, under a path prefix
- run: traefik.register_route
with:
route:
name: hooks-in
host: "hooks.example.com"
path_prefix: "/in" # ANDed onto the host rule
strip_prefix: true # backend sees the path without /in
backend: "http://127.0.0.1:9200"
protocol: https
middlewares:
- type: ip_allowlist
source_ranges: ["203.0.113.0/24", "10.0.0.0/8"]
- type: rate_limit
average: 10
burst: 20
Route middlewares are a closed, declarative set — each entry names its type and only that type’s fields (a typo’d field is a loud failure, not a silent pass): basic_auth (htpasswd-format users), forward_auth (address, optional auth_response_headers, trust_forward_header), ip_allowlist (source_ranges of IPs/CIDRs), rate_limit (average, burst, period_secs), and headers (request/response maps). Your middlewares run before the protocol’s own (SSE headers, gRPC-Web conversion). To balance one hostname over several instances, add backends: [ { url, weight }, … ] — traffic splits by weight.
modbus.* (10)Industrial I/O over MODBUS TCP (and RTU tunnelled over TCP). Where & when: polling sensors and actuators, decoding raw registers into engineering units, alarming on thresholds, and feeding readings into the rest of a playbook. Register a device once; then read or write it by alias. The daemon can also be a MODBUS device: a built-in server publishes platform-computed values for SCADA to read — and, when enabled, accepts writes from external masters as events.
| Verb | What it does & when to use it | Arguments | Returns |
|---|---|---|---|
modbus.register_plc (prov) | Register a PLC/device under an alias — with polled subscriptions that can decode values and raise threshold alarms, and optional vendor defaults (profile: siemens). | alias, transport: { kind: tcp, host, port?, unit_id? }, profile?, poll_interval_ms?, read_subscriptions? (strings or { spec, decode?, deadband?, thresholds? }), allowed_register_ranges?, emit_mode? | — |
modbus.unregister_plc (teardown) | Remove a device. | alias | — |
modbus.set_server_registers | Publish live values into the daemon’s own served register map — the platform computes, SCADA reads. | holding? / input?: [ { address, value } ], coil? / discrete?: [ { address, value } ] | — |
modbus.list | List registered devices. | — | device list |
modbus.read | Read coils or registers — raw, or decoded into engineering units. | alias, register_type (coil|discrete_input|input|holding), address, quantity, decode? ({ type, word_order?, scale?, offset? }) | ${prev.values}; flat ${prev.value} when quantity is 1; ${prev.decoded} with decode |
modbus.write | Write coils or registers — optionally read back and verified. | alias, operation (e.g. write_single_register) + its fields (address, value/values), verify? | write echo; ${prev.verified} |
modbus.read_device_id | Read device identification. | alias, read_code?, object_id? | device id objects |
modbus.read_file_record / modbus.write_file_record | File-record access. | alias, file_number, record_number, … | record values |
modbus.read_fifo | Read a FIFO queue. | alias, address | FIFO values |
Writes are deny-by-default. A freshly registered PLC accepts no writes until you list permitted register ranges in its registration — a safety guard for industrial targets. For critical setpoints add
verify: true: the daemon reads the range back after writing and fails loudly if the device reports different values, so a dropped write can never masquerade as success.
Inbound industrial events. MODBUS itself is poll-based, but the daemon turns polling into triggers for you: a registered subscription emits Fact.Modbus.ValueChanged as values move (with an optional decode the value arrives in engineering units — a real 72.5 °C, not a raw 725), and thresholds with a deadband add a hysteresis alarm that emits exactly one Fact.Modbus.ThresholdCrossed per episode edge (direction: enter|exit) — an alarm, not an event storm. With the built-in server enabled and marked writable, an external master’s write lands as Fact.Modbus.ServerRegisterWritten — SCADA pushing a value into your playbooks.
# Provisioning: register the device with a decoded, alarmed subscription
- run: modbus.register_plc
with:
alias: line-1
profile: siemens # vendor defaults (e.g. word order) unless overridden
transport: { kind: tcp, host: 10.20.0.5, port: 502, unit_id: 1 }
poll_interval_ms: 1000
read_subscriptions:
- spec: "holding/100:2"
decode: { type: f32, scale: 0.1 } # ValueChanged.new_value in °C
deadband: 0.5
thresholds:
- { op: gt, value: 80.0, label: temp_high }
allowed_register_ranges: [] # read-only device
emit_mode: on_change
---
# Business: read one holding register each time a schedule fires
- run: modbus.read
with: { alias: line-1, register_type: holding, address: 40001, quantity: 1 }
# quantity 1 -> the lone value is also flat: ${prev.value}
---
# Business: publish computed values for SCADA to poll (served register map)
- run: modbus.set_server_registers
with:
holding:
- address: 0
value: ${steps.stock.row.qty}
show.* (12, outside the 64)The browser-facing daemon: it serves pages and assets, renders templates, and runs the live HTTP/WebSocket/SSE gateway. Where & when: publishing an operator page or a live dashboard. Its twelve verbs are addressable from playbooks like any other, but sit outside the counted 64 — they are the platform’s own presentation controls, not part of the integration contract.
| Verb | What it does & when to use it | Arguments |
|---|---|---|
show.register_page (prov) | Publish a page at a path; add live+channel for a push dashboard. | name, path, content_inline or content_url, live?, channel?, template? |
show.update_page / show.unregister_page / show.list_pages | Replace, remove, or list pages. An update takes new content — or template+data, re-rendering a registered template at the page’s own path (live pages without hard-coding an output path). | name (+ content_inline/content_url or template+data for update) |
show.set_index_menu | Set the hub index menu. | items: [ { label, href, icon?, group? } ] |
show.register_template / show.render_template | Store a template, then render it to a path with data. | register: name, template_source. render: template, data?, output_path |
show.upload_asset / show.delete_asset | Manage CSS/JS/image assets — served from /assets/<name>. The name must carry a real extension (it drives the served content type); binaries go in as content_b64. | name, mime_type, content_inline / content_b64 / content_url |
show.invalidate_cache / show.preload_cache | Cache control. | path?/all? · paths |
show.status | Health and counts. | — |
# Render a template with live data onto a dashboard path
- run: show.render_template
with:
template: orders-dashboard
output_path: /dashboard
data: { open_orders: ${prev.count} }
---
# Refresh a REGISTERED page from its REGISTERED template — no output_path coupling
- run: show.update_page
with:
name: ops-board
template: ops-board
data:
rows: ${steps.rows.rows}
Pages can carry real JavaScript. Page and template output serve
<script>markup byte-for-byte, and live data drops into script context safely with{{ data | tojson }}(plain{{ … }}stays HTML-escaped). Keep literal${…}out of inline JS — the playbook interpolator claims that syntax; plain string concatenation avoids the clash.
Two daemons deliberately expose no playbook verbs. The logger is a pure sink — it records every event from the bus (and, opted in, raises a Fact.Logs.ErrorRateExceeded event when one daemon starts erroring hard); you never call it. The playbook daemon is the orchestrator itself — you start work by emitting events (an HTTP hit, a schedule firing, a message arriving), not by calling the orchestrator. It guards its own gate: a playbook that parses but could never run — an unknown verb, a reference to a step that doesn’t exist, a missing secret — is rejected on arrival with a concrete error list (Fact.Playbook.Rejected), and the last good version keeps serving.
| Prefix | Daemon | Verbs |
|---|---|---|
database.* | database-service | 11 |
data.* | datatransporter-service | 11 |
modbus.* | modbus-service | 10 |
mail.* | mailbox-service | 8 |
analytics.* | dataanaliser-service | 7 |
scheduler.* | scheduler-service | 6 |
ai.* | aiinjector-service | 5 |
traefik.* | traefiklinker-service | 4 |
webhook.* | webhookcaller-service | 2 |
| Total | 64 |
(Plus showman’s 12 show.* presentation verbs, outside the counted 64.) The number is contract-tested: the build regenerates the machine-readable event contract from the same registry the engine uses, and a CI gate fails on any drift.
If a verb or argument isn’t listed here, it isn’t there. The platform’s
validatechecks a playbook’s shape but not whether a verb or argument name is real — a typo passes validation and then fails at run time. This page is the list to check against.
This is the full programmable surface of Binions. Each binion (daemon) is a root; under it sit the verbs it understands; under each verb every argument is expanded down to the last leaf, with its type, whether it is required, and its default; and finally the result — the Fact the verb returns — with every field it carries.
How to read it. required means you must supply it; optional values show their default. one of: lists the allowed values of a fixed set. choose one (by
kind/operation): is a tagged choice — you set that key to one value and supply only that branch’s fields. list of X is an array of X. object (key→value map) is a free-form map. A verb’s Returns are the fields you can read afterwards with${prev.…}or${steps.<id>.…}.
database — the database-service binionCreates tables and reads & writes structured records in a SQL or NoSQL database. (11 verbs.) Every action below also accepts connection: string (optional) — a named connection registered with database.register_connection, mutually exclusive with the inline backend/dsn/path trio.
database.register_connectionEmits Fact.Database.ConnectionRegistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
connection: <alias>) — lowercase letters, digits, _/-, up to 64 chars.postgres | sqlite | mysql | mssql | mongo (required) — Backend this connection speaks. Every backend is registrable — including postgres with a DSN (a second server next to the install default).${secret.…} — this is a provisioning verb.The connection is opened eagerly — a bad DSN fails the registration itself. The registry is in-memory (credentials never persist); provisioning playbooks re-run on every boot, healing it automatically.
Returns — Fact.Database.ConnectionRegistered
database.register_tableEmits Fact.Database.TableRegistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
ColumnSpec (required) — Column declarations. Order is preserved in DDL.TEXT | BIGINT | INT | DOUBLE | BOOLEAN | JSONB | TIMESTAMPTZ | UUID | NUMERIC (required) — SQL type (from [`SqlType::parse`]).true) — `NOT NULL` if `false`, else nullable.IndexSpec (optional, default [] (empty list)) — Optional index declarations.false) — `UNIQUE INDEX` if true.postgres (default) | sqlite | mysql | mssql | mongo (optional, default postgres) — target backend (default `postgres`).Returns — Fact.Database.TableRegistered
database.writeEmits Fact.Database.Inserted on success.
Arguments
postgres (default) | sqlite | mysql | mssql | mongo (optional, default postgres) — target backend (default `postgres`, backward compatible).Returns — Fact.Database.Inserted
database.queryEmits Fact.Database.QueryResult on success.
Arguments
[] (empty list)) — Columns to project. `[]` (or omitted) means `*`.{} (empty)) — Filter map (`{ "col.op": value, ... }`) — comparisons, text and set tests, null checks, time windows, or: groups, and qualified table.col.op keys for join filters (qualified keys need an explicit operator). String values bound against timestamp columns are cast automatically.100) — `LIMIT N`. Default 100, max 10_000.0) — Rows to skip before returning — the pagination page-start. SQL renders `LIMIT .. OFFSET ..`; MongoDB maps it to cursor skip.false) — `SELECT DISTINCT` — de-duplicate the projected rows. Not supported on the mongo backend.first | one (optional) — Single-row mode: the fact then carries a flat row object for ${prev.row.<col>}. Zero rows fails loudly; one also fails on more than one.postgres (default) | sqlite | mysql | mssql | mongo (optional, default postgres) — target backend (default `postgres`).Returns — Fact.Database.QueryResult
single: was requested (${prev.row.<col>}).database.updateEmits Fact.Database.Updated on success.
Arguments
postgres (default) | sqlite | mysql | mssql | mongo (optional, default postgres) — target backend.Returns — Fact.Database.Updated
database.upsertEmits Fact.Database.Upserted on success.
Arguments
postgres (default) | sqlite | mysql | mssql | mongo (optional, default postgres) — target backend.Returns — Fact.Database.Upserted
database.deleteEmits Fact.Database.Deleted on success.
Arguments
postgres (default) | sqlite | mysql | mssql | mongo (optional, default postgres) — target backend.Returns — Fact.Database.Deleted
database.transactionEmits Fact.Database.TransactionCompleted on success.
Arguments
kind): (required) — Ordered list of operations.kind: writekind: updatekind: deletepostgres (default) | sqlite | mysql | mssql | mongo (optional, default postgres) — target backend (default `postgres`). All ops run on it.Inside ops, a row/set/where value may reference an earlier write as "@tx:<op>.<column>" (0-based op index) — resolved against that write’s returned row before the transaction commits (PostgreSQL/SQLite; other backends reject it; escape a literal with "@@tx:"; forward and non-write references are rejected up front).
Returns — Fact.Database.TransactionCompleted
database.existsEmits Fact.Database.ExistsResult on success.
Arguments
{} (empty))postgres (default) | sqlite | mysql | mssql | mongo (optional, default postgres)Returns — Fact.Database.ExistsResult
database.countEmits Fact.Database.CountResult on success.
Arguments
{} (empty))postgres (default) | sqlite | mysql | mssql | mongo (optional, default postgres)Returns — Fact.Database.CountResult
database.aggregateEmits Fact.Database.Aggregated on success.
Arguments
[] (empty list)) — Columns to group by (empty = one summary row).AggregateSpec (required) — Aggregations to compute.sum / avg / min / max / count."*" with count).{} (empty)) — Filter map — same enriched grammar as query (operators, time windows, or: groups, automatic timestamp casts).{ revenue.gt: 1000 }).100) — Cap on returned groups.0) — Groups to skip — pagination for large group sets (MongoDB: cursor skip).Returns — Fact.Database.Aggregated
group_by columns plus each aggregate alias.mail — the mailbox-service binionSends e-mail and receives inbound mail and message-broker feeds — and works IMAP folders on demand. (8 verbs.)
mail.register_mailboxEmits Fact.Mailbox.Registered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
imap (default) | mqtt | amqp | redis | ws (alias wss) | sse | kafka (optional, default imap) — Which protocol the listener uses to consume messages. Defaults to IMAP for backward compatibility.ImapSettings (optional) — IMAP server settings — required when `protocol: imap` (the default). Even for MQTT/AMQP mailboxes, set this if you also want outbound SMTP.993) — Port — typically 993 for IMAPS.INBOX) — `Folder` to SELECT (default `INBOX`).MqttSettings (optional) — MQTT broker settings — required when `protocol: mqtt`.1883) — Port — typically 1883 (cleartext) or 8883 (TLS)."" (empty)) — MQTT client_id. Empty → auto-generated.30) — Keep-alive seconds (default 30).sensors/+device/+metric, alerts/#rest): a matching incoming topic puts the captured segments into Fact.Mail.Received.topic_params{} — filterable (topic_params.device.eq) without any parsing downstream. Validated at registration; non-matching topics simply omit the field.AmqpSettings (optional) — AMQP broker settings — required when `protocol: amqp`."" (empty)) — Optional explicit AMQP URI (`amqp://user:pass@host:port/vhost`). When empty, built from `host`/`port`/`username`/`password`/`vhost` below."" (empty)) — Host (only used if `uri` empty).5672) — Port (only used if `uri` empty) — 5672 / 5671.guest) — Username (default `guest`).guest) — Password (default `guest`)./) — Virtual host (default `/`).RedisSubSettings (optional) — Redis pub/sub settings — required when `protocol: redis` .WsFeedSettings (optional) — WebSocket feed settings — required when `protocol: ws`/`wss` .[String, String] pairs (optional, default [] (empty list)) — Extra handshake headers.SseFeedSettings (optional) — SSE feed settings — required when `protocol: sse` .[String, String] pairs (optional, default [] (empty list)) — Extra request headers.KafkaSettings (optional) — Kafka settings — required when `protocol: kafka` (feature-gated build).latest) — `auto.offset.reset` (default `latest`).[] (empty list)) — Topics / queues to subscribe to (MQTT/AMQP only). IMAP uses `imap.mailbox_folder` instead.SmtpSettings (optional) — SMTP server settings — outbound mail (`Action.Mail.Send`). Optional for MQTT/AMQP mailboxes; required if any playbook calls `mail.send` against this alias.465) — Port — typically 465 (TLS-implicit) or 587 (STARTTLS).true) — If true, the daemon spawns an inbound listener task at register time + on every boot.Returns — Fact.Mailbox.Registered
mail.unregister_mailboxEmits Fact.Mailbox.Unregistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
Returns — Fact.Mailbox.Unregistered
mail.sendEmits Fact.Mail.Sent on success. On an IMAP mailbox it sends e-mail over SMTP; on a broker-protocol mailbox, setting payload publishes a frame over that broker instead (MQTT / AMQP / Redis / WS; Kafka in kafka-enabled builds) and the e-mail fields are ignored.
Arguments
[] (empty list)) — Cc recipients.[] (empty list)) — Bcc recipients.body_text); rendered with vars — loops and conditionals live in the template, the playbook stays flat. An undefined variable fails the send loudly.body_html); rendered with vars, HTML-autoescaped.In-Reply-To header for threading; feed it ${trigger.envelope.message_id} for an in-thread auto-reply.References chain (space-joined into the header); defaults to [in_reply_to] when only that is set.Returns — Fact.Mail.Sent
smtp or the broker protocol (mqtt / redis / amqp / ws).mail.list_foldersEmits Fact.Mail.FoldersListed on success. IMAP mailboxes only; runs over an ad-hoc session, so the live listener is untouched. A failure emits a loud Fact.Mail.OperationFailed { op, reason }.
Arguments
Returns — Fact.Mail.FoldersListed
mail.fetchExplodes a folder on demand: each matching message is emitted as its own Fact.Mail.Received — the SAME shape the live listener produces (envelope, attachment policy and offload, dedup), so per-message playbooks serve the pull and the push path alike. The verb then closes with the summary Fact.Mail.Fetched (the saga response; the per-message facts precede it). The listener’s own progress markers are never touched.
Arguments
INBOX) — Folder to read.10) — Newest N messages to emit (max 100).false) — Only messages without the seen flag.YYYY-MM-DD).Returns — Fact.Mail.Fetched
Received facts of this fetch.mail.moveEmits Fact.Mail.Moved on success. Uses the server’s native move where available, with an automatic copy-flag-expunge fallback on older servers.
Arguments
Fact.Mail.Received.uid).INBOX) — Folder the message currently lives in.Returns — Fact.Mail.Moved
mail.markEmits Fact.Mail.Marked on success.
Arguments
INBOX) — Folder the message lives in.seen | answered | flagged | deleted | draft (required) — Flags to set (or clear).false) — Clear instead of set.Returns — Fact.Mail.Marked
mail.appendEmits Fact.Mail.Appended on success. Writes a message into a folder — it stores, it does not send. Use it for audit copies, drafts, or seeding a folder.
Arguments
[] (empty list)) — To: header addresses (informational — nothing is delivered).Returns — Fact.Mail.Appended
ai — the aiinjector-service binionCalls a large-language-model provider to generate text, classify, or extract fields — batched if you wish, with a queryable usage ledger. (5 verbs.) Providers are named instances from the daemon’s configuration; on inject/classify/extract (and batch) the arguments provider (one instance) / providers (an ordered fallback chain of up to 8, mutually exclusive with provider) and max_cost_usd (a pre-flight cost cap — fail-closed if the instance/model pair has no price) are available everywhere, and model is optional — it falls back to the instance’s default_model.
ai.injectEmits Fact.AI.Generated on success.
Arguments
default_model.1024) — Max output tokens. Default 1024.Returns — Fact.AI.Generated
ai.classifyEmits Fact.AI.Classified on success.
Arguments
bad_output).default_model fallback.Returns — Fact.AI.Classified
ai.extractEmits Fact.AI.Extracted on success.
Arguments
ExtractField (required) — Fields to extract.text / int / decimal / bool / date are enforced by coercion (European/US number formats normalise; dates accept RFC-3339 or YYYY-MM-DD); other strings act as prompt hints only.fail (default) | null | raw (optional) — What happens when a canonical-typed value cannot be coerced: fail loudly at the source, substitute null, or keep the raw string.default_model fallback.Returns — Fact.AI.Extracted
bad_output instead of leaking raw text.ai.batchRuns classify or extract over up to 200 texts in one action and emits ONE aggregate Fact.AI.BatchCompleted — the saga response. Items run sequentially; a single item’s failure lands in results[] without killing the batch, and the cost cap is running: once the accumulated actual cost reaches it, the remaining items are skipped.
Arguments
classify | extract (required) — Sub-operation applied to every item.op: classify.ExtractField (optional) — Required for op: extract.ai.extract (optional).Returns — Fact.AI.BatchCompleted
ai.usage_reportReads the usage ledger the daemon keeps for every successful call (and for failures that still consumed tokens): calls, input/output tokens and USD per provider × model, on three axes — all-time, per day, and per correlation (kept 30 days). Prices come from the [providers.pricing] table (an "<instance>/<model>" key beats a bare model key) layered over built-in defaults for Claude-family models; unpriced models meter at 0.0.
Arguments
provider | model (default) | correlation (optional) — Aggregation axis.group_by: correlation: the run to report on. Absent = the CURRENT playbook run — “what did this process cost so far”.YYYY-MM-DD start of a daily-bucket range (only with provider/model; absent = all-time totals).since’s day.Returns — Fact.AI.UsageReported
calls, input_tokens, output_tokens, cost_usd.${prev.total.cost_usd}).webhook — the webhookcaller-service binionCalls outbound HTTP, gRPC or SSH endpoints — with retries, optional signing and an SSRF guard. (2 verbs.)
webhook.register_endpointEmits Fact.Webhook.EndpointRegistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
"" (empty)) — Absolute URL — SSRF guard still applies at send-time. Optional on RE-registration (alias-only oauth transients merge it from previous state); required the first time.POST) — Default HTTP method (POST when omitted).[String, String] pairs (optional, default [] (empty list)) — Default request headers (vec of `[name, value]`).OAuthBlock (optional) — OAuth2 block (settings + daemon-owned tokens/pending).[] (empty list)) — Requested scopes (space-joined into the authorize URL).[String, String] pairs (optional, default [] (empty list)) — Extra `key=value` pairs appended to the authorize URL (e.g. Google's `access_type=offline`, `prompt=consent`).OAuthTokens (optional) — Issued tokens (absent until the first successful exchange).Bearer) — Token type as reported (always sent as `Bearer`).OAuthPending (optional) — Pending authorization round (absent once exchanged).false) — TRANSIENT — force a token refresh now (scheduler canonical).Returns — Fact.Webhook.EndpointRegistered
webhook.sendEmits Fact.Webhook.Delivered on success.
Arguments
"" (empty)) — Absolute URL (scheme http or https; SSRF-checked before sending). Optional when `endpoint` is supplied; the endpoint registry resolves it. Explicit `url` always wins over the registered default.POST) — HTTP method (default POST).[String, String] pairs (optional, default [] (empty list)) — Optional request headers (vec of `[name, value]`).body and form/form_files; default content type application/octet-stream (override via headers). HMAC sign covers the DECODED bytes.multipart/form-data text fields (scalar values). Mutually exclusive with body/body_b64.form as ONE multipart request. Signing a multipart body is refused (hmac_misconfig) — the boundary is random.false) — Expect a JSON response body: parse it into Delivered.response_json (API-as-data-source). A non-JSON reply becomes Failed{reason: bad_json}; a body over the daemon’s response cap (256 KiB default) becomes Failed{reason: response_too_large}. gRPC replies fill the same field.url/endpoint; each alias runs the full single-send pipeline and emits its own Delivered/Failed stamped with group_id + endpoint, followed by one summary Fact.Webhook.GroupCompleted{total, ok, failed, results[]} — a saga step should set expect: Webhook.GroupCompleted. HTTP only (grpc/ssh multicast is rejected).parallel (default) | sequential (optional) — Group delivery order; sequential still delivers to EVERY endpoint (no stop-on-failure).HmacConfig (optional) — Optional HMAC signing instruction.hmac-sha256 | hmac-sha512 (required) — Which HMAC algorithm to apply.GrpcConfig (optional) — gRPC call descriptor — required iff `protocol == "grpc"`.30000) — Per-call deadline in milliseconds (default 30 000).SshAction (optional) — SSH call descriptor — required iff `protocol == "ssh"`. Carries ONLY a `command_ref` (a name in the allow-list); never a shell string.Returns — Fact.Webhook.Delivered
expect_json: true (${prev.response_json.<field>}).On a multicast send the group closes with Fact.Webhook.GroupCompleted — total / ok / failed: integers, and results: a per-endpoint outcome list; that summary is the fact a saga step should wait for (expect: Webhook.GroupCompleted).
scheduler — the scheduler-service binionFires events on a cron or fixed-interval schedule. (6 verbs.)
scheduler.register_scheduleEmits Fact.Schedule.Registered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks) Registration upserts by name. Exhausted schedules — a fired one-shot, a finished calendar — deregister themselves, emitting Fact.Schedule.Deregistered.
Arguments
@every <N>[s|m|h|d]. Exactly one of `cron_expr` / `interval_seconds` / `at` / `dates`.cron_expr: "@every <N>s"; a plain interval is not expressible in cron (*/45 fires at :00/:45, not every 45 s). Anchored so the cadence never drifts; firing is quantised to the engine tick (default 30 s). Exactly one of `cron_expr` / `interval_seconds` / `at` / `dates`.2026-07-05T10:00:00+02:00) or naive YYYY-MM-DDTHH:MM[:SS] resolved in timezone (default UTC) — the data-driven shape (at: ${prev.row.termin}). Fires once, then the schedule deregisters itself. Exactly one of the four expressions.at). Each fires once; overdue instants catch up one per tick; after the last one the schedule deregisters itself.Europe/Warsaw): the cron grid keeps LOCAL wall-clock hours through both daylight-saving transitions, and naive at/dates timestamps resolve in it. A timestamp in the spring-forward gap fails loudly at registration; ambiguous instants take the first occurrence.fire_id/scheduled_at stay on the grid (dedupe unchanged); effective granularity is the engine tick.Returns — Fact.Schedule.Registered
@every form, round-tripped unchanged).scheduler.deregister_scheduleEmits Fact.Schedule.Deregistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
Returns — Fact.Schedule.Deregistered
scheduler.pause_scheduleEmits Fact.Schedule.Paused on success.
Arguments
Returns — Fact.Schedule.Paused
scheduler.resume_scheduleEmits Fact.Schedule.Resumed on success.
Arguments
Returns — Fact.Schedule.Resumed
scheduler.trigger_nowEmits Fact.Schedule.Fired on success.
Arguments
Returns — Fact.Schedule.Fired
scheduler.list_schedulesEmits Fact.Schedule.Listed on success.
Arguments
Returns — Fact.Schedule.Listed
ScheduleListItem (required) — Schedule rows.Payload (read with ${trigger.…})
data — the datatransporter-service binionMoves files in and out of object / file storage, copies and moves them between backends, parses documents, mints share links, converts between formats, and manages the bucket registry. (11 verbs.)
data.uploadEmits Fact.Data.Transported on success.
Arguments
Returns — Fact.Data.Transported
data.downloadEmits Fact.Data.Downloaded on success.
Arguments
false) — If true, emit `body_b64` instead of `body`. Defaults to false (text body).Returns — Fact.Data.Downloaded
data.list_objectsEmits Fact.Data.Listed on success.
Arguments
name | mtime | size (optional) — Sort the listing; objects without a timestamp always sort last.asc | desc (optional) — Sort direction.Returns — Fact.Data.Listed
ListedObject (required) — Returned objects.${prev.first.key} is “the newest file” when you sorted by mtime desc.data.delete_objectEmits Fact.Data.Deleted on success.
Arguments
Returns — Fact.Data.Deleted
data.transformEmits Fact.Data.Transformed on success.
Arguments
Returns — Fact.Data.Transformed
data.register_bucketEmits Fact.Data.BucketRegistered on success (Fact.Data.BucketRegistrationFailed on a bad config). (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
data.* steps use as bucket:.s3 (default) | minio | sftp | ftp | local_fs | mongo | http_ingest (optional) — Which backend to bind. mongo stores files in MongoDB GridFS (re-putting a key replaces its revisions; connects and pings eagerly at registration). http_ingest is a WRITE-ONLY sink — each put POSTs the object as multipart/form-data (a file part named by the key, plus key and sha256 fields) to endpoint, with an optional bearer token from credentials.secret_key; get/list/delete refuse loudly. Neither supports watch_dir.http_ingest.mongo backend; use ${secret.…}).binions); the bucket_id doubles as the GridFS bucket name.us-east-1) — S3 region label.${secret.KEY} references, never literals.Fact.Data.FileDiscovered.true) — Path-style addressing (MinIO default).Returns — Fact.Data.BucketRegistered
data.unregister_bucketEmits Fact.Data.BucketUnregistered on success. (teardown verb)
Arguments
Returns — Fact.Data.BucketUnregistered
data.move / data.copyEmit Fact.Data.Moved / Fact.Data.Copied on success. The universal get → put (→ delete) through the bucket registry — ANY registered backend pair composes: watched folder to processed/, offloaded mail attachment to an archive, markdown to a RAG sink. A move whose source delete fails after a successful copy is a loud Fact.Data.OperationFailed — never a silent duplicate.
Arguments
from_key (provenance paths carry over).Returns — Fact.Data.Moved / Fact.Data.Copied
data.presignEmits Fact.Data.Presigned on success. Mints a time-limited download URL for an object — share a link, not an attachment. S3/MinIO backends only; other backends fail loudly with Data.OperationFailed{reason: unsupported} (they have no signing scheme).
Arguments
3600) — Link TTL in seconds — max 604800 (7 days).Returns — Fact.Data.Presigned
data.parseEmits Fact.Data.Parsed on success. Lifts the text out of a stored document — the missing middle link of “an invoice arrives as a PDF attachment”: attachment offload → data.parse → ai.extract → database.write, entirely inside the platform. PDF parsing reads the embedded text layer — there is NO OCR: a scanned PDF fails loudly with no_text_layer (route scans through an external OCR service via webhook.send + expect_json). Malformed input becomes a parse_error failure fact, never a stuck queue entry.
Arguments
key / content_b64 is required.bucket+key.auto (default) | pdf | docx | text (optional) — Format override; auto detects by key extension, then magic bytes, falling back to plain text.truncated: true.Returns — Fact.Data.Parsed
pdf / docx / text).${prev.text} — feed it to ai.extract).analytics — the dataanaliser-service binionComputes statistics, anomalies, rankings and forecasts over rows of data — and filters, reshapes and fans out record sets in flight. (7 verbs.)
analytics.calculate_statsEmits Fact.Analytics.StatsComputed on success.
Arguments
StatOpSpec (required) — Ops to compute (in order). At least one must be specified.[] (empty list)) — Group records by these fields (scalar values) and compute the same ops PER GROUP into the fact’s groups[], in first-appearance order. The whole-set results stay as they are.Returns — Fact.Analytics.StatsComputed
StatResult (required) — Computed `(op, value)` pairs in the request's order.${prev.values.avg}; a dot in a slug goes underscore (p99.9 → p99_9).group_by: one entry per group, each carrying its key{} (the grouping field values) and its own values{} map, in first-appearance order.analytics.detect_anomalyEmits Fact.Analytics.AnomaliesDetected on success.
Arguments
Returns — Fact.Analytics.AnomaliesDetected
Anomaly (required) — Detected outliers.anomalies[0].record.customer_email).analytics.rankEmits Fact.Analytics.Ranked on success.
Arguments
RankCriterion (required) — Ordered criteria (first criterion is primary key).asc | desc (optional, default desc) — Sort direction (defaults to `desc` — typical "top-N" semantics).Returns — Fact.Analytics.Ranked
RankCriterion (required) — Criteria actually applied — same order as request.asc | desc (optional, default desc) — Sort direction (defaults to `desc` — typical "top-N" semantics).analytics.forecastEmits Fact.Analytics.Forecasted on success.
Arguments
Returns — Fact.Analytics.Forecasted
ForecastResult (required) — Forecast results.ForecastPoint (required) — Forecasted points.${prev.next}).analytics.filterEmits Fact.Analytics.Filtered on success. Narrows an inline record set with THE SAME grammar as trigger filters (one shared evaluator — the two can never drift): field.op keys, bare key = equals, dot-paths into nested fields. An empty result is a normal outcome; a malformed filter emits Fact.Analytics.OperationFailed{reason: bad_filter}.
Arguments
data: "${steps.q.rows}" or ${trigger.envelope.attachments}.{ "status.eq": "paid", "customer.email.endswith": "@x.com" }.Returns — Fact.Analytics.Filtered
analytics.deriveEmits Fact.Analytics.Derived on success. A full data processor over inline records: an ordered pipeline of NAMED operations — numeric (add sub mul div round abs coalesce), conversions (to_number to_string to_bool; European/US separators normalise), string (concat upper lower trim replace substring split pad length regex_extract regex_match), object (get set rename pick omit merge), array (unique sort slice flatten count_by join). Operands: a bare string is a dot-path (a numeric segment indexes an array), numbers/booleans are literals, {lit: X}/{field: p} make it explicit. There is NO expression language — a typo’d fn or argument fails at validation. A per-record miss yields null (pair with coalesce); action-level errors (bad regex, wrong arity, over the caps of 64 ops / 10 000 records) emit Fact.Analytics.OperationFailed. Regex operations run on a linear-time engine — a pathological pattern cannot freeze the daemon.
Arguments
data: "${steps.q.rows}".DeriveOp (required) — The pipeline, executed in order (1..=64); each op names its fn, usually an as: output field and of: operand(s).Returns — Fact.Analytics.Derived
${prev.row.digest} after a join).analytics.emit_itemsExplodes an inline array into one Fact.Analytics.ItemEmitted PER element — the per-item iteration pattern: a second playbook triggers on ItemEmitted with dot-path filters into item (item.status.eq: overdue), and every element gets its own run, retries and audit trail. The verb’s own saga response is the summary Fact.Analytics.ItemsEmitted. Hard cap 1000 items — over the cap the step fails loudly, it never truncates.
Arguments
Returns — Fact.Analytics.ItemsEmitted (summary)
ItemEmitted of this explosion.Each per-item Fact.Analytics.ItemEmitted (a trigger event) carries: item (the element), index (0-based), total, batch_id.
traefik — the traefiklinker-service binionPublishes and removes reverse-proxy routes on the HTTP edge. (4 verbs.)
traefik.register_routeEmits Fact.Traefik.RouteRegistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
RouteSpec (required) — Route to materialise as YAML in the file-provider dir.[] (empty list)) — Entry points the router attaches to; defaults to `["web", "websecure"]`.TlsRouteSpec (optional) — Optional TLS section. Set `Some(TlsRouteSpec { cert_resolver: Some("letsencrypt") })` to drive ACME issuance.http | https (default) | ws | wss | sse | grpc | grpc-web (optional, default https) — Protocol variant — drives middleware / serversTransport / backend scheme emit. Defaults to `https` for backward compatibility when the field is omitted./in — only requests under it match this route).path_prefix before forwarding, so the backend sees the bare path (applied after the operator middleware chain).Middleware (optional) — Declarative operator middlewares, chained BEFORE the protocol’s own. Each is a flat object with a type discriminator and only that type’s fields — a foreign or typo’d field fails loudly.type: basic_auth — users: list of htpasswd-format entries (hash with htpasswd; secrets via ${secret.…} in provisioning).type: forward_auth — address (auth service URL), auth_response_headers? (headers copied onto the forwarded request), trust_forward_header?.type: ip_allowlist — source_ranges: bare IPs or CIDRs.type: rate_limit — average (requests per period), burst? (defaults to average), period_secs? (default 1).type: headers — request? / response?: custom header maps.Backend (optional) — Weighted load-balancing over several instances; each entry: url (http/https) and weight (default 1). Traffic splits by weight.Returns — Fact.Traefik.RouteRegistered
http | https (default) | ws | wss | sse | grpc | grpc-web (required) — Protocol the route serves (kebab-case string in the JSON payload). Subscribers can branch on this to attach protocol-specific listeners / observability.traefik.unregister_routeEmits Fact.Traefik.RouteUnregistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
Returns — Fact.Traefik.RouteUnregistered
traefik.list_routesEmits Fact.Traefik.Routes on success.
Arguments
No arguments.
Returns — Fact.Traefik.Routes
LiveRouterSummary (required) — Live routers from Traefik admin API (any provider).traefik.reloadEmits Fact.Traefik.Reconfigured on success.
Arguments
No arguments.
Returns — Fact.Traefik.Reconfigured
modbus — the modbus-service binionTalks to industrial PLCs over Modbus TCP — and serves its own register map to external masters. (10 verbs.)
modbus.register_plcEmits Fact.Modbus.PlcRegistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
kind): (required) — Transport.kind: tcp — Native MODBUS TCP (MBAP header, port 502 by spec).502) — TCP port. Defaults to 502 per MODBUS Messaging on TCP/IP § 4.1.255) — Unit/slave identifier (1..247 single device, 0 broadcast, 255 direct).1000) — Per-request timeout. Defaults to 1000ms.kind: rtu_over_tcp — RTU framing tunneled through a raw TCP socket (Moxa MGate / Lantronix / Advantech ADAM gateways). 2000) — Per-request timeout.1000) — Background polling cadence.[] (empty list)) — Polled subscriptions — each entry produces 1 FC call per cycle. An entry is a plain string spec ("holding/100:2") or an object { spec, decode?, deadband?, thresholds? }:DecodeSpec (optional) — type (u16 i16 u32 i32 u64 i64 f32 f64), word_order (big/little — register order; bytes are always big-endian; a vendor profile may fill it), scale, offset. The poller then emits per type-width chunk with the PHYSICAL value (raw × scale + offset); change detection compares raw words, never floats; 64-bit integers without scaling keep exact precision.on_change emission (analog-noise suppression; heartbeats bypass it) — and the hysteresis band of the thresholds below.{ op: gt|ge|lt|le, value, label }. A per-(address, label) hysteresis machine emits Fact.Modbus.ThresholdCrossed on EDGES only — enter when the condition becomes true, exit once the value recedes past the threshold by at least deadband. Exactly one fact per episode edge; the first true sample after a restart counts as enter.siemens: a decode with no explicit word_order resolves to little — the classic S7 trap); explicit fields always win. The registration fact echoes the profile.RegisterRange (optional, default [] (empty list)) — Whitelist for writes. **Empty = deny ALL writes** (deny-by-default per spec § Security threat model L2).on_change (default) | every_poll (optional, default on_change) — Polling emit mode.60) — Heartbeat cadence when `emit_mode = on_change` (≥1).100) — Per-target writes/sec cap. Default 100/s soft cap.Returns — Fact.Modbus.PlcRegistered
modbus.unregister_plcEmits Fact.Modbus.PlcDeregistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
Returns — Fact.Modbus.PlcDeregistered
modbus.listEmits Fact.Modbus.PlcList on success.
Arguments
No arguments.
Returns — Fact.Modbus.PlcList
PlcStatus (required) — Snapshot of every registered PLC at call time.modbus.readEmits Fact.Modbus.ReadSucceeded on success.
Arguments
coil | discrete_input | input | holding (required) — Register type.DecodeSpec (optional) — Engineering decode of the read words (word spaces only; quantity must be a multiple of the type width).Returns — Fact.Modbus.ReadSucceeded
coil | discrete_input | input | holding (required) — Register type.type): (required) — Returned values.type: bits → value: list of booleantype: words → value: list of integerquantity == 1 (${prev.value}); with a decode, the single decoded chunk is preferred.decode was given.modbus.writeEmits Fact.Modbus.WriteSucceeded on success.
Arguments
false) — Read the written range back after a successful write and fail LOUDLY (Fact.Modbus.Failed, “verify mismatch”) when the device reports different values. Supported for the single/multiple coil and register writes; mask-write / read-write ops must be read back explicitly.operation):operation: write_single_coil — FC 0x05.operation: write_multiple_coils — FC 0x0F (1..1968 coils per call).operation: write_single_register — FC 0x06.operation: write_multiple_registers — FC 0x10 (1..123 registers per call).operation: mask_write_register — FC 0x16 — atomic bit manipulation `new = (cur AND and_mask) OR (or_mask AND NOT and_mask)`.operation: read_write_multiple_registers — FC 0x17 — atomic read-then-write same packet.Returns — Fact.Modbus.WriteSucceeded
true when verify: true read the values back successfully.modbus.set_server_registersEmits Fact.Modbus.ServerRegistersSet on success. Publishes live values into the daemon’s own served register map ([modbus_server] in its configuration) — the platform computes, SCADA reads. The map is in-memory, so the publishing playbook re-runs on every boot (the registry self-heal pattern). With the server disabled the verb fails loudly.
Arguments
{ address, value } (optional) — Word values served to register reads.{ address, value } (optional) — Bit values served to coil/discrete reads.Returns — Fact.Modbus.ServerRegistersSet — a summary of the updated spaces.
Payload (read with ${trigger.…}): alias, register_type, address, new_value (the PHYSICAL value when the subscription decodes — raw × scale + offset), old_value (previous engineering value), raw (the raw words behind a decoded chunk), polled_at. Emitted by polled subscriptions per emit_mode; a deadband suppresses noise-level changes.
Payload: alias, register_type, address, label (the threshold’s name), op, threshold, value, direction (enter | exit), polled_at. Exactly one fact per alarm-episode edge (hysteresis via the subscription deadband).
Payload: space (coil/holding), address, values (list), value (flat, when a single point was written), quantity, peer (the writing master’s address), written_at. Emitted when the built-in server is writable and an external master writes coils or holding registers — the inbound-industrial trigger. Input/discrete spaces are never writable (protocol rule).
modbus.read_device_idEmits Fact.Modbus.DeviceIdRead on success.
Arguments
0x01) — Read Device ID code: 0x01 basic, 0x02 regular, 0x03 extended, 0x04 specific. Default: 0x01 (mandatory objects only).0) — First object ID to read (0x00 for VendorName when read_code = 0x01).Returns — Fact.Modbus.DeviceIdRead
modbus.read_file_recordEmits Fact.Modbus.FileRecordRead on success.
Arguments
Returns — Fact.Modbus.FileRecordRead
modbus.write_file_recordEmits Fact.Modbus.FileRecordWritten on success.
Arguments
Returns — Fact.Modbus.FileRecordWritten
modbus.read_fifoEmits Fact.Modbus.FifoRead on success.
Arguments
Returns — Fact.Modbus.FifoRead
show — the showman-service binionServes live web pages, templates and assets, plus an HTTP / WebSocket edge. (12 verbs.)
show.register_pageEmits Fact.Showman.PageRegistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
Returns — Fact.Showman.PageRegistered
show.unregister_pageEmits Fact.Showman.PageUnregistered on success.
Arguments
Returns — Fact.Showman.PageUnregistered
show.update_pageEmits Fact.Showman.PageUpdated on success. Three exclusive content sources: inline HTML, a fetch URL, or template+data — the last re-renders a REGISTERED template at the page’s REGISTERED path (and refreshes the hot cache), so a live page needs no separate output path.
Arguments
data; exclusive with the content sources).{{ rows }}).Returns — Fact.Showman.PageUpdated
show.list_pagesEmits Fact.Showman.PagesListed on success.
Arguments
No arguments.
Returns — Fact.Showman.PagesListed
PageSummary (required)show.set_index_menuEmits Fact.Showman.IndexMenuSet on success.
Arguments
MenuItem (required)Returns — Fact.Showman.IndexMenuSet
show.register_templateEmits Fact.Showman.TemplateRegistered on success. (provisioning verb — registers a named resource; runs in provisioning playbooks)
Arguments
Returns — Fact.Showman.TemplateRegistered
show.render_templateEmits Fact.Showman.TemplateRendered on success.
Arguments
Returns — Fact.Showman.TemplateRendered
show.statusEmits Fact.Showman.StatusReported on success.
Arguments
No arguments.
Returns — Fact.Showman.StatusReported
LastError (optional)show.invalidate_cacheEmits Fact.Showman.CacheInvalidated on success.
Arguments
false)Returns — Fact.Showman.CacheInvalidated
show.preload_cacheEmits Fact.Showman.CachePreloaded on success.
Arguments
Returns — Fact.Showman.CachePreloaded
show.upload_assetEmits Fact.Showman.AssetUploaded on success.
Arguments
/assets/<name>. MUST carry a real extension — the served content type derives from it.The asset MATERIALIZES as a real file in the served directory; delete_asset removes it.
Returns — Fact.Showman.AssetUploaded
show.delete_assetEmits Fact.Showman.AssetDeleted on success.
Arguments
Returns — Fact.Showman.AssetDeleted
Payload (read with ${trigger.…})
Payload (read with ${trigger.…})
mail — events the mailbox binion emitsOne inbound message or feed frame. The payload shape depends on the source, read with ${trigger.…}:
mail.fetch read).attachments[0], refreshed after the attachment policy ran — ${trigger.first_attachment.filename} without list indexing.EmailMessage (required) — the parsed message.Address (required) — `From` header — first address only (mail-parser may surface a list).Address (optional, default [] (empty list)) — `To` recipients.Address (optional, default [] (empty list)) — `Cc` recipients.Address (optional, default [] (empty list)) — `Bcc` recipients (rare in inbound, recorded if present).Address (optional, default [] (empty list)) — `Reply-To` if distinct from `From`.[] (empty list)) — `In-Reply-To` header — for threading.[] (empty list)) — `References` header — for threading.AttachmentMeta (optional, default [] (empty list)) — Per-attachment metadata.{ kind: "imap", mailbox, uid }.{ kind, … } describing the broker.{/[; up to 256 KiB) — ${trigger.body_json.temp} with no extraction step. Non-JSON frames simply omit it.topic_pattern (topic_params.device, …); absent when the topic doesn’t match.false on broker feeds.Three binions are part of every set but are not called with run: — they work in the background or from the command line.
Subscribes to every event on the bus and writes it to an append-only JSONL log. It has no verbs and no arguments — it is a pure sink for observability.
The operator command line (binions-cliconsole). Not a playbook verb; its sub-commands are:
Action.* / Fact.* / Log.* event by hand.Control.* event (e.g. reload playbooks).--follow keeps the stream open and prints facts live as they arrive.--dry-run adds the static feasibility checks the engine applies on entry — unknown verbs, references to undeclared steps, loop variables outside their loop — offline.--redis-url still wins when given).The orchestrator that runs your playbooks. It has no run: verbs of its own; it emits the lifecycle events you can trigger on or trace by:
{ component: "playbook-service" }. The provisioning trigger — pair with filter: { component.eq: playbook-service }.{ trigger } (the playbook name is in the event envelope).{ playbook, steps, status }.{ playbook, steps, status } (trace by correlation_id).{ name, file, errors[], source, kept } — kept says the previous good version is still serving. Wire an admin alert to it.