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 twenty-six node types: five value.* constants, four transform.* type adapters, five flow.* gateways (switch, map, wait, guard, merge), four trigger.* entry points (webhook, schedule, called-by-a-workflow, declared event received), two workflow.* sub-workflow nodes (execute, return), two agent.* nodes (runtime-picked, defined-in-the-node), three memory.* nodes (write, read, forget) and context.read. 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, or a descriptor that breaks the trigger invariants. 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 twenty-six 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, or, for trigger.event-received, the declarative projection of its event that makeCatalog attaches from its family. 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).
receives TriggerReceives trigger only What a trigger receives: its family and, depending on the family, its address, its declared event, its context and its example. Present if and only if kind is trigger. See What a trigger receives.
receive { protocol, label, transports } no Public metadata for a pinned trigger supplied by an inbound protocol plugin: the protocol id, its label and the names of its own transports (their description, default port and hint, is served by GET /api/connections/protocols). The console uses it to filter EAI connections.
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 DynamicKind 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. Plugin nodes with config-derived ports declare them with derivedPorts instead (plugin manifest).

Every core primitive declares context: [] — a memory node's subject kind is added from its config, see Memory primitives. Only the four trigger.* primitives set receives: trigger.webhook, trigger.schedule and trigger.workflow-called in the pinned family, trigger.event-received in the configurable family.

What a trigger receives

TriggerReceives is a union discriminated by kind:

kind Fields Meaning
pinned eventType, establishes, example? Own address: eventType is the address. An event of this type goes to one designated workflow, never to every workflow of the type. Without __workflowFile, an event of a type claimed only by such triggers is refused, and the run id is the eventId. The core webhook, schedule and sub-workflow triggers and the plugin inbound connections (pinnedRouting: true in a manifest) are pinned.
dedicated event The declared event is fixed by the node type (event: in its plugin's manifest). The event's contract gives the type received, the context established (all its kinds, required and optional, in declaration order), the example and the default filter (see Choose which events start a workflow).
configurable field The declared event is the value of the instance's configuration field field (config.event for trigger.event-received), a non-empty string taken as written. The facts are those of a dedicated trigger on that event; the ports are the event's fields, in declaration order, then done. Every declared event is claimed by a configurable trigger. Only the core catalogue has this family.

establishes is the context the trigger establishes; "*" establishes any registered kind whose identifier the event carries (webhook, workflow call). A manifest that declares no establishes on a pinned trigger gets []. A plugin trigger without pinnedRouting is always dedicated: the other forms are refused when the plugin loads. example is the example trigger payload for the console (composer, filter check, simulation), completed into the example event.

The descriptor holds no copy of these facts. Every reader resolves them for one trigger instance (node type and spec.trigger.config) through triggerFacts (libs/shared/src/domain/trigger-facts.ts): family, type received, role of the selector (route, filter or ignored), event contract, context established, example, default filter, and a problem (trigger-unknown for a missing descriptor, event-absent for a configurable trigger whose configuration carries no event — absent, empty or not a string —, event-unknown for a declared event missing from the registry, the configured value kept as the type received). Static validation turns each problem into one error (triggerProblemErrors). The same module computes the routing key of a workflow (triggerRoute), the triggers that can receive a type (eventClaims: a configurable trigger claims every declared event, with the configuration that makes it receive it), whether a type is routed to every matching workflow (eventAdmission: routable, addressed or unclaimed), and the console identity of an instance (triggerKey: the type, followed by #<event> for a configurable trigger).

registerNodeMeta refuses a descriptor for which triggerMetaIssues reports a problem:

  • receives is present if and only if kind is trigger;
  • a pinned trigger has a non-empty eventType;
  • receive sits only on a pinned trigger;
  • a dedicated or configurable trigger has neither dynamic nor derivedPorts;
  • the field of a configurable trigger names a field of its config marked typeHint: true: it defines the ports, so it is neither exposable nor wireable.

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 and memory.read
sink Writes to an external system (data inputs, no output). run Yes — memory.write and memory.forget
compute Pure function (typed inputs → typed outputs). run No
gateway Routes/branches a value under a condition. run Yes — the five flow.* nodes and the two workflow.* 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. Every core primitive gets both, except the three trigger.* nodes, which only get done (kind: "trigger" is 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. The port rules of a plugin node when meta.derivedPorts is declared; for a configurable trigger, no input and one output per field of the chosen event's contract, in declaration order (none when the event is absent or unknown); resolveDynamicPorts(meta.dynamic, config) when meta.dynamic is set (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" }. resolveTypeName (libs/shared/src/domain/agent-spec.ts) matches the exact case, in this order: the name of a registered object type (t.obj(name), if isObjectType(name)), then a primitive name (all eleven PrimitiveName values), with the [] suffix for a list. The core kinds that have no name — quantity, coded, ref, any — are written as a TypeRef object instead (type: { kind: quantity }). So with @posos/common loaded, Quantity names that plugin's object type, never the core quantity kind, and quantity names nothing.

An empty or unset hint resolves to any. A non-empty name that does not resolve is refused by validateSpec (typeHintErrors, libs/shared/src/engine/dynamic-ports.ts), with the names that match ignoring case, then the core kind of that name as a TypeRef:

Nœud « b » : type « quantity » inconnu dans « type ». Écrire l'un de : « Quantity » (@posos/common), { kind: quantity } (type du cœur).

DYNAMIC_TYPE_HINTS lists the checked fields: type (break, make, switch, guard, merge), valueType (guard, wait), from/to (convert), itemType/resultType (map). Memory nodes check their type the same way (memoryConfigErrors).

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
merge flow.merge The count field (inputs value1…value<count>) and the type field
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[]
memory-write memory.write value: <type> input, from the declared type field; version output; expectedVersion input and conflict output when conditional is set
memory-read memory.read value: <type>, writtenAt: DateTime, version: Integer, missing: Boolean and convertedFrom: String outputs
memory-forget memory.forget No data port

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.

value.datetime emits nothing when its value is empty or missing, so the nodes that read it are skipped. A non-empty value must follow the DateTime format (Date and DateTime formats), for example 2026-06-11T08:30:00+02:00 or 2026-06-11; any other string fails the run with a WorkflowDataError on that node.

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, the outputs list is empty, without an error. A name that does not resolve is refused, with the object types of the same name ignoring case (see Dynamic ports); only types with fields are proposed. A type without fields (a primitive, or a core kind such as { kind: quantity }) is refused too: type Quantity (type du cœur) sans champs à éclater. Choisir un type structuré. A core quantity value is compared on its fields in a condition instead (criteria.value >= 6.0 on a flow.guard whose criteria receives the measure).

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 rules as break: an empty type yields zero input ports, an unknown name or a type without fields is refused.

transform.format

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

config template: String — a template: {name}, `{name
Resolved inputs One port per input variable of template (the root of each placeholder's path, context excepted), in first-appearance order, deduplicated (templateVariables()). Each is type: any, required: true.
Resolved outputs value: String — the formatted string; nothing when a placeholder resolves to nothing.

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). The registry starts empty and is filled from plugin manifests (contributes.conversions, a JEXL expression per conversion), e.g. Prescription → Annotation from @posos/clinical. 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.*)

Five node types, all kind: "gateway", category "Décision & flux", each with dynamic set. flow.wait suspends the run and is documented with its modes in Schedule and wait; the four others follow.

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. Static validation requires each case to have a unique name of letters, digits, _ and -, other than default and done, and a condition that compiles (JEXL — Static validation).
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, required by static validation unless wired.
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."

flow.merge

Label: Fusion de branches. Joins exclusive branches (an OR join): emits on value the first present input, in the order value1, value2…, and nothing when no input is present.

config count (typeHint: true, Integer) — number of inputs, from 2 to 8, 2 when absent; type (typeHint: true, a type name of the hint grammar or a structured TypeRef) — type of the inputs and of the output, required. Both are structural: neither can be wired or exposed.
Resolved inputs value1 … value<count>, each typed <type>, required: false. resolveDynamicPorts bounds count to [2, 8] and takes 2 when it is absent or not an integer, so the ports stay computable on an invalid config.
Resolved outputs value: <type>, "Première entrée présente, dans l'ordre des entrées."

An input is present when it resolves to a value other than undefined:

  • a wired input is present when its source ran (done) and emitted the port; a skipped source, or one that did not emit, leaves it absent. null is a present value;
  • an input composed field by field (fusion.value1.code) is present when at least one of its fields is;
  • an unwired input carrying an inputs: constant is always present.

Execution:

  • an absent input never skips the node: all inputs are optional. after stays an AND join, so an absent after branch skips it (node-skipped-after);
  • the order is positional, not temporal: the topological order runs all the upstream first. When two inputs are present, value1 wins over value2, and so on;
  • with no present input, the node emits neither value nor done: a node whose required input is fed by value is skipped (node-skipped-missing-input) and a fusion.done → x.after chain is blocked. The observation is done with no output, as for a false guard;
  • the emitted value goes through the output check of <type> like any output (WorkflowDataError on a mismatch, null included). There is no dedicated trace code; the observation shows the present inputs and the value kept. A simulation runs the same behavior.

Inside a flow.map body (the nodes between item and the source of result), a merge runs once per iteration and can join a value derived from item with a value computed before the loop, and feed result. After the loop, a body node is only done with no outputs in the outer pool, so a merge outside the body never sees its value: static validation refuses that wiring (M5 below).

Static validation (validateSpec):

Rule Condition Message
M1 count present and not an integer from 2 to 8 Nœud « {id} » : « count » doit être un entier entre 2 et 8.
M2 fewer than two inputs fed (a direct or field connection, or an inputs: constant) Nœud « {id} » : moins de deux entrées alimentées. Câbler au moins deux entrées.
M3 type absent or empty; each connection to value<k> is then type-checked like any connection Nœud « {id} » : « type » manquant. Préciser le type des valeurs réunies.
M4 an unwired value<k> carries an inputs: constant and a later input is fed Nœud « {id} » : « value{k} » a une valeur fixée, les entrées suivantes ne sont jamais lues. Fixer la valeur sur la dernière entrée alimentée.
M5 a connection to value<k> comes from the body of a map m and the merge is outside that body Nœud « {id} » : « value{k} » est alimentée par « {src} », du corps du map « {m} », et le nœud est hors de ce corps. Relier le nœud à « {m}.result » ou retirer la connexion.

The visual editor sets type from the first input wired when it is absent, and never replaces it: a named type as its name, a core kind without a name (quantity, coded, reference) as a TypeRef. A source typed any, such as a done pin or a webhook body, sets nothing. The Value type field of the inspector sets or changes type; Boolean joins done pins, which carry true. Beyond eight inputs, two merges are chained. Before publishing a workflow that uses flow.merge, every engine replica must run a version that knows it (Upgrade the platform).

Memory primitives (memory.*)

Three node types, category "Mémoire", each with dynamic set. They read and write the workflow memory: durable, typed values attached to a subject of the run (an entity of a context kind, such as a patient) or to the workflow for all its subjects, kept from one run to the next. The recipe is Remember values between runs.

An item is addressed by four things:

  • scope (typeHint: true, closed vocabulary workflow | subject | workflow-global, default workflow): workflow keeps the item private to the workflow that writes it (its slug, across all its versions), for the run's subject; subject shares it with every workflow of the same subject; workflow-global keeps it private to the workflow and shares it across all subjects — it has no subject.
  • subject (typeHint: true): the context kind of the subject, e.g. patient, declared by @posos/common. The subject's id is read from the run's ambient context. The kind must be in the workflow's context; static validation adds it to the node's required context. It is refused with workflow-global, which requires no context.
  • key (String, max 200 characters): exposable as a port, so a key can be computed per run or per loop item.
  • The workflow itself, for scope: workflow and workflow-global: the engine supplies it, a node cannot address another workflow's private memory.

Each item also carries:

  • its type, stored explicitly with the value — as a TypeRef and as a readable name (typeLabel: Decimal, List<String>);
  • a version: an integer greater than 0, new at every write and never reused for the address, even after the item is forgotten or expired;
  • an expiry: every write sets one, 30 days by default.

scope, subject, type, ttlUnit and conditional are structural: they cannot be exposed or wired, because static validation must know them.

memory.write

Label: Mémoriser une valeur. kind: "sink".

config scope, subject, key, type (typeHint: true, a type name of the hint grammar — Decimal, String[], a registered structured type — or a structured TypeRef; Any is refused), ttl (Decimal) and ttlUnit (seconds, minutes, hours or days) — both or neither, neither meaning 30 days, 100 years at most — and conditional (Boolean, default false).
Resolved inputs value: <type>, required: true · with conditional: true, expectedVersion: Integer, required: true.
Resolved outputs version: Integer, the version of the value written · with conditional: true, conflict: Boolean, emitted only when nothing was written.

The value is checked against type before it is stored (validateValue) and must serialize to at most 256 KiB of JSON; a mismatch is a WorkflowDataError naming the item. Writing replaces the previous value of the same address.

A conditional write is an optimistic lock: it writes only if the version currently in memory equals expectedVersion — the version of a memory.read, or 0 for "only if nothing is stored" (an expired item counts as nothing). Otherwise nothing is written and conflict is emitted. The check and the write are one atomic operation in the store. expectedVersion is a required input: if the branch that feeds it produces nothing, the node is skipped rather than writing without the lock.

memory.read

Label: Lire une valeur mémorisée. kind: "source".

config scope, subject, key, type (same rules as memory.write).
Resolved inputs None besides after.
Resolved outputs value: <type> and writtenAt: DateTime, emitted when an unexpired item exists · version: Integer, always emitted, 0 when there is no item · missing: Boolean, emitted only when there is none · convertedFrom: String, emitted only when the value was converted, naming the stored type.

The stored item is read in three cases:

  1. The stored type is assignable to the type read (isAssignable — the same type, or a widening accepted by connections, such as Integer read as Decimal): the value is returned as stored.
  2. Otherwise, a conversion registered from the stored type to the type read (the registry used by transform.convert, contributed by plugins) is applied, element by element between two lists; convertedFrom names the stored type.
  3. Otherwise the read fails: a Decimal never reads as Integer, even when today's value is whole.

The returned value must conform to the type read. A failure — no conversion, a conversion that throws, a non-conforming value — is a WorkflowDataError, never a silent missing.

memory.forget

Label: Oublier une valeur mémorisée. kind: "sink".

config scope, subject, key.
Resolved inputs None besides after.
Resolved outputs None besides done.

Context primitive (context.read)

Label: Lire le contexte. kind: "source", category "Contexte", dynamic: "context-read". The run's context, one entity per context kind, is established by the trigger and read implicitly by the nodes that require it; this node turns it into data that can be wired.

config kind (typeHint: true) — the context kind to read, e.g. patient, declared by @posos/common. It must be a registered kind, and it is added to the node's required context.
Resolved inputs None besides after.
Resolved outputs id: String — the entity id; ref: <Entity>Ref ({ id }) when the kind declares an entity; missing: Boolean, emitted alone when the run has no such context.

A template reads the same data inline, {context.<kind>.id} (see Templates in text fields).

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

Eight primitives beyond the dataflow ones, each covered by a dedicated recipe or section:

  • trigger.schedule — starts the workflow from rules (intervals or cron); establishes no context, so a scheduled workflow has no subject: it can use memory in scope workflow-global only (see Memory primitives). See Schedule and wait.
  • 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.
  • trigger.event-received — starts the workflow on the declared event chosen in its configuration; see Declared event trigger.
  • 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.

Declared event trigger (trigger.event-received)

Label: Événement reçu. kind: "trigger", category "Déclencheurs", receives: { kind: "configurable", field: "event" }. It starts the workflow on the declared event chosen in its configuration, among every event the loaded plugins declare, and is routed by fan-out like a plugin's dedicated trigger: one event starts the workflows of both.

config event (String, typeHint: true) — the event type, as declared. Not required: an absent or unknown event is reported by one static check, on the trigger's facts (« Déclencheur : aucun événement choisi. Choisir un événement déclaré. », « Déclencheur : événement « X » non déclaré par les plugins chargés. Choisir un événement déclaré ou charger le plugin qui le déclare. »).
Context established Every context kind of the event, required and optional. The identifiers are not outputs: read them with context.read.
Resolved inputs None.
Resolved outputs One port per field of the event, in declaration order, typed and described by the contract; then done. done alone without a known event.
fromEvent The declarative projection of the event (declaredEventBehavior, the one of a declarative dedicated trigger without declared outputs), the contract read in the registry at each call. An event no loaded plugin declares throws a DeploymentError: a new run fails; a run in flight is held at its next resumption until the event is declared again (see Declare an event).

A plugin that renames or types the outputs of its event (a reference, a structured object) contributes a dedicated trigger for it; when one exists, prefer it.

Extending the catalogue

registerNodeMeta(meta) is the only way anything besides these twenty-six 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 twenty-six 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/flow.merge 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.
97 documents29 sectionssource : /docs · généré au build