Loops and iteration
Repetition in Iterna 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
End of passinput (Item valueon Map,Keep item?on Filter) 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 of pass. Both legs are required: with no
edge out of Start validation reports subgraph.missing_entry_edge, with no edge into End of pass,
subgraph.missing_exit_edge.
- Execute + Data
- Execute
The edge from the body back into End of pass 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 Item value |
| 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 the body’s 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 “Passes at most” 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 of pass— 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
Item valueto the output array, as is. - Filter requires a strict boolean on
Keep item?: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.