Loops and iteration
Repetition in Flow is not drawn as an arrow pointing back. It is expressed with four shell
nodes — While Loop, For each, Map and Filter — each holding a body: ordinary
nodes the shell runs as many times as needed. All four sit in the palette under
Logic & Flow, group Iteration, and all four are available on the free plan.
An edge pointing back is an error, not a loop
The engine does not walk the nodes top to bottom. It counts incoming dependencies: a node starts once every input has resolved (see the graph model). An edge from a later node back into an earlier one means “this node waits for itself” — the incoming dependency never resolves and that whole part of the graph stalls.
Such a graph is therefore rejected before the run: validation reports graph.cycle and names
the entire closed chain. If the loop runs through a Wait all barrier the code is
graph.barrier_unsatisfiable, which says the same thing: the barrier waits on an input that
lives downstream of itself. The full list is on validation codes.
The shell and its body
All four nodes share one construction: two ports mark the body’s boundary.
- the
Startoutput (Itemon the array nodes) opens the body; - the
Endinput closes it; - the
Doneoutput fires once, after the repetition ends. Everything that continues in the graph hangs offDone.
The body is every node on a path between Start and End. Both legs are required: with no
edge out of Start validation reports subgraph.missing_entry_edge, with no edge into End,
subgraph.missing_exit_edge.
- Execute + Data
- Execute
The edge from the body back into End looks like the very “arrow backwards” just rejected,
but it is not one: a shell’s boundary edges are excluded from the dependency count, so the
scheduler never sees them. That is exactly why a loop is only allowed in this shape — the
engine has to know where the body starts and ends in order to run it as a separate nested pass.
Which of the four
| Node | How many passes | What reaches the body | The node’s result |
|---|---|---|---|
| While Loop | while the condition holds | nothing: Start is pure exec |
the value that arrived on its input |
| For each | one per array element | the element | the number of elements processed |
| Map | one per array element | the element | the array of values collected from End |
| Filter | one per array element | the element | the original elements whose body returned true |
The split matters. While Loop and For each repeat work: their result is not the
body’s data but the fact that N passes happened. Map and Filter transform a list:
they collect whatever the body put on End, and the output order matches the input order.
Nesting shells is rejected: a loop inside a loop fails with
subgraph.nested_iteration_shell — the inner shell could never be started and the run would
hang. Sequence the loops instead (first Done → second input), or move the inner walk into a
single JavaScript Code node.
While Loop: the condition and the counter
The condition is a template expression evaluated before every pass, the first one
included. False right away means the body never runs, and Done still fires. The body must
change whatever the condition reads: values travel between passes through session variables
(the Set Variable node) or through body node outputs, {{ nodes.<id>.output }}.
Inside the loop a service variable {{ variables.loop_index }} holds the pass number, counted
from zero; it disappears once the loop exits. Counting passes with it is the trap everybody
falls into: the variable is set after the condition is checked, so on the very first check
it does not exist yet. And a condition that read a missing value does not fail the node — it
simply becomes false. So {{ variables.loop_index < 2 }} gives zero passes, not three:
the body never runs, Done fires immediately and the run counts as a success.
The working shape uses the default filter, which supplies a value on that first check:
{{ variables.loop_index | default(-1) < 2 }} gives three passes, with indexes 0, 1 and 2.
The rule is general and covers any value that only appears inside the body: it is not there on
the first check, so either write | default(...) or initialise the variable with a Set
Variable node before the loop. And mind the one-step lag: from the second check onward
the condition sees the index of the pass that already finished, not of the one about to
start — when the number of passes has to be exact, set it with the “Max Iterations” field
rather than with arithmetic in the condition.
The number of passes is capped twice: by the node’s field and by the engine ceiling — 100 passes by default, a value the plan can change. The node’s field can only lower the ceiling, never raise it above the plan’s.
Arrays: For each, Map, Filter
These three take a JSON array (or a string encoding one). An object such as
{"items": [...]} is not an array and the node fails with “expected a list”. The usual fix is
a Template node in front of the shell holding {{ inputs.input.items | tojson }};
validation warns about this wiring in advance with array.input_shape whenever the array
comes straight out of Entry.
- Execute + Data
The element arrives in the body as an ordinary edge value and is read as
{{ inputs.<input port> }} — for most nodes that is {{ inputs.input }}. The “Include index”
switch changes the shape: instead of the bare element, Item carries
{<key>: element, index: N}, read in the body as {{ inputs.input.item }} and
{{ inputs.input.index }}. The key name is the “Item key” field next to the switch.
- For each ignores whatever arrives on
End— the node exists for the side effect: a mail per address, a row per record, a message per chat. Its result is the element count. - Map appends whatever arrived on
Endto the output array, as is. - Filter requires a strict boolean on
End:truekeeps the original element,falsedrops it. Anything else fails the node rather than counting as truthy.
{{ loop.* }} — reserved and empty
Templates do have a loop namespace, and the engine owns it, but nothing fills it today:
{{ loop.item }} inside a body renders as an empty string. Read the element from the port
({{ inputs.input }}) and the pass number of a While Loop from
{{ variables.loop_index }}. The full list of namespaces is on
templates.
What the run report shows
Every pass of the body lands in the report as its own group — “Iteration 1”, “Iteration 2” and
so on, with the inputs and outputs of every body node on that pass. Outside the loop,
{{ nodes.<body node>.output }} holds the last pass: there is one namespace for all
iterations and each pass overwrites the previous one.
If the body never ran — an empty array, a condition false from the start — the body nodes are
reported as skipped with the reason loop_body_not_entered. The reason is separate for a
concrete cause: a body node has no ordinary incoming dependency at all (the shell’s boundary is
excluded from the count), and without this branch of the classifier it would be labelled “no
trigger” — i.e. it would look like a wiring defect where the wiring is correct.
Executions
Node executions
| Node | Status | Explanation | Time |
|---|---|---|---|
| for_each_1 | completed | 0 elements | 0.01s |
| log_1 | skipped | Inside a loop body that never iterated | — |
| exit_1 | completed | 0.00s |
The run itself succeeds: a body that never ran is an outcome, not a failure. Details are on run statuses.
The ceilings
- While Loop passes — 100 by default, set by the plan.
- Array length for For each, Map and Filter — 10,000 elements; a longer array is not truncated, it fails the node.
- Run wall clock — the overall second budget: the loop checks it before every pass, so a long loop stops with a budget error instead of hanging.
- A per-node timeout does not apply to the shells, by design: their time is the sum of the body nodes’ times, and each of those is already capped. See timeouts and budgets.