Skip to content

Composing flows

Five fences reuse one flow from another: call_flow and include inline a shared flow, foreach and split run one per element of a list, fork runs one without waiting.

All of them are resolved at compile time. The called flow’s steps are copied into the calling artifact, so there is no run-time lookup, no version skew, and no network hop. The consequence is that a change to a shared flow reaches its callers only when you recompile and redeploy them.

The library directory

Shared flows live in a directory you pass to the compiler:

Terminal window
$ nexus compile order-intake.flow.md --libs ./lib
$ nexus deploy order-intake.flow.md --version 1.0.0 --libs ./lib
$ nexus validate order-intake.flow.md --libs ./lib

Library flows are never deployed on their own, but they are not tenant-free. A reference resolves within the calling flow’s tenant: call_flow: normalize-address from a flow whose front matter says tenant: acme looks for a library .flow.md that declares both flow: normalize-address and tenant: acme. A file that declares the name under a different tenant is not a match — the compiler refuses it, naming both tenants, rather than pulling in another tenant’s flow silently. A bare name is a reference within your own tenant, never across tenants; if two tenants need the same helper, each keeps its own copy.

A library flow is an ordinary .flow.md file. It is referenced by its flow: name, not by its filename. A reference the compiler cannot resolve — wrong name, or right name under the wrong tenant — is a compile error, so it never reaches deployment.

call_flow — inline, with parameters

The first line is the flow name. Every line after it is a parameter: a key: value pair that fills one of the {{ ctx.key }} slots the called flow leaves open.

## Step: normalise-address
```call_flow
normalise-address
country_default: RO
keep_original: true
```

Inside normalise-address, {{ ctx.country_default }} becomes RO and {{ ctx.keep_original }} becomes true, wherever they appear.

A parameter’s value is fixed when the flow is built — a word, a number, true/false, null. It is not an expression: source: $.order.id sets the parameter to the literal text $.order.id, it does not read order.id from the message. There is no message to read from yet — substitution happens at compile time, not per call. The called flow still sees the live message as $; parameters are only the fixed knobs.

The called flow’s steps then run in place, on the current message, and what its last step produces becomes the message for the next step here.

include — inline without parameters

The whole fence body is a flow name.

## Step: shared-validation
```include
standard-order-checks
```

Same inlining as call_flow, with no substitution. Use it for a fixed block — a set of validation rules, a standard enrichment — that needs no configuring.

foreach — one call per element, in order

The first line is an expression that yields an array. call: names the flow. Any lines after that are parameters, exactly as in call_flow — fixed key: value fill-ins for the called flow’s {{ ctx.key }} slots.

## Step: process-lines
```foreach
$.items
call: process-line
source: order-intake
```

Each element of the array becomes the message for one run of process-line, and on every run {{ ctx.source }} is order-intake. The results are collected into an array, which becomes the message for the next step.

Each iteration gets its own context, with the outer variables copied in. Variables written inside an iteration do not leak back out, and one iteration cannot see another’s variables. Use the returned array to carry results forward.

An element whose iteration ends in a false condition is omitted from the results rather than included as its pre-filter value. So the output array can be shorter than the input, which is often what you want — and worth remembering when you compare lengths.

The iterator must evaluate to an array. Anything else fails the step.

split — the same, in parallel

Same structure as foreach, plus an optional timeout_ms.

## Step: fan-out
```split
$.items
call: process-line
timeout_ms: 5000
```

Branches run concurrently and the step waits for all of them before continuing. Results are collected into an array, as with foreach. If any branch fails, the step fails.

timeout_ms is checked after all branches have finished, not while they run. It is a guard against a slow batch, not a way to cancel a hung branch: a branch that never returns holds the step regardless of the value here. Bound the work itself with read_timeout_ms or deadline_ms on the steps inside the called flow.

Omit timeout_ms, or set it to 0, to disable the check.

Prefer foreach unless the branches are genuinely independent and slow enough to be worth the concurrency. Sequential is easier to reason about, and the ordering is guaranteed.

fork — start it and move on

## Step: notify
```fork
call: send-notification
continue_parent: true
```

The branch is started and not waited for. Its result is discarded, and its failure does not fail the calling flow — so use it only for work whose outcome nobody depends on.

continue_parent Effect
true (default) The calling flow continues to the next step
false The calling flow stops here

Because nothing waits for the branch, nothing reports its outcome to the caller either. Anything you need to know about must be visible in the branch’s own log and audit records.

What a body may promise about the response

response_status: and response_header_*: written on a step inside a called body behave differently per fence, and the difference follows from what each fence is:

  • foreach carries them back. The loop is sequential and belongs to this delivery, so its iterations are positions in execution like any other steps. The parent’s accumulated header set and status are handed to each iteration and taken back after it: last writer wins per header name, across iterations and parent steps alike, and a response_header_remove: inside a body operates on the delivery’s real set — it can remove a header the parent declared earlier. An iteration dropped by a false condition still hands its declarations back; an iteration that fails says nothing (the step fails first).
  • split does not. Branches are threads; “last writer” over their completion order would mean nothing to the caller. Declarations inside a split body stay inside it.
  • fork does not. The branch runs after the response has left — a verdict from there would arrive at a response already sent.

call_flow and include are not in this list because they are not bodies: their steps are inlined into the calling sequence at compile time and behave exactly like steps you wrote yourself.

Time budgets

Every flow run has a deadline (flow_max_duration_secs). The fences divide it like this:

  • foreach and split bodies inherit the parent’s remaining deadline. They are part of this delivery, so they cannot buy it more time: a parent that has already spent most of its budget has little left to hand to the loop.
  • A detached fork branch gets its own full budget — fresh, the same size as any flow’s — and is never unlimited. It outlives the parent’s response, but not the platform’s patience.

Choosing between them

Waits Order Result Response status/headers Failure
call_flow, include yes inline replaces the message as your own steps fails the flow
foreach yes sequential, preserved array carried back fails the flow
split yes concurrent array not carried fails the flow
fork no — discarded not carried ignored