Documentation / Conception / Référence / Core dataflow primitives

Core dataflow primitives

Source: libs/shared/src/engine/catalog-metadata.ts (the CATALOG_META registry) and libs/shared/src/engine/dynamic-ports.ts (port derivation and the implicit flow ports). This page mirrors both files field by field.

Scope

CATALOG_META starts with exactly eighteen node types: five value.* constants, four transform.* type adapters, three flow.* gateways, two trigger.* entry points (webhook, called-by-a-workflow), two workflow.* sub-workflow nodes (execute, return) and two agent.* nodes (runtime-picked, defined-in-the-node). These are the only node types the engine itself ships with; every other node type (domain triggers, record sources/sinks, clinical computes, human tasks, registered agents) is added to the same registry at plugin-load time through registerNodeMeta, which throws if a plugin tries to register an id that already exists with a different definition. The console, the static validator, and the interpreter all read node types out of this one registry without distinguishing core from plugin-contributed entries.

Per Node behavior contract, the eighteen primitives below are also the only node types built directly into makeCatalog (libs/engine-core/src/engine/catalog.ts): they do not go through a plugin's NodeBehaviorFactory, and have no corresponding run/propose/fromEvent module to inspect — their execution is internal to the interpreter. See Interpreter internals for that side; this page documents only the node metadata and ports.

catalog-metadata.ts is deliberately pure — no ports/adapters, no functions — so it is JSON-serializable and consumed as-is by the console to draw sockets.

For the DSL that instantiates and wires these node types (nodes, connections, expose, inputs), see Workflow DSL. For the type grammar carried on every port, see TypeRef. For plugin-contributed node types, see Plugins concept and Bundled plugins.

The node descriptor: NodeTypeMeta

Every entry in CATALOG_META — core or plugin — is a NodeTypeMeta:

Field Type Required Meaning
id string yes Catalogue key, e.g. value.text. Matches the key it's stored under.
kind NodeKind yes Execution role — see Node kind below.
category string yes Business grouping for the UX palette.
label string yes Display label.
description string yes Display description.
context ContextKey[] yes Ambient context kinds the node consumes (open, plugin-contributed — e.g. patient, encounter, order, document from @posos/common).
establishes ContextKey[] no Context a trigger establishes. Not used by any core primitive.
eventType string no Domain event type a trigger claims. Not used by any core primitive.
example Record<string, unknown> no Example trigger payload for the console. Not used by any core primitive.
inputs PortDef[] yes Input ports — a display template only if dynamic is set (see below).
outputs PortDef[] yes Output ports — same caveat.
config ConfigFieldDef[] no Static configuration fields.
dynamic "break" | "make" | "convert" | "switch" | "map" | "guard" | "format" no If set, the node's real ports are derived from its config by resolveDynamicPorts (dynamic-ports.ts), not read from the inputs/outputs arrays above.

All twelve primitives declare context: []; none sets establishes, eventType, or example (those three fields are meaningful only for kind: "trigger" nodes, contributed by plugins).

label, description, and category are quoted verbatim from catalog-metadata.ts in the tables below — they are authored in French in the source and reproduced as-is; they are display metadata, not part of a node's ports.

Node kind

NodeKind is a closed union of eight execution roles (catalog-metadata.ts header comment; execution methods per Node behavior contract):

kind Role Behavior method Used by a core primitive?
trigger Entry point: establishes context, emits initial outputs. fromEvent No
source Reads an external system through a port (few/no data inputs). run Yes — the five value.* nodes
sink Writes to an external system (data inputs, no output). run No
compute Pure clinical function (typed inputs → typed outputs). run No
gateway Routes/branches a value under a condition. run Yes — the three flow.* nodes
human-task Human-in-the-loop validation, optional SLA. propose No
agent LLM node (model call + tool loop), projected from *.agent.yaml. — (AgentRunnerPort.run) No
transform Type adaptation (break/make/convert); ports derived from config. — (engine-internal) Yes — the four transform.* nodes

Full taxonomy and the branching model built on gateway + absence propagation are covered in Architecture — core & plugins and Branching and absence.

Ports: PortDef

Every input or output in inputs/outputs (declared or dynamically derived) is a PortDef:

Field Type Required Meaning
name string yes Port name, addressed as nodeId.name in connections.
type TypeRef yes Port type — see TypeRef.
required boolean no Default true. An unmet required input skips the node (absence propagation).
description string no Display description / tooltip.
fromConfig boolean no Set when the port was synthesized from an exposed config field; always optional regardless of the field's own requiredness.
flow boolean no Set on the implicit after/done pins only. A flow port carries no data, is managed entirely by the engine, and is never seen by a node behavior.

Config fields: ConfigFieldDef

Every entry in config is a ConfigFieldDef:

Field Type Required Meaning
name string yes Config key.
type TypeRef yes Type of the field's value.
description string no Display description.
typeHint boolean no Design-time hint inferred from wiring elsewhere (e.g. transform.break's type, flow.guard's type/valueType). Not a business parameter: it can never be exposed as a port or wired to, and is hidden from the editor's "Parameters ⇄ ports" panel.

Implicit flow ports: after / done

Two more PortDefs, FLOW_AFTER and FLOW_DONE (dynamic-ports.ts), are added to (almost) every node's resolved port set:

Constant Port Direction type required Meaning
FLOW_AFTER after input any false Multi-wire join (AND): waits for every wired branch to have emitted; skipped if any is absent. Received data is ignored.
FLOW_DONE done output any Emitted once the node has produced (at least one declared output emitted, or a node with no declared outputs at all).

These are added by nodePorts() (below), not present in a node type's own inputs/outputs arrays. All twelve core primitives get both, since none is kind: "trigger" (the one kind exempt from after). Full authoring semantics and sequencing/joining patterns are in Workflow DSL — reserved identifiers and Sequence with after and done.

Resolving a node's actual ports: nodePorts()

nodePorts(meta, config, expose) (dynamic-ports.ts) computes the concrete input/output port list actually available on a node instance, combining four sources in this order:

  1. Base ports. If meta.dynamic is set, resolveDynamicPorts(meta.dynamic, config) (see Dynamic ports below); otherwise meta.inputs/meta.outputs verbatim.
  2. Exposed config-field ports. For each name in expose (the workflow node instance's expose: [...] list), look up that field in meta.config. Skipped if the field doesn't exist, is typeHint: true, or its name collides with an already-taken input name (a declared/dynamic port always wins). Otherwise a port { name, type: field.type, required: false, description: field.description, fromConfig: true } is appended to inputs.
  3. after. Appended to inputs unless meta.kind === "trigger" or a base port is already named after.
  4. done. Appended to outputs unless a port is already named done.

This same function runs both at execution time and, per dynamic-ports.ts's header comment, is replayed identically on the config-plane side (graph.ts) so the editor validates connections against the exact same port set the interpreter uses.

Dynamic ports: the dynamic discriminator

For the dynamic primitives, dynamic names which case of resolveDynamicPorts(kind, config) computes the real ports; the node type's own inputs/outputs entries in CATALOG_META are then only a display template, ignored at resolution time.

A shared helper, asObj(name), turns a config string into a TypeRef: resolveTypeName(name) ?? { kind: "any" } — it resolves name against the object-type registry first (t.obj(name), if isObjectType(name)), else against the fixed primitive-name map, else falls back to any if name is empty, unset, or not (yet) registered. (resolveTypeName, libs/shared/src/domain/agent-spec.ts.)

The type/itemType/resultType/valueType/from/to config fields these resolutions read are all typeHint: true — authored by hand only when writing YAML directly, or filled in automatically by the visual editor as soon as the corresponding data port is wired (see Wire ports and Branch with guards and switch).

dynamic Used by Ports come from
break transform.break Object type's registered fields (objectFields)
make transform.make Same, inverted
format transform.format {variable} occurrences in the config template
convert transform.convert The from/to config fields directly
switch flow.switch The type field + cases[].name
map flow.map The itemType/resultType fields
guard flow.guard The type/valueType fields
webhook trigger.webhook Five fixed outputs + one typed output per fields[] entry (reserved names excluded)
workflow-input trigger.workflow-called One typed output per fields[] entry
workflow-return workflow.return One typed input per fields[] entry
workflow-execute workflow.execute Inputs from fields[], outputs from outputs[] (copied from the target)
agent-inline agent.inline Inputs from inputs[], outputs from outputs[]

The fields[]/outputs[]/inputs[] lists all share one grammar — { name, type?, required? }, type using the resolveTypeName names with the recursive [] list suffix, any when omitted (workflowFieldPorts, dynamic-ports.ts).

Constant-value primitives (value.*)

Five node types, all kind: "source", category "Valeurs constantes", no dynamic, no inputs. Each has exactly one output port and one config field, both named value and both of the same type; per each node's own description string, the node emits the configured value verbatim.

id Label (FR) Output port Config field
value.text Texte (constante) value: String (t.string) value: String
value.number Nombre (constante) value: Decimal (t.decimal) value: Decimal
value.integer Entier (constante) value: Integer (t.integer) value: Integer
value.boolean Booléen (constante) value: Boolean (t.boolean) value: Boolean
value.datetime Date/heure (constante) value: DateTime (t.dateTime) value: DateTime

Note the id/type mismatch on value.number: its TypeRef is t.decimal (primitive name Decimal), not a Number-named primitive.

Plus the implicit after input and done output on all five (see Implicit flow ports).

Transform primitives (transform.*)

Four node types, all kind: "transform", category "Transformations", each with dynamic set — so the ports below (beyond after/done) are the resolved ports, not the static template in CATALOG_META.

transform.break

Label: Éclater (break). Decomposes an object into its fields, one output port per field.

config type: String (typeHint: true) — name of the object type to explode.
Resolved inputs value: <type>, required: true.
Resolved outputs One port per field of <type> (name = field name, type = the field's own TypeRef), from objectFields(typeName) (libs/shared/src/engine/type-system.ts).

If type is empty or does not resolve to a registered object type, the outputs list is empty (objectFieldsSafe swallows the lookup failure and returns {}) — no error, just no output ports.

transform.make

Label: Composer (make). The inverse of break: composes an object from its fields, one input port per field.

config type: String (typeHint: true) — name of the object type to compose.
Resolved inputs One port per field of <type> (name = field name, type/required = the field's own descriptor).
Resolved outputs value: <type>.

Same empty-type fallback as break: an unresolved type yields zero input ports.

transform.format

Label: Format (gabarit). Builds a string from a {variable} template — one input port per distinct variable.

config template: String — a string containing {name} placeholders.
Resolved inputs One port per variable name matched by /\{([a-zA-Z_][\w-]*)\}/g in template, in first-appearance order, deduplicated (templateVariables()). Each is type: any, required: true.
Resolved outputs value: String — the formatted string.

template itself is not a typeHint field, so it follows the ordinary params⇄ports rule (any config field not marked typeHint can be wired via expose/a targeted connection) — but per resolveDynamicPorts, the derived input-port set is always computed from the static config.template, even when template itself is wired: a dynamic template can only fill variables the static template already declared as ports. See Wire ports — build a string with transform.format.

transform.convert

Label: Convertir (From/Into). Converts a value through a registered conversion.

config from: String, to: String (both typeHint: true) — source and target type names.
Resolved inputs value: <from>, required: true.
Resolved outputs value: <to>.

The concrete conversion function is looked up at run time by findConversion(from, to) against the open registry in libs/shared/src/engine/conversions.ts (CONVERSIONS array + registerConversion); a handful of conversions ship pre-registered there today (e.g. EgfrResult → Decimal, DrugSafetyReport → Integer, Prescription → Annotation). Port typing here only reflects the from/to names configured on the node — it does not itself confirm a matching conversion is registered.

Flow-control primitives (flow.*)

Three node types, all kind: "gateway", category "Décision & flux", each with dynamic set.

flow.switch

Label: Switch / case. Routes to the first case whose condition holds, otherwise default (covers if/else).

config type: String (typeHint: true) — type of the routed value; cases (not a typeHint) — a list of { name, condition }, condition a JEXL expression over value.
Resolved inputs value: <type>, required: true.
Resolved outputs One port per cases[].name (via caseNames(), which accepts either a bare string or {name, ...} per entry and drops anything without a non-empty string name), each typed <type>, plus a fixed default: <type> port, "Émis si aucun cas ne correspond." (emitted when no case matches).

flow.map

Label: Map (for-each). Iterates a list: item feeds a sub-pipeline, result returns (back-edge), collected gathers the results, skipped the elements whose iteration produced none.

config itemType, resultType (both typeHint: true, String) — element type and per-element result type.
Resolved inputs items: List<itemType>, required: true, "Collection à parcourir." · result: resultType, required: true, "Résultat par élément (retour du corps de boucle)."
Resolved outputs item: itemType, "Élément courant (vers le corps de boucle)." · collected: List<resultType>, the results only (no holes). · skipped: List<itemType>, the elements left without a result — emitted only when non-empty.

Loop-body structural constraints (no cycles other than the result back-edge, no nested maps, a node cannot belong to two loop bodies, result's source must be inside the body, item cannot be consumed outside it) are enforced by static validation, not by this metadata — see Loop with map and Static validation engine.

flow.guard

Label: Garde conditionnelle. Tests criteria; if the condition holds, emits on pass — the optional value input if fed, otherwise criteria itself. Otherwise the branch stops (no output emitted at all).

config type, valueType (both typeHint: true, String) — types of criteria and value; condition (not a typeHint, String) — a JEXL expression over criteria.
Resolved inputs criteria: type, required: true, "Donnée testée par la condition." · value: valueType, required: false, "Optionnel : renvoyé sur « pass » s'il est alimenté (sinon « criteria »)."
Resolved outputs pass, typed valueType if config.valueType is a non-empty string, else type — "« value » si fourni, sinon « criteria » — si la condition est vraie."

Entry-point, sub-workflow and agent primitives (trigger.*, workflow.*, agent.*)

Six primitives beyond the dataflow ones, each covered by a dedicated recipe:

  • trigger.webhook — inbound HTTP entry point (/api/hooks/<uuid>/<name>): fixed outputs method/headers/params/query/body plus one typed output per declared body field; config carries the url slug, accepted methods, auth (none/basic/header via a credential), response mode (sync/async + timeoutSeconds). See Trigger a workflow with a webhook.
  • trigger.workflow-called — entry point of a callable workflow: one typed output per declared field, fed by the calling workflow; ambient context inherited from the caller.
  • workflow.return — kind gateway; one typed input per declared field. What it receives is what the workflow answers — to a parent workflow AND to a synchronous webhook caller. Its echoed values are not declared output ports (nothing can wire from them).
  • workflow.execute — kind gateway; executes another workflow inline in the same durable run (child spec frozen in the journal, namespaced steps, recursion refused, depth ≤ 8, forbidden in a flow.map body). See Call a sub-workflow.
  • agent.dynamic — runs a REGISTERED agent picked at runtime: inputs agent (String — name or nodeTypeId, wireable) and inputs (Any record), output result (Any — the shape is only known at runtime).
  • agent.inline — a complete agent DEFINED in the node's config (provider, model, prompt, tools, maxIterations, typed inputs[]/ outputs[] from which the ports derive); no prior registration. Both agent primitives execute through the same runner as registered agent nodes. See Use an agent in a workflow.

Extending the catalogue

registerNodeMeta(meta) is the only way anything besides these eighteen primitives enters CATALOG_META. Writing a new node type (declaring its NodeTypeMeta and implementing its behavior) is covered in Implement a node behavior and Plugin manifest, not here — this page is scoped to the eighteen built-ins.

  • Workflow DSL — the YAML grammar that instantiates and wires these node types (nodes, connections, expose, inputs, reserved trigger/after/done identifiers).
  • TypeRef — the type grammar carried on every PortDef/ConfigFieldDef.
  • JEXL — the expression language for flow.guard's condition and flow.switch's cases[].condition.
  • Wire ports, splitting a port by field, and the params⇄ports mechanics behind expose/fromConfig/inputs.
  • Branch with guards and switch and Loop with map — task-oriented recipes for flow.guard/flow.switch and flow.map.
  • Sequence with after and done — using the implicit flow ports to order or join branches.
  • The dataflow model and Branching and absence — the concepts behind ports, wiring, and absence propagation.
  • Node behavior contract — how plugin-contributed node types (everything not on this page) implement run/propose/fromEvent.
  • Architecture — core & plugins — where these primitives sit in the wider core/plugin split.
  • Glossary — node, port, kind, after/done, absence propagation.
75 documents21 sectionssource : /docs · généré au build