Wire ports: split-pin and params⇄ports
This recipe assumes you already have a workflow YAML with a trigger and a few
nodes (see Your first workflow) and know
the dataflow model — typed ports, static vs. runtime validation, absence
propagation (see The dataflow model).
It covers four things you'll do constantly while wiring a graph: connecting
ports, splitting an object port by field, moving values between config and
ports, and building strings with transform.format. The full grammar (nodes,
connections, expose, inputs) lives in
Workflow DSL; the full node/port catalog is in
Core primitives.
Everything below is done by editing YAML. The visual editor exposes the same mechanics as UI affordances (drag-to-connect, a dotted "⚙"-prefixed socket for exposed config fields) — see Use the visual editor if you prefer that path.
Connect two ports directly
A connection is { from: "nodeId.outPort", to: "nodeId.inPort" }. Both ends
must exist on their node's type, and the output type must be assignable to the
input type (structural subtyping, exact value-set match, exact/omitted
dimension — see TypeRef). The
trigger's outputs are addressed the same way, under the reserved node id
trigger.
- Pick the upstream node's output port name and the downstream node's input port name from the catalog (or the editor's palette).
- Add one entry per wire under
connections:. Order doesn't matter — the engine computes topological order itself. - Wire the same output to more than one input if several nodes need it; fan-out is free.
- Wire two different outputs into two different inputs of the same node to get convergence — the node only runs once both (all required) inputs are satisfied.
# content/clinical-demo/workflows/bio-result.workflow.yaml
connections:
- from: compute_dfg.egfr
to: store_dfg.egfr
- from: compute_dfg.egfr
to: detect_condition.egfr
- from: compute_dfg.egfr # fan-out: same output, three inputs
to: drug_safety.egfr
- from: load_medications.medications
to: drug_safety.medications # convergence: drug_safety needs both
drug_safety (clinical.drug-safety.renal) only runs once both egfr (from
compute_dfg) and medications (from load_medications) have a value; if
either branch is absent, it is skipped, per
Branching and absence.
Split a port by field (split-pin)
An object-typed port doesn't need an intermediate node to reach into one of
its fields — wire nodeId.portName.fieldName on either end of a connection.
The field's own type is what gets checked for assignability, not the whole
object's.
Read one field out of an output
- On the
fromside of a connection, append.fieldNameto the source port. - The field must exist on the port's registered object type (checked
statically against
OBJECT_TYPES).
# content/clinical-demo/workflows/drafts/test-posos.workflow.yaml
connections:
- from: map.item.medication # `item` is a MedicationStatement; take `.medication` (CodeableConcept)
to: format.plop
map.item (inside a flow.map body) is a MedicationStatement; .medication
extracts its medication: CodeableConcept field directly, without a
transform.break node in between.
Compose an input from split fields
The same dotted syntax works on the to side, and you can point several
connections at different fields of the same input port — the engine
assembles them into one object at runtime (only the fields you wire are set;
nothing else is inferred).
nodes:
drug_code: { type: value.text, config: { value: "67003879" } }
drug_label: { type: value.text, config: { value: "Tacrolimus" } }
convert: { type: posos.drug-code, config: { to: posos } }
connections:
- { from: drug_code.value, to: convert.concept.code }
- { from: drug_label.value, to: convert.concept.display }
This builds convert's required concept (CodeableConcept: code and
display required, system optional) from two sources, without the
transform.make node that test-posos.workflow.yaml uses for the same
purpose — pick whichever reads better for your graph.
If you address a field that doesn't exist on the port's object type, or the
port isn't an object at all, validation reports it — see
Troubleshooting below.
Unify a parameter and a port (params⇄ports)
Every non-typeHint config: field can be treated as an input port, and every
unwired input port can carry a fixed value — same data, only the source
differs.
Parameter → port: wire into a config field
- Point a connection's
toatnodeId.configFieldName— no special declaration needed, any wireable (non-typeHint) config field can be targeted directly. - Optionally add
expose: [configFieldName]on the node instance if you want the socket to show up in the editor even before you've wired anything (a pure UX hint — it changes nothing about validation or runtime behavior). - At runtime the wired value overrides the static
config:value; if the upstream branch is absent, the static value is used as the fallback.
nodes:
threshold_value:
type: value.integer
config: { value: 90 }
build_condition:
type: transform.format
config: { template: "value > {threshold}" }
guard:
type: flow.guard
config: { condition: "criteria > 1000" } # static fallback
connections:
- { from: threshold_value.value, to: build_condition.threshold }
- { from: trigger.result, to: guard.criteria }
- { from: build_condition.value, to: guard.condition } # config field, wired
guard.condition is normally a static string in config:; here it is instead
produced by another node, with the static string kept as the fallback for a
run where build_condition doesn't fire.
A typeHint: true config field (one whose value is inferred from wiring
elsewhere, like flow.guard's type/valueType, or transform.break's
type) can never be exposed or wired — it isn't a business parameter. A
config field whose name collides with an already-declared input port is also
never exposable this way — the declared port wins.
Port → parameter: give an input a fixed value
- Add the value under
nodes.<id>.inputs.<portName>instead of wiring a connection — it's validated statically against the port's type, just like a wired value would be. - Use it for any unwired input, including nested object literals.
# content/clinical-demo/workflows/analyse-posos.workflow.yaml
notify_pharmacist:
type: notify.send
config: { severity: critical }
inputs:
title: Escalade Pharmacien
body: Une alerte critique POSOS n'a pas été traitée par le prescripteur et
nécessite une révision pharmaceutique.
make_alert_obs:
type: transform.make
config:
type: Observation
inputs:
status: final
code:
code: critical-alert
display: Alerte POSOS
value:
value: 1
unit: alert
effective: 2024-01-01T00:00:00Z
A port that is both wired and given a fixed value is a validation error — pick one. And unlike the config-field fallback above, an input that IS wired but whose upstream branch ends up absent at runtime does not fall back to a fixed value on the same port: absence propagation governs it, the wire is authoritative.
Build a string with transform.format
transform.format turns a {variable} template into a string, with one
input port derived per variable name in the template — no fixed set of
ports to remember.
- Set
config.templateto a string containing{name}placeholders ([a-zA-Z_][\w-]*, e.g.{dfg},{patient_name}). - Wire (or give a fixed value to) each variable's auto-derived input port —
they're all required, type
any. - Take the formatted result from the
valueoutput port (String).
# content/clinical-demo/workflows/transmission-imputabilite.workflow.yaml
format_alert:
type: transform.format
config:
template: >-
Des symptômes de la transmission correspondent à des effets
indésirables documentés des traitements en cours ({count}
correspondance(s)) :
{detections}
Vérifier l'imputabilité médicamenteuse.
connections:
- from: guard_detections.pass
to: format_alert.count
- from: detect_adverse_effects.summary
to: format_alert.detections
- from: format_alert.value
to: notify.body
{count} and {detections} each become a required input port on
format_alert — there's no config:/inputs: schema entry for them, they're
entirely derived from the template text.
Rendering rules (formatValue in
libs/engine-core/src/engine/catalog.ts): a {value, unit}-shaped object
renders as "31.2 mL/min"; any other object renders as JSON; a variable whose
port received nothing stays as the literal {name} marker in the output — a
visible, debuggable gap rather than a silent blank.
The template itself is just transform.format's template config field, so
the params⇄ports mechanics above apply to it too — you can wire a dynamic
template into nodeId.template. In that case the port set stays derived
from the static config's template, not the wired one: the dynamic template
can only fill in variables the static template already declared as ports.
Verify
After wiring, run the static validator — it re-checks every rule above (existing ports, split-pin fields, type compatibility, the wired-vs-fixed conflict, required inputs) before anything executes. See Validate a workflow for the CLI, and Simulate a workflow to actually run the graph against sample data once it's green.
Troubleshooting
The validator's messages (libs/engine-core/src/engine/validate.ts) are
French today regardless of locale — here's what to do with the ones this page
concerns:
sortie/entrée « … » inexistante— the port name before the dot doesn't exist on that node type. Check spelling against the catalog.champ « … » inconnu sur …— a split-pin field (after the second dot) isn't in the port's registered object type. Check the type's field list (TypeRef).type incompatible ( … → … )— the (possibly field-narrowed) output type isn't assignable to the (possibly field-narrowed) input type. See TypeRef'sisAssignablerules.« … » (expose) n'est pas un champ de config de …— the name inexpose:isn't one of the node'sconfig:fields.« … » est un hint de type (inféré du câblage) — pas exposable comme port— you targeted atypeHintfield (e.g.type,valueType); those are inferred from wiring elsewhere and can't be a port.valeur fixe sur une entrée inconnue « … »— a key underinputs:doesn't match any input port on that node type.entrée « … » à la fois câblée et à valeur fixe— the same input port has both a connection and aninputs:entry; remove one.valeur fixe de « … » invalide— the literal underinputs:doesn't conform to the port's type.entrée requise « … » non connectée— a required input has neither a connection nor a fixed value.
For the full mechanics behind these checks, see Static validation engine.
Related
- Workflow DSL — the full
connections,expose, andinputsgrammar. - Core primitives — every core node type
and its ports, including
transform.*. - TypeRef — the type grammar and
isAssignablerules behind connection compatibility. - The dataflow model — the concepts this recipe assumes.
- Branching and absence — how a skipped upstream node propagates through a connection.
- Branch with guards and switch —
flow.guard/flow.switch, whosecondition/casesconfig fields follow the same params⇄ports rules as this page'sguardexample. - Loop with map —
flow.map'sitem/resultports, the source of the split-pin read example above. - JEXL — the expression language behind
flow.guard/flow.switchconditions. - Validate a workflow — the CLI that runs every check in this page's troubleshooting section.
- Use the visual editor — the GUI equivalent of every mechanic on this page.
- Static validation engine
— internals of
validateSpec,nodePorts, andisAssignable.