Loop
Run a body of steps once per item of an upstream list, then fire done once with the collected results. The iterate and done ports, and shaping for cost.
Run a body of steps once per item of an upstream list, then continue once with everything collected. Two output ports do the work:
- iterate: the body subgraph wires here; it runs N times, once per item.
- done: fires once after every iteration finishes, with
the aggregate:
{ "items": [each iteration's result], "total": N, "failed": K }.
What it does
At build time the compiler walks everything reachable from the
iterate port and compiles it as a separate body. At run time the
loop evaluates Iterate over against the upstream payload to get
the list, then runs the body once per element. Iterations are
independent, so several run at once (up to eight at a time by
default); items still comes back in the list's original order.
Each body run gets its own rows in the run history, tagged with the iteration index, so a failure on item 37 of 200 is visible as exactly that. If the job running the loop dies and is retried, iterations that already completed are not re-run; their stored outputs are reused and only the unfinished ones execute.
Failures are counted rather than fatal: one bad iteration lands in
failed (with its index and reason under errors) and the loop
completes. If every iteration fails, the loop step itself
fails, so a downstream step never summarizes an empty collection
as if it were real data.
Config
One field: a path to the list to iterate.
Iterate over is a path to a list the upstream step already
emits, such as payload["items"] or payload["data"]["posts"]. It is not a
transform. If the data isn't already a list (stringified JSON,
needs filtering), fix it upstream with
reshape or run code and
point the path at that. The sandbox rejects anonymous functions,
so Enum.map-style reshaping can't be written here; slicing
helpers like Enum.take(payload["items"], 10) can.
Inside the body, each pass sees payload.item (the element) and
payload.index (0-based) alongside the upstream payload, so
{{ payload.item.title }} works in templates. Both keys are
re-exposed on every body step, not only the first, so a
save to store three steps into the body
can still key on {{ payload.item.id }}.
The list is capped per plan: 1,000 items on Free, 10,000 on Pro. A longer list fails the step before any iteration starts.
Output
On done, once:
{
"items": [
{ "id": 101, "title": "First", "summary": "…" },
{ "id": 102, "title": "Second", "summary": "…" }
],
"total": 3,
"failed": 1,
"errors": [ { "index": 2, "reason": { "message": "404 from the API" } } ]
}
items holds each iteration's terminal output (the last body
step's output) in order; failed iterations are absent from it.
errors only appears when something failed. Reference
{{ payload.items }} or payload["items"] downstream, for
example as the input to one AI step over
the whole collection.
A test run from the Inspector adds over_total (the full list
length) and a test_note explaining that only one item ran.
Shape the loop for cost
Only work that genuinely must run per item belongs off iterate,
typically the per-item fetch. Anything that can consume the whole
collection belongs off done: one AI call summarizing 30 items is
~30× cheaper than 30 calls, and one digest reads better than 30
messages. The default shape:
loop ─ iterate → fetch detail
└ done → AI summarize all → send digest
Leave done unwired only when the outcome is genuinely per-item
(reply to each email individually).
Patterns
- Fetch then digest: list ids → iterate: fetch each → done: summarize all → one Slack message.
- Work queue: get from store in
listmode → loop overpayload["rows"]→ do the work → save keyed{{ payload.item.key }}. - Fan out to sub-workflows: a call workflow in the body runs the child inside each iteration and hands its output to the next body step.
Gotchas
- Testing a loop from the Inspector runs the body once with a sample item, a guard against firing 50 sends while building. Real runs iterate the full list.
- Iterations run concurrently, so a body that hits a rate-limited API may need a wait or a smaller batch. Loops don't nest (the compiler rejects a loop inside a loop's body); use a sub-workflow for the inner loop.
- Each iteration records its own step execution in the run history, grouped per loop.
- A partially failed loop still completes. Check
payload["failed"]with an if before trustingitemswhen every item matters.
Related
- Loop until for repeat-until-true.
- Reshape to filter or sort the list first.
- Merge in
combine_arraymode to build a list from branches.