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:
receivesis present if and only ifkindistrigger;- a
pinnedtrigger has a non-emptyeventType; receivesits only on apinnedtrigger;- a
dedicatedorconfigurabletrigger has neitherdynamicnorderivedPorts; - the
fieldof aconfigurabletrigger names a field of itsconfigmarkedtypeHint: 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:
- Base ports. The port rules of a plugin node when
meta.derivedPortsis declared; for aconfigurabletrigger, 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)whenmeta.dynamicis set (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" }. 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.nullis 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.
afterstays an AND join, so an absentafterbranch skips it (node-skipped-after); - the order is positional, not temporal: the topological order runs all the
upstream first. When two inputs are present,
value1wins overvalue2, and so on; - with no present input, the node emits neither
valuenordone: a node whose required input is fed byvalueis skipped (node-skipped-missing-input) and afusion.done → x.afterchain is blocked. The observation isdonewith no output, as for a false guard; - the emitted value goes through the output check of
<type>like any output (WorkflowDataErroron a mismatch,nullincluded). 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 vocabularyworkflow|subject|workflow-global, defaultworkflow):workflowkeeps the item private to the workflow that writes it (its slug, across all its versions), for the run's subject;subjectshares it with every workflow of the same subject;workflow-globalkeeps 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'scontext; static validation adds it to the node's required context. It is refused withworkflow-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: workflowandworkflow-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
TypeRefand 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:
- The stored type is assignable to the type read (
isAssignable— the same type, or a widening accepted by connections, such asIntegerread asDecimal): the value is returned as stored. - 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;convertedFromnames the stored type. - Otherwise the read fails: a
Decimalnever reads asInteger, 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 scopeworkflow-globalonly (see Memory primitives). See Schedule and wait.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.trigger.event-received— starts the workflow on the declared event chosen in its configuration; see Declared event trigger.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.
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.
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.switch/flow.mergeandflow.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.