A loop repeats a group of steps a fixed number of times. It is the one piece of control flow in the playbook grammar — a counted range, an optional early exit, and a body of ordinary steps. Reach for it when you need to do the same thing several times in a row: poll until something is ready, retry a check a bounded number of times, or walk a short range of values — all without leaving the playbook. The one thing a loop never does is walk a list of data — for that, the platform fans the list out into one event per element instead (see Iterating over data below).
Think BASIC, not Python. A loop counts from one number to another and runs its body each time — like
FOR n = 1 TO 5. There is no arithmetic, there are no variables of your own, and a hard limit is always required. It stays deliberately small so a playbook is still something you can read top to bottom and know exactly what it will do.
| What it is | A counted repetition of a block of steps |
| Where it goes | As one entry in a playbook’s steps list |
| Always required | A max cap (1–10000) — a loop can never run unbounded |
| Optional | An until condition for early exit |
| The counter | ${loop.<name>} — the current number, usable inside the body |
| Not for | Walking a list of data — fan each element out as its own event instead (see Iterating over data below) |
A loop is the right tool for a short, bounded repetition that happens inside a single run. Most repetition in Binions is better handled in other ways, so it is worth knowing which is which before you reach for loop.
Reach for a loop when:
until condition ends the loop as soon as the resource responds.${loop.n}.to: ${prev.count}), as long as it stays within the cap.Use something else when:
| You want it to run every few minutes | Use a schedule, not a loop. Register a cron schedule and react to Fact.Schedule.Fired — see Triggers & scheduling. |
| You want to retry on failure with back-off | That is built into the daemons already (retry, back-off, and a circuit breaker). You do not write a retry loop in YAML. |
| You want one action per item of a list | Fan out instead of looping: analytics.emit_items turns each element into its own event, and a second playbook runs once per item — see Iterating over data just below. |
| You want to scan a whole network or large range | A sweep belongs in a daemon operation, which does it quickly and emits a fact per device. A sequential YAML loop over many slow targets would take far too long (see Limitations). |
| You want to branch (if / else) | A loop cannot branch. Use a trigger filter, two complementary playbooks, or route on a result fact — see Anatomy of a playbook. |
A counted loop cannot take a list and run its body once per element — and it does not need to. The platform’s answer to “for each item” is to fan the list out into events: one step explodes the list, and a second playbook runs once per element. Two small playbooks replace the loop you cannot write, and give you more than a loop ever would — every element gets its own run, its own retries, and its own audit trail in the event log.
analytics.emit_items explodes the list. Give it items (any list produced by an earlier step) and it emits one Fact.Analytics.ItemEmitted per element — each carrying the element itself as item, plus index, total, and a shared batch_id so related items can be traced as one batch.Fact.Analytics.ItemsEmitted; the per-item runs it spawned then proceed independently, each as a first-class playbook run.Fact.Analytics.ItemEmitted, and its filter reaches into the element with dot-paths — for example item.status.eq: overdue.analytics.filter step between the query and the fan-out — it takes the same field.op conditions as a trigger filter, so nothing new to learn.name: overdue-scan-daily
description: "Daily: read overdue invoices, then give each one its own run."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: overdue-scan-daily
steps:
- id: overdue
run: database.query
with:
table: invoices
where:
status: overdue
limit: 500
- id: fan_out
run: analytics.emit_items
with:
items: ${steps.overdue.rows}
---
name: overdue-remind-one
description: "Runs once per emitted element; the filter reaches into the item."
trigger:
event: Fact.Analytics.ItemEmitted
filter:
item.invoice_no.is_not_null: true
item.status.eq: overdue
steps:
- id: remind
run: mail.send
with:
from_alias: crm-outbound
to:
- ${trigger.item.customer_email}
subject: "Payment reminder — invoice ${trigger.item.invoice_no}"
body_text: |
Our records show invoice ${trigger.item.invoice_no}
(amount: ${trigger.item.amount}) is still unpaid.
Batch ${trigger.batch_id}, item ${trigger.index}.
The counted loop and the fan-out complement each other: loop repeats steps a bounded number of times inside one run; analytics.emit_items turns data into runs. When the work varies per element of a list, fan out. The verbs involved are covered in the verb vocabulary.
A loop is a single entry in the steps list. Instead of a run, it carries a loop block. Here is the whole thing, annotated:
steps:
- loop:
for: n # the counter's name; read it as ${loop.n} in the body
from: 1 # first value, a whole number (inclusive)
to: 5 # last value (inclusive) — a whole number, or ${...}
max: 5 # REQUIRED hard cap on iterations (1–10000)
until: # OPTIONAL early exit, checked after each pass
status.eq: 200
do: # the body — ordinary steps, run in order each pass
- run: webhook.send
with:
endpoint: health-probe
body:
attempt: ${loop.n}
for — required. The name of the counter. It becomes ${loop.<name>} inside the body. Use a short lowercase name like n or page.from — required. The first value, a whole number. The range is inclusive, so from: 1 means the first pass has the counter set to 1.to — required. The last value (inclusive). It is normally a whole number, but it may be a placeholder such as ${prev.count}, which is resolved once when the loop starts.max — required, and this is the important one. A hard cap on how many times the body may run (between 1 and 10000). It guarantees the loop always ends. If the range you ask for is larger than max, the run stops with a clear error rather than running away.until — optional. An early-exit condition using the same operators as a trigger filter: .eq, .ne, .contains, .startswith, .endswith, .gt, .ge, .lt, .le, .in, .not_in, and the null probes .is_null / .is_not_null. It is checked after each pass; the moment it matches, the loop stops.do — required. The body: a list of ordinary steps (each with run, with, and an optional id), run in order on every pass.A loop step is just the
loopblock. It does not take arunor awithof its own (anidis allowed). Everything the loop does lives indo.
from to to, inclusive. Each pass sets ${loop.<name>} to the current number and runs the whole body once.max is mandatory and hard. A loop without max is rejected when the playbook is checked. If the requested range is bigger than max, the run aborts loudly — the loop is never silently shortened. This is what makes every loop guaranteed to finish.until is a post-check (a do-while). The body always runs at least once for a non-empty range; the condition is tested after each pass against the result of the last step in that pass. So a poll always probes once before it can decide it is “ready”.to is resolved once. When to is a placeholder, it is read a single time as the loop starts and must be a whole number. If it works out that from is greater than to, the loop simply runs zero times — cleanly, with no error.${loop.<name>} placeholder used outside its own loop is rejected when the playbook is loaded — before anything runs — like any other reference the playbook cannot honour.Check a health endpoint up to ten times; stop as soon as it answers with HTTP 200. The until condition turns the bounded count into “keep checking until ready, but never more than ten times”.
name: wait-for-service
description: "Probe a service until it is healthy, up to ten attempts."
trigger:
event: Fact.System.Boot
steps:
- loop:
for: attempt
from: 1
to: 10
max: 10
until:
status.eq: 200 # exit as soon as the probe returns 200
do:
- run: webhook.send
with:
endpoint: service-health
method: GET
Write five warm-up rows, using the counter as data. The counter ${loop.n} is a real number, so it can go straight into a value.
name: seed-rows
description: "Insert five numbered placeholder rows."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: seed-job
steps:
- loop:
for: n
from: 1
to: 5
max: 5
do:
- run: database.write
with:
table: warmup
row:
slot: ${loop.n}
note: "placeholder row"
An earlier step reports how many pages there are; the loop then fetches each one. The max cap protects you even if that count comes back larger than expected.
name: fetch-all-pages
description: "Ask how many pages exist, then fetch each one."
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: nightly-sync
steps:
- id: count
run: database.count
with:
table: pages
- loop:
for: page
from: 1
to: ${steps.count.count} # resolved once, when the loop starts
max: 100 # hard ceiling, whatever the count says
do:
- run: webhook.send
with:
url: "http://127.0.0.1:9310/pages/${loop.page}"
method: GET
A loop is intentionally minimal. Knowing its edges keeps your playbooks honest:
from → to in steps of one.analytics.emit_items instead (see Iterating over data above); the counted loop stays for numeric ranges.parallel block. A loop also cannot sit inside a parallel block. The body is a flat list of ordinary steps.${loop.n} only fetches the counter — you cannot add to it, compare it, or compute with it inside the YAML.until can only end the loop early; it cannot skip the body or choose between two paths. Loops do not replace if / else — for that, filter on the trigger, write a second playbook, or route on a result fact.max is required and limited to 10000. There is no way to write an unbounded loop, by design.A loop only repeats work that is already guarded. Every step inside the body passes the same checks as any other step — the outbound host allow-list for web calls, the named-command allow-list for SSH, the permitted register ranges for Modbus. A loop never bypasses any of them; it simply runs the same checked operation more than once. The mandatory
maxcaps how much work a single run can ever generate. The same thinking protects the fan-out pattern:analytics.emit_itemsis capped at 1000 items and fails loudly beyond it.