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 Start output (Item on the array nodes) opens the body;
  • the End input closes it;
  • the Done output fires once, after the repetition ends. Everything that continues in the graph hangs off Done.

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.

Entry
While Loop
Exit
Body: poll the status
  • Execute + Data
  • Execute
While Loop: the body is wired on both sides, the rest of the graph hangs off Done. The grey wire is pure exec — order without a value.

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.

Entry
Pull out the array
Map
Exit
Body: transform
  • Execute + Data
Map: the element leaves through Item, the transformed value returns to End, the collected array comes out of Done.

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 End to the output array, as is.
  • Filter requires a strict boolean on End: true keeps the original element, false drops 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.

app.iterna.ai

Executions

Node executions

NodeStatusExplanationTime
for_each_1completed0 elements0.01s
log_1skippedInside a loop body that never iterated
exit_1completed0.00s
Run report: the loop reached Done, but the body never ran.

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.

What next