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:
- Base ports. If
meta.dynamicis set,resolveDynamicPorts(meta.dynamic, config)(see Dynamic ports below); otherwisemeta.inputs/meta.outputsverbatim. - Exposed config-field ports. For each name in
expose(the workflow node instance'sexpose: [...]list), look up that field inmeta.config. Skipped if the field doesn't exist, istypeHint: 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. after. Appended to inputs unlessmeta.kind === "trigger"or a base port is already namedafter.done. Appended to outputs unless a port is already nameddone.
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 outputsmethod/headers/params/query/bodyplus one typed output per declared body field; config carries the url slug, accepted methods, auth (none/basic/headervia 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— kindgateway; 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— kindgateway; executes another workflow inline in the same durable run (child spec frozen in the journal, namespaced steps, recursion refused, depth ≤ 8, forbidden in aflow.mapbody). See Call a sub-workflow.agent.dynamic— runs a REGISTERED agent picked at runtime: inputsagent(String— name or nodeTypeId, wireable) andinputs(Anyrecord), outputresult(Any— the shape is only known at runtime).agent.inline— a complete agent DEFINED in the node's config (provider, model, prompt, tools,maxIterations, typedinputs[]/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.
Related
- Workflow DSL — the YAML grammar that instantiates and
wires these node types (
nodes,connections,expose,inputs, reservedtrigger/after/doneidentifiers). - TypeRef — the type grammar carried
on every
PortDef/ConfigFieldDef. - JEXL — the expression language for
flow.guard'sconditionandflow.switch'scases[].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.switchandflow.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.