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:
$ 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 ./libLibrary 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_flownormalise-addresscountry_default: ROkeep_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```includestandard-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$.itemscall: process-linesource: 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$.itemscall: process-linetimeout_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```forkcall: send-notificationcontinue_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:
foreachcarries 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 aresponse_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 falseconditionstill hands its declarations back; an iteration that fails says nothing (the step fails first).splitdoes not. Branches are threads; “last writer” over their completion order would mean nothing to the caller. Declarations inside asplitbody stay inside it.forkdoes 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:
foreachandsplitbodies 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
forkbranch 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 |