A playbook moves data from one step to the next in exactly one way — ${...} references. When the e-mail that triggered a run carries an invoice total, when one step queries rows the next step must summarise, when a loop counts from one to twelve — a ${...} reference is how that value reaches the place it is needed. This page is the complete reference: every kind of reference, how it is built, and how to write your own, each with worked examples.
The key idea. Binions has no variables to declare, no expression language, no functions. There are exactly five kinds of reference, and together they are the whole data-flow vocabulary. Learn these five and you can wire any playbook.
Every ${...} starts with one of five names. The name decides where the value is read from; the dotted path after it drills into that value.
${trigger.…} | A field of the event that started the playbook — the e-mail, the schedule firing, the HTTP request. |
${prev.…} | The result of the step just before this one. |
${steps.<id>.…} | The result of any earlier step you gave an id: — reach back past the previous one. |
${loop.<name>} | The loop counter, available only inside a loop: body. |
${secret.<KEY>} | A credential read from the secret store — in provisioning playbooks only. |
These five are the entire list. There is no
${env.…}, no${vars.…}, and nothing like${a + b}or${now()}. Anything inside the braces that does not start with one of the five names is rejected. If a workflow seems to need arithmetic or a conditional, the answer is a richer verb, not a bigger expression — see Anatomy of a playbook.
Before the references themselves, one rule governs all of them — and it is the one that surprises people. How a ${...} is substituted depends on whether it is the whole value or sits inside a longer string.
${...} and nothing else, the result keeps its original JSON type. A number stays a number, a list stays a list, an object stays an object. This is what lets you hand a numeric total straight to a numeric column, or pass an entire list of rows to the next step.${...} is surrounded by other characters (or more than one appears in the same string), every reference is converted to text and spliced in. This is how you build keys, paths, subjects, and messages.# Whole value — the number stays a number, the list stays a list
row:
total: ${prev.result.amount_gross} # → 1240.50 (a number, not "1240.50")
data: ${prev.rows} # → [ {…}, {…} ] (the whole list)
# Inside text — each reference is turned into text and joined
key: "invoices/${trigger.from}/${prev.id}.pdf" # → "invoices/acct@acme.com/42.pdf"
subject: "Order ${prev.id} confirmed" # → "Order 42 confirmed"
Why it matters. If you wrote
total: "${prev.result.amount_gross} PLN"you would get the string"1240.50 PLN"— fine for a label, wrong for a numeric column. Keep a reference on its own when you need the value’s real type; wrap it in text only when you mean to build a string.
${trigger.…} — data from the triggering eventWhat it is. Every run starts because one event fired. ${trigger} is that event’s payload, available read-only for the whole run. How it is built: the platform hands the triggering event’s body to the run as trigger; you address its fields with a dotted path. How to write one: look up which fields the event carries (table below), then write ${trigger.<field>} — nesting as deep as the data goes, e.g. ${trigger.envelope.from.email}.
The fields you can address depend on which event triggered the run:
| Trigger event | Fields you can reference |
|---|---|
Fact.Mail.Received (e-mail / IMAP) | via, from, subject, has_attachments, message_id, source.kind, and the full message under envelope — envelope.body_text, envelope.body_html, envelope.attachments, envelope.date. |
Fact.Mail.Received (MQTT / AMQP / Redis / WS / SSE / Kafka) | via, body_text, received_at, content_type, source.kind, message_id — a feed frame has no from/subject/envelope. |
Fact.Schedule.Fired | name, fired_at, scheduled_at, fire_count, and whatever you attached when registering the schedule under payload — payload.<field>. |
Fact.Http.Received | route, method, query, headers, body (parsed JSON when possible), correlation_id, client_ip. |
Fact.Showman.WsMessage | channel, payload, client_id. |
Fact.System.Boot | component (always playbook-service) — the provisioning trigger. |
Example 1 — pull the body out of an incoming e-mail. An invoice arrives; the extraction step reads the plain-text body straight from the message envelope.
trigger:
event: Fact.Mail.Received
filter:
via.eq: faktury
has_attachments: true
steps:
- id: extract
run: ai.extract
with:
model: gpt-4o-mini
text: ${trigger.envelope.body_text} # the e-mail body
fields:
- { name: total, type: decimal }
- { name: supplier, type: text }
The whole-value rule applies: because text: is exactly one reference, the body is passed as a string, unchanged.
Example 2 — carry the sender and subject into a stored row. The same trigger also exposes the sender address and subject line as top-level fields, ready to record alongside the extracted data.
- id: save
run: database.write
with:
table: invoices
row:
supplier_email: ${trigger.from} # e.g. acct@acme.com
subject: ${trigger.subject}
total: ${prev.result.total} # from the extract step (see ${prev} below)
Example 3 — reply to an HTTP request that triggered the run. A form posts to /in/contact; you read a field from the JSON body, and reply to the waiting caller using the request’s correlation id woven into the reply path.
trigger:
event: Fact.Http.Received
filter:
route.eq: contact
steps:
- run: database.write
with:
table: leads
row: { email: ${trigger.body.email}, message: ${trigger.body.message} }
- run: webhook.send
with:
url: "http://127.0.0.1:9099/in/_reply/${trigger.correlation_id}" # built inside a string
body: { ok: true }
${prev.…} — the previous step’s resultWhat it is. In the default (saga) mode, each step waits for its result before the next one runs, and that result is exposed to the next step as ${prev}. How it is built: when a step completes, the fact it produced becomes ${prev} for exactly the step that follows. How to write one: write ${prev.<field>}, where the field depends on what the previous verb returns. The most useful outputs:
| Previous step | Reference |
|---|---|
database.query | ${prev.rows} (the list of rows), ${prev.count} — and with single: first|one, the flat ${prev.row.<col>} |
database.write | ${prev.id} (the new row’s id) |
database.count / database.exists | ${prev.count} / ${prev.exists} |
ai.inject | ${prev.text} (the generated text) |
ai.classify | ${prev.category} (the chosen category) |
ai.extract | ${prev.result.<field>} (one key per field you asked for) |
ai.batch / ai.usage_report | ${prev.results}, ${prev.ok}, ${prev.failed}, ${prev.cost_usd} / ${prev.rows}, ${prev.total.cost_usd} |
modbus.read | ${prev.values}; the flat ${prev.value} when quantity is 1; ${prev.decoded} with a decode |
webhook.send | ${prev.status}; with expect_json: true the parsed ${prev.response_json.<field>} (+ ${prev.response_headers.<name>}); ${prev.response_body_truncated} otherwise |
analytics.calculate_stats | ${prev.results} — and the flat map ${prev.values.avg} (a slug per op; p99.9 → p99_9) |
analytics.filter / derive / rank / detect_anomaly / forecast | ${prev.rows} / ${prev.rows} (+ flat ${prev.row.<field>} when one record remains) / ${prev.ranked} / ${prev.anomalies} / ${prev.forecast} + flat ${prev.next}, ${prev.last} |
data.list_objects | ${prev.objects} — and, sorted, the flat first hit ${prev.first.key} |
data.parse / data.presign | ${prev.text} (feed it to ai.extract) / ${prev.url}, ${prev.expires_at} |
mail.fetch | summary ${prev.count}, ${prev.batch_id} (each fetched message rides its own Fact.Mail.Received) |
Flat single values, by design. Wherever a step’s natural answer is one thing — one row (
single:), the newest file (first), one statistic (values.avg), one sensor point (value) — the fact carries it flat, so the next step reads it in one hop. There is no list indexing in references (rows[0]is not a thing); ask the verb for the single-value shape instead. And onlyai.extractnests its answer underresult— every other verb’s fields are read directly off the fact.
Example 1 — feed queried rows into a statistic. A query returns rows; the next step computes an average over them. Because data: is a lone reference, the whole list is passed through with its type intact.
- id: recent
run: database.query
with: { table: readings, order_by: observed_at, order_dir: DESC, limit: 200 }
- run: analytics.calculate_stats
with:
data: ${prev.rows} # the list of rows from the query above
field: value
ops: [ { name: avg }, { name: max } ]
Example 2 — put a generated summary into an e-mail. A language-model step writes a summary; the send step uses it as the body.
- run: ai.inject
with: { model: gpt-4o-mini, prompt: "Summarise this week's orders in three sentences." }
- run: mail.send
with:
from_alias: reports
to: [ finance@example.com ]
subject: "Weekly summary"
body_text: ${prev.text} # the generated text
Example 3 — use an API’s answer as data. With expect_json: true the reply is parsed for you — the next step reads its fields directly. (Without it, only ${prev.response_body_truncated} — a trimmed string for logging — is available.)
- run: webhook.send
with:
endpoint: fx-api
method: GET
expect_json: true
- run: database.write
with:
table: fx_rates
row:
eur: ${prev.response_json.rates.EUR} # a number, straight from the reply
status: ${prev.status} # e.g. 200
${prev}only reaches back one step. It is replaced after every step, so it always means “the step immediately before”. To use the result of a step further back, give that step anid:and use${steps.<id>}instead.
${steps.<id>.…} — any earlier step’s resultWhat it is. A named handle to any earlier step’s result, not just the previous one. How it is built: give a step id: my-name; its result is then stored under that id for the rest of the run. How to write one: ${steps.my-name.<field>} — the fields are exactly the same ones ${prev} would expose for that verb. Step ids must be unique within a playbook.
Example 1 — combine an extracted field with the trigger. The write step uses both a value the extract step produced and a value from the original e-mail — two different sources in one row.
- id: extract
run: ai.extract
with:
model: gpt-4o-mini
text: ${trigger.envelope.body_text}
fields: [ { name: total, type: decimal }, { name: due_date, type: text } ]
- run: database.write
with:
table: invoices
row:
total: ${steps.extract.result.total} # from the named step
due_date: ${steps.extract.result.due_date}
from: ${trigger.from} # from the trigger
Example 2 — reach back past the previous step. A query’s rows are first scanned for anomalies; the alert step then refers to the original rows again, even though the anomaly step ran in between.
- id: recent
run: database.query
with: { table: readings, limit: 200 }
- run: analytics.detect_anomaly
with: { data: ${steps.recent.rows}, field: value, method: z_score }
- run: webhook.send
with:
endpoint: ops-alert
body:
anomalies: ${prev.anomalies} # from the step just before
sample_size: ${steps.recent.count} # from two steps back
Example 3 — join an asynchronous result. In mode: async, a fire-and-forget step is given an id, and a later wait_for: joins its result under its own id, which downstream steps then read.
mode: async
steps:
- id: cls
run: ai.classify
with: { model: gpt-4o-mini, text: ${trigger.envelope.body_text}, categories: [ billing, technical ] }
- wait_for:
event: Fact.AI.Classified
match:
causation: ${cls} # ties the wait to the fire-and-forget step above
id: verdict
- run: database.write
with:
table: tickets
row: { category: ${steps.verdict.category} }
A typo in the id is a hard error. Referencing
${steps.<id>}with an id no step declared stops the run immediately — this is deliberate, so a misspelt handle fails loudly instead of quietly resolving to nothing.
${loop.<name>} — the loop counterWhat it is. Inside a loop: step, the counter’s current value. How it is built: a loop declares a counter with for:, and on each pass the engine sets that counter to the current number. How to write one: use ${loop.<name>} — matching the for: name — anywhere inside the loop’s do: body. The counter is a number, so on its own it keeps that type; inside a string it becomes text.
Example 1 — iterate over a fixed range. Process twelve months in turn, passing the counter as a numeric filter value.
- loop:
for: month
from: 1
to: 12
max: 12
do:
- run: database.query
with:
table: sales
where: { month: ${loop.month} } # a number — whole-value
Example 2 — build a distinct name each pass. Here the counter is embedded in a string to produce a unique object key per iteration.
do:
- run: data.upload
with:
bucket: exports
key: "monthly/report-${loop.month}.csv" # → report-1.csv, report-2.csv, …
body: ${prev.body}
Example 3 — size the loop from earlier data. A count drives how many times the loop runs; the upper bound is a reference, resolved once when the loop starts.
- id: n
run: database.count
with: { table: pending_jobs }
- loop:
for: i
from: 1
to: ${steps.n.count} # resolved once at loop entry; must be a whole number
max: 1000
do:
- run: database.query
with: { table: pending_jobs, where: { batch: ${loop.i} } }
Loop counters live only inside their loop. Using
${loop.<name>}outside the loop body — or with a name no loop declared — is a hard error. Every loop also requires amax:ceiling; a range wider thanmaxstops the run rather than running away. See Loops & iteration.
${secret.<KEY>} — credentials, in provisioning onlyWhat it is. A password, token, or key, pulled from the platform’s secret store at the moment a resource is registered — never written into the playbook file. How it is built: the operator drops a file named <KEY> into the secret directory; ${secret.KEY} resolves to its contents (trailing newlines trimmed). How to write one: ${secret.<KEY>}, where KEY is uppercase letters, digits, and underscores only.
Provisioning playbooks only. A secret reference is allowed only in a provisioning playbook — the place that registers a mailbox, broker, endpoint, or bucket. It belongs nowhere else: business playbooks refer to the resource by the alias it was registered under, so the credential is read once and never travels through day-to-day logic. (Playbooks deployed through the MCP server may not use secrets at all; register secret-bearing resources from the host.)
Example 1 — an IMAP mailbox password. The inbox is registered on boot; its password comes from the store, not the file.
trigger:
event: Fact.System.Boot
filter: { component.eq: playbook-service }
steps:
- run: mail.register_mailbox
with:
alias: faktury
protocol: imap
imap:
host: imap.example.com
username: faktury@example.com
password: ${secret.FAKTURY_IMAP_PASSWORD}
Example 2 — a broker password for an MQTT feed. The same pattern registers a telemetry broker; subscriptions go in the top-level topics: list.
- run: mail.register_mailbox
with:
alias: sensors
protocol: mqtt
mqtt:
host: mosquitto.internal
username: binions
password: ${secret.SENSOR_MQTT_PASSWORD}
topics: [ "sensor/+/temperature" ]
Example 3 — an SMTP sending password. Add an smtp: block so the alias can also send mail; its password is a secret too.
smtp:
host: smtp.example.com
username: reports@example.com
password: ${secret.REPORTS_SMTP_PASSWORD}
Signing keys are different. When
webhook.sendsigns a request, it names an environment variable that holds the key (key_env:), not a${secret}reference — the key never enters the playbook or the event at all.${secret}is specifically for the credentials a provisioning step registers.
${env.…} and ${vars.…} do not exist; only the five names above are accepted — and a playbook that uses an unknown namespace is rejected when it is loaded, with a concrete error, before it ever runs.now() inside the braces — a reference only reads a value, it never computes one. When you need a computed result, use a verb that computes it (analytics.derive reshapes and calculates over records; analytics.detect_anomaly decides “is this abnormal?” instead of an if).${trigger.…} and ${prev.…} paths that don’t exist resolve to nothing rather than failing — so check your field names against the tables above. A ${steps.<id>} that names a step the playbook never declares is caught at load time (the file is rejected with the error spelled out); a ${loop.<name>} outside its loop likewise.