Loop over a list with map
You have a node that outputs a list, and you want to run a sub-pipeline once
per element, then carry on with one aggregated result. flow.map is the DSL's
only for-each primitive — there is no for/while keyword in the grammar (see
Core primitives and
Workflow DSL). This recipe wires one, using the
real per_symptom loop from
content/clinical-demo/workflows/transmission-imputabilite.workflow.yaml as the running
example: standardizing every symptom an agent extracted from a communication.
1. Wire the list into items
Add a flow.map node and connect a list-typed output to its items input
(required, list(any)):
nodes:
per_symptom: { type: flow.map }
connections:
- { from: extract_symptoms.symptoms, to: per_symptom.items }
items follows the same parameter⇄port rules as any other input — a node with
nothing wired can carry a fixed inline list instead (per_symptom: { type: flow.map, inputs: { items: [...] } }), see
Wire ports. In practice you'll almost always
wire it: the whole point of the node is iterating a runtime-sized list.
Don't hand-set config.itemType / config.resultType — they're type hints the
engine derives from how you wire item and result (steps 2–3), and the
validator explicitly rejects exposing a type-hint field as a port
(libs/shared/src/engine/catalog-metadata.ts, libs/engine-core/src/engine/validate.ts).
2. Send item into the body
Wire the map's item output (the current element) into whichever node starts
the per-element pipeline:
nodes:
standardize:
type: posos.autocomplete
config: { entityType: ADVERSE_EFFECT, k: 5, thresh: 0.4 }
connections:
- { from: per_symptom.item, to: standardize.query }
The body of the loop is whatever this wiring reaches: every node that is
both a descendant of item and an ancestor of whatever you'll wire into
result in the next step (libs/engine-core/src/engine/graph.ts,
computeBodies). A body can be a single node, as here, or a short chain — there
is no explicit "end body" marker, it falls out of the wiring.
3. Close the loop with the back-edge to result
Wire one body output into the map's result input. This is the only
connection in a workflow allowed to point backward — it's excluded from the
cycle check precisely because it's a loop's own return edge, not a genuine
cycle:
connections:
- { from: standardize.concept, to: per_symptom.result }
result is required like any other input, and satisfied the same way (wire or
fixed value) — it just happens to be the one input the engine lets a body node
feed backward into. If you don't need to transform the element at all, wire the
pass-through directly: { from: per_symptom.item, to: per_symptom.result } is
valid (the map itself as its own result source).
4. Consume collected after the loop
collected (a list, map.collected) holds the results in input order — the
interpreter iterates items with a plain indexed loop and pushes the
result value of each iteration that produced one
(libs/engine-core/src/engine/interpreter.ts):
connections:
- { from: per_symptom.collected, to: detect_adverse_effects.symptoms }
An iteration whose result-source node was skipped (a guard inside the body
closed, or one of the body's own required inputs was absent for that element)
contributes nothing to collected — the list only ever contains values of
resultType, so a downstream node never meets a hole. The elements left
without a result come out on a second output, skipped (a list of
itemType), which is emitted only when it is non-empty: wire it to the
branch that reports what the loop could not handle, and that branch stays
silent when every element produced a result.
connections:
- { from: per_symptom.skipped, to: format_unresolved_symptoms.symptoms }
See Branching and absence for how absence propagates in general.
5. Let a body node depend on something computed once, outside the loop
A body node can also read a value computed outside the loop — a lookup, a
converted code, anything that only needs to happen once rather than per
element. Wire the external node straight into the body node; you don't need to
route it through items or duplicate it per iteration:
nodes:
per_symptom: { type: flow.map }
locale_label: { type: value.text, config: { value: "fr-FR" } }
annotate:
type: transform.format
config: { template: "{symptom} ({locale})" }
connections:
- { from: extract_symptoms.symptoms, to: per_symptom.items }
- { from: per_symptom.item, to: annotate.symptom } # per iteration
- { from: locale_label.value, to: annotate.locale } # computed once
- { from: annotate.value, to: per_symptom.result }
locale_label has no path from per_symptom.item, so it isn't part of the
body — the engine instead adds a synthetic ordering edge that runs it once,
before the map, on the main pass (orderEdges in graph.ts). Get this backward
— wiring something from item into a node you'd rather run once — and it
becomes part of the body and reruns every iteration instead.
Static limits of the loop model
meridian-plugin validate (and the interpreter, at run time) enforce a small
set of structural rules on top of the usual port/type checks. Each one is a
named, actionable error out of
libs/engine-core/src/engine/graph.ts (graphStructureErrors) — hit one and
the fix is usually the one line quoted below.
Cycles outside a loop's own back-edge
Only a connection targeting map.result is allowed to point backward. Any
other wire that loops a value back into one of its own (transitive) inputs is
rejected:
Cycle dans le graphe (hors boucles map) impliquant : …
Fix: remove the cycle, or if it genuinely is a per-element loop, express it as
a flow.map body instead.
Nested loops
A flow.map cannot sit inside the body of another flow.map:
Boucles imbriquées non supportées : le map « X » est dans le corps du map « Y ».
There's no nested for-each in the current model. See
Core primitives for what flow.map does and
doesn't cover.
A node claimed by two loop bodies
A single node instance can belong to only one loop's body:
Le nœud « X » appartient au corps de DEUX boucles (« A » et « B ») — dupliquez-le.
Fix, as the message says: give each loop its own instance of that node.
result sourced from outside the body
Whatever you wire into map.result must be the map itself (pass-through) or a
node reachable forward from map.item:
Map « X » : la source de « result » (« Y ») n'appartient pas au corps de la boucle (le corps = les nœuds entre map.item et map.result).
Fix: route the value through the body instead of pulling it in from elsewhere.
item consumed outside the body
A consumer of map.item must also be on the path back to result — otherwise
it's reading a "current element" that only exists inside an iteration that
never resolves for it:
Map « X » : « item » alimente « Y » qui est HORS du corps de la boucle — câblez aussi un chemin de retour vers map.result pour inclure ce nœud dans le corps.
Fix: wire that node's output (possibly through further nodes) back into
result, so it's part of the loop it's reading from.
Verify
Run the static validator before anything else — it catches all five structural errors above plus the ordinary port/type checks, with no engine and nothing executed:
pnpm plugin validate content/clinical-demo/workflows/transmission-imputabilite.workflow.yaml
See Validate a workflow for
the full command and error catalogue. Once it's green, run
Simulate a workflow against sample data to see
collected actually populate, iteration by iteration.
Related
- Core primitives —
flow.mapalongsideflow.guardandflow.switch. - Workflow DSL — the
connectionsgrammar, including themap.resultback-edge. - The dataflow model — port-to-port wiring basics this recipe builds on.
- Branching and absence — how a
skipped body node moves its element from
collectedtoskipped. - Branch with guards and switch and Sequence with after and done — the other two flow mechanisms.
- Validate a workflow — catches every error in this page before a run starts.
- Simulate a workflow — run the loop against sample data.
- Static validation engine — internals of body inference, back-edges and the synthetic ordering edge.
- Interpreter internals — how the engine actually executes a map's body per element at run time.