Documentation / Conception / Guides pratiques / Wire ports: split-pin and params⇄ports

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.

  1. Pick the upstream node's output port name and the downstream node's input port name from the catalog (or the editor's palette).
  2. Add one entry per wire under connections:. Order doesn't matter — the engine computes topological order itself.
  3. Wire the same output to more than one input if several nodes need it; fan-out is free.
  4. 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

  1. On the from side of a connection, append .fieldName to the source port.
  2. 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

  1. Point a connection's to at nodeId.configFieldName — no special declaration needed, any wireable (non-typeHint) config field can be targeted directly.
  2. 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).
  3. 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

  1. 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.
  2. 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.

  1. Set config.template to a string containing {name} placeholders ([a-zA-Z_][\w-]*, e.g. {dfg}, {patient_name}).
  2. Wire (or give a fixed value to) each variable's auto-derived input port — they're all required, type any.
  3. Take the formatted result from the value output 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's isAssignable rules.
  • « … » (expose) n'est pas un champ de config de … — the name in expose: isn't one of the node's config: fields.
  • « … » est un hint de type (inféré du câblage) — pas exposable comme port — you targeted a typeHint field (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 under inputs: 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 an inputs: entry; remove one.
  • valeur fixe de « … » invalide — the literal under inputs: 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.

  • Workflow DSL — the full connections, expose, and inputs grammar.
  • Core primitives — every core node type and its ports, including transform.*.
  • TypeRef — the type grammar and isAssignable rules 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 switchflow.guard/flow.switch, whose condition/cases config fields follow the same params⇄ports rules as this page's guard example.
  • Loop with mapflow.map's item/result ports, the source of the split-pin read example above.
  • JEXL — the expression language behind flow.guard/flow.switch conditions.
  • 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, and isAssignable.
75 documents11 sectionssource : /docs · généré au build