Branch with guards and switch
There is no if node in the DSL. To make a run take different paths depending
on data, wire one of two gateway node types — flow.guard stops a branch
outright when a condition doesn't hold, flow.switch picks exactly one of
several named paths — and lean on absence propagation for the "else" you'd
otherwise have to draw by hand: a node with a required input that never
arrives is skipped, not errored. When exclusive branches lead to a common
step, flow.merge joins them back. This recipe wires these into one
branching workflow. For the model behind them, see
Branching and absence.
Prerequisites
- A workflow file to edit — see Your first workflow if you don't have one yet.
- Comfort with port-to-port wiring; see Wire ports if
from/toconnections are new to you.
Stop a branch with flow.guard
Use a guard when the rest of a branch should run only if a condition on some
data holds. This is the real gate from
content/clinical-demo/workflows/bio-result.workflow.yaml:
Add the node with its condition, a JEXL expression over
criteria:nodes: guard_creatinine: type: flow.guard config: condition: criteria.code.code == "creatinine"Wire the tested data to
.criteria:connections: - from: trigger.result to: guard_creatinine.criteriaWire
.passto whatever should run only when the condition is true:connections: - from: guard_creatinine.pass to: compute_dfg.resultWhen the condition is false, the guard emits nothing at all —
.passnever fires, socompute_dfg(and everything wired downstream of it) is skipped for that run. There is no second branch to author.If downstream needs a value other than
criteriaitself, wire it to the optional.valueinput —.passthen carries.valueinstead of.criteria. The same file does this for its second guard:nodes: guard_alerts: type: flow.guard config: condition: criteria.count > 0 connections: - from: drug_safety.report to: guard_alerts.criteria # tested - from: guard_alerts.pass to: notify_alert.report # forwarded on passHere
guard_alerts.criteriais tested forcount > 0, and because nothing is wired to.value,.passforwardscriteriaitself (the report) tonotify_alert.
flow.guard's type/valueType config fields (the types of criteria and
value) are marked as inferred hints in the catalog
(libs/shared/src/engine/catalog-metadata.ts) — the visual editor fills them
in as soon as you wire .criteria/.value and shows them read-only. When
hand-editing YAML, you don't need to set them at all; only condition is
yours to write.
A guard needs a condition. Static validation refuses a guard whose condition
is absent or blank (Nœud « … » : condition vide.), unless a connection feeds
the condition config field. It also refuses a condition that does not compile
or reads a variable other than criteria, value and context, with the
column of the error:
Nœud « seuil » : condition, colonne 26 : « , » inattendu. Séparateur décimal : le point (6.0).
In the visual editor, the condition field shows these problems while you type.
Filter or guard
A trigger filter and a guard can test the same fact, at two different moments:
- the trigger filter (
trigger.selector) decides whether the run starts at all. It reads the event as received, bound toevent. An event that no filter keeps starts no run:POST /api/eventsanswers200withruns: [], and nothing appears in the executions list; - a guard stops a branch inside a run that has already started. It reads the outputs of the trigger or of other nodes.
On a lab result, criteria.code.code == "creatinine" on the trigger's result
output (an Observation) tests what event.analyte == "creatinine" tests in a
filter. Put the condition in the filter when the workflow has nothing to do
otherwise; keep a guard when other branches of the run must go on. See
Choose which events start a workflow
and Events and triggers — what a filter reads.
Route to one of several cases with flow.switch
Use a switch when there are more than two outcomes, or you want them named
instead of just pass/no-pass. This is the real gate from
content/clinical-demo/workflows/analyse-posos.workflow.yaml:
Add the node. Set
config.typeto the object type flowing through.value, andconfig.casesto an ordered list of{ name, condition }, eachconditiona JEXL expression overvalue:nodes: check_alerts: type: flow.switch config: type: Integer cases: - name: critical condition: value > 0Wire the routed data to
.value:connections: - from: interactions.count to: check_alerts.valueWire each case's output — one port per
cases[].name— to its own downstream node:connections: - from: check_alerts.critical to: make_alert_obs.afterA switch also always exposes a
.defaultoutput, emitted when no case matches. Leaving it unwired is fine — as in this file, which has nothing to do when there's no critical count.
The switch tests cases in order and routes to the first whose condition is
true; put the more specific conditions first. Exactly one output fires per
run (a case, or default); everything wired to the other output ports is
skipped — the same absence propagation the guard relies on. Case names become
port names, so keep them to identifiers (letters, digits, _, - — the
editor sanitizes typed names to this set).
Static validation refuses a case:
- without a
name, or whose name has other characters than letters, digits,_and-; - named
defaultordone, which are already output ports of the node; - whose name repeats an earlier case;
- without a
condition, or whose condition does not compile or reads a variable other thanvalueandcontext. The error names the case:Nœud « check_alerts » : cas « critical », condition, colonne 9 : ….
cases itself must be a list; absent, every value goes to default. The
editor checks each case condition while you type, like the guard's.
Like the guard, flow.switch's config.type is an inferred hint, filled in
by the editor once .value is wired; set it by hand only when authoring YAML
directly.
Join exclusive branches with flow.merge
Use a merge when exclusive branches — the cases of a switch, or a guard and its opposite — each produce a value of the same type for one common step. Wiring every branch to the same input does not work: an input reads only its first connection. The merge reads one input per branch and emits the first one present.
Add the node. Set
config.typeto the type of the joined values, andconfig.count(2 to 8, default 2) when there are more than two branches:nodes: note: type: flow.merge config: type: StringWire each branch to its own input,
value1,value2…:connections: - from: note_high.value # branch of the "high" case to: note.value1 - from: note_low.value # branch of the "low" case to: note.value2Wire
.valueto the common step, which then runs once, with the value of the branch taken:connections: - from: note.value to: send_note.body
The merge emits the first present input in the order value1, value2…
When no branch produced a value (a switch that took default, a guard that
stopped), it emits neither value nor done: send_note is skipped, like
any node whose required input never arrives. When two inputs are present at
once, value1 wins; the order is the order of the inputs, not the order in
which the branches ran.
Branches that produce no value join the same way: wire each branch's done
pin to its own input, set config.type to Boolean (done carries true),
and wire the merge's done to the common step's .after. The step then runs
once when any of the branches ran.
An input can also carry a fixed value (inputs:) as the fallback when no
branch produces one. A fixed value is always present, so it goes on the last
fed input: static validation refuses a fixed input followed by a fed one.
Static validation also refuses a merge with fewer than two fed inputs, a
count outside 2 to 8, a missing type, and a merge outside a flow.map
body fed by a node of that body (the messages are listed in
Core primitives — flow.merge).
In the visual editor, the first input you wire sets type when its source
has a type (a done pin or a webhook body has none); the Value type
field of the inspector sets or changes it. The Number of inputs field sets
count; lowering it removes the connections and fixed values of the inputs
that go away.
Let absence be the else you don't write
A required input a run never receives — because the node upstream chose not
to emit it — leaves the node skipped, not errored. This is the same
mechanism behind an unmatched guard or switch case, and it composes with any
compute node for free. From external-plugins/demo/behaviors/clinical.ts:
export const detectConditionFromEgfr: BehaviorFor<"clinical.condition.detect-from-egfr"> = () => ({
run: ({ inputs }) => {
const proposal = suspectedConditionFromEgfr(inputs.egfr);
return proposal ? { proposal } : {}; // no proposal → branch stopped
},
});
detect_condition only emits proposal when eGFR is low enough to suspect a
condition. Wire straight through, with nothing extra:
connections:
- from: detect_condition.proposal
to: confirm_condition.proposal
- from: confirm_condition.condition
to: record_condition.condition
When no proposal is emitted, confirm_condition and record_condition are
both skipped for that run — there is no else branch to author.
Absence only takes over at runtime. A required input still needs a
connection (or a fixed value) at authoring time: leaving guard_alerts.criteria
or check_alerts.value unwired is a static validation error —
Nœud « … » : entrée requise « … » non connectée.
(libs/engine-core/src/engine/validate.ts) — not something absence
propagation will paper over. Wire it, and let the run decide whether data
actually shows up.
Verify both paths run
- Validate the workflow to confirm every required input is wired and every connected type matches.
- Simulate the workflow with inputs on each side of
your guard's condition, and one per switch case, then check the node
statuses: the branch you expect should read
done, the othersskipped.
Related
- Branching and absence — the model behind guard, switch, and absence propagation.
- Wire ports — port-to-port connections, split-pins, and fixed values, the mechanics this recipe builds on.
- Sequence with after and done — chain or join branches by control flow alone, once a guard or switch has already cut a path.
- Loop with map — the flow primitive for iterating over a list instead of branching.
- JEXL — the expression language for
condition. - Workflow DSL and
Core primitives — the full schema for
flow.guard,flow.switch,flow.merge, and every other primitive. - Validate a workflow and Simulate a workflow — check a branching workflow before running it for real.
- Glossary — guard, switch, absence propagation.