Documentation / Conception / Guides pratiques / Loop over a list with map

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.

75 documents13 sectionssource : /docs · généré au build