Blocks

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

The loop step in the Inspector 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 list mode → loop over payload["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 trusting items when every item matters.

Related

  • Loop until for repeat-until-true.
  • Reshape to filter or sort the list first.
  • Merge in combine_array mode to build a list from branches.