Workflow DSL (meridian/v3)
This is the complete grammar of a Meridian workflow document. A workflow is a
single declarative YAML file — the sole source of truth for a pipeline,
versioned in git and executed by the generic interpreter. The grammar is defined
by the Zod schema WorkflowSpec in
libs/shared/src/domain/workflow-spec.ts;
this page mirrors that schema field by field.
Documents live in the definition store, seeded from *.workflow.yaml files in
git (content/clinical-demo/workflows/ for the shipped demos,
apps/api/workflows/ for the host's system definitions).
For the why behind the dataflow model, see The dataflow model. For task-oriented authoring, see the how-to guides.
Top-level document
The document root is an object with the following fields: six are required,
description and labels are optional. WorkflowSpec is a plain z.object
(not .strict()), so parsing drops any other top-level key instead of
rejecting it.
| Field | Type | Required | Description |
|---|---|---|---|
apiVersion |
string literal "meridian/v3" |
yes | Grammar version discriminator. |
name |
string | yes | Human-readable workflow name. |
description |
string | no | Free-text description of the workflow. See Organisation metadata. |
labels |
map of string | no | Key/value labels for grouping and filtering in the console. See Organisation metadata. |
context |
array of string | yes | Ambient context kinds established by the trigger (open, plugin-contributed). |
trigger |
object | one of two forms | One entry node (reserved id trigger), exclusive with triggers. |
triggers |
map of trigger instances | one of two forms | At least two entries, keyed by stable id, exclusive with trigger. |
nodes |
map of node instances | yes | Node instances keyed by id. |
connections |
array of connections | yes | Port-to-port dataflow edges. |
apiVersion: meridian/v3
name: Réception résultat biologie
context: [patient, encounter]
trigger:
type: trigger.bio-result-received
description: Un résultat de biologie est reçu.
nodes:
load_demographics: { type: patient.load-demographics }
guard_creatinine:
type: flow.guard
config: { condition: 'criteria.code.code == "creatinine"' }
compute_dfg: { type: clinical.egfr.ckd-epi-2021 }
connections:
- { from: trigger.result, to: guard_creatinine.criteria }
- { from: guard_creatinine.pass, to: compute_dfg.result }
- { from: load_demographics.demographics, to: compute_dfg.demographics }
apiVersion
Zod: z.literal("meridian/v3").
Must be the exact string meridian/v3. Any other value fails schema
validation. This field discriminates the grammar version.
name
Zod: z.string().
Free-form human-readable name. No format constraint beyond being a string.
context
Zod: z.array(z.string().min(1)).
The ambient variables the trigger establishes and that nodes may read. The
vocabulary is open: each element must be a context kind contributed by a
loaded plugin (contributes.contextKinds — @posos/common contributes
patient, encounter, order, document); the array may contain any subset
(including empty) and any order.
Static validation (validateSpec) checks that every element is a registered
kind (an unknown kind is an error naming the known ones), that the trigger
establishes each one (a trigger declaring establishes: "*" — webhook,
workflow-called — establishes any registered kind opportunistically), and that
context covers what the workflow's nodes require.
A trigger on a declared event establishes every context kind of the event,
required and optional: a workflow on trigger.medication-prescribed can list
encounter, which MedicationPrescribed declares optional. At run time a kind
is present only when the event carries its identifier (encounterId for
encounter); a run without it has no encounter context. See
Events and triggers — context kinds.
triggers
An ordered map of two or more instances with the same fields as trigger below. Ids match [A-Za-z_][\w-]*; __proto__, prototype and constructor are forbidden. Trigger ids and ordinary node ids are disjoint. Connections use <entryId>.<port>. Exactly one entry is selected per run; an entry always emits done when fired, even when its optional data is absent.
Combination and branch rules, editing and examples: Start a workflow from several events. trigger remains the canonical form for a single entry; existing specs require no migration.
trigger
Zod: z.object({ type: z.string(), description: z.string().optional(), selector: z.string().optional(), config: z.record(z.unknown()).optional(), position: NodePosition.optional() }),
where NodePosition is z.object({ x: z.number(), y: z.number() }).
The entry node of the workflow. It establishes the ambient context and emits
the initial outputs consumed by downstream connections.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Id of a node type of kind trigger in the catalogue. |
description |
string | no | Optional human-readable description. |
selector |
string | no | JEXL filter evaluated on the event payload, bound to event: true, the workflow starts on the event; false or failing, it does not. Without a selector, the workflow receives every event of its type. A filter restricts its own workflow only: every other published workflow that receives the type still starts. An empty selector, or one that reads no event field, is refused by static validation; an empty or blank selector that reaches the active set without it (seed, definitions import) counts as a failing filter: the workflow receives nothing. See JEXL expressions and Choose which events start a workflow. |
config |
object (z.record(z.unknown())) |
no | Configuration of the trigger. Its keys are defined by the trigger type, for example webhookId, slug and auth for trigger.webhook, fields for trigger.workflow-called, connectionId for an EAI trigger, rules and timezone for trigger.schedule, event for trigger.event-received. |
position |
object (NodePosition) |
no | Position of the trigger on the editor canvas. Ignored by the engine. |
The trigger instance is addressed in connections by the reserved node id
trigger (see Reserved identifiers) — e.g.
trigger.result.
The trigger instance receives one event type: the declared event chosen in
config.event for Événement reçu (trigger.event-received), a declared event its
plugin references with event: for a dedicated trigger (the trigger inspector shows
its contract under Declared event), or, for a trigger with its own address, the
address it names with eventType. config.event is a string, the event type as
declared (no spaces removed); an absent, empty or non-string value, or a type no
loaded plugin declares, is a static validation error:
trigger:
type: trigger.event-received
config: { event: PatientTransferred }
selector: event.unit == "4050"
POST /api/events refuses an event whose type no loaded trigger claims (a type that
is neither declared nor an address: every declared event is claimed by
trigger.event-received), a type that no published workflow receives, and a type
claimed only by triggers with their own address (webhook, schedule, sub-workflow
call, EAI reception) sent without __workflowFile. See
Event ingestion API and
Events and triggers; the plugin side is in
The open event model.
nodes
Zod: z.record(WorkflowNodeInstance).
A map from node id to a node instance. Keys are the node ids used on both sides of connection port references. Each value conforms to the node-instance schema below.
Node instance
Zod: WorkflowNodeInstance.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Id of a node type from the catalogue. |
config |
object (z.record(z.unknown())) |
no | Static configuration for the node type. |
position |
object (NodePosition) |
no | Position of the node on the editor canvas. Ignored by the engine. |
inputs |
object (z.record(z.unknown())) |
no | Inline constants for input ports (port → parameter). |
expose |
array of string | no | Config field names shown as input ports in the editor (parameter → port). |
connection |
string | no | Connection the node uses, for a node type that requires one. Absent: the default connection of the type, or the port bound by the instance. |
nodes:
load_demographics: { type: patient.load-demographics }
guard_creatinine:
type: flow.guard
config: { condition: 'criteria.code.code == "creatinine"' }
type
Zod: z.string().
The id of a node type in the catalogue (core or a loaded plugin). Static validation checks the type exists. Node types are documented per plugin; see the behavior contract and core primitives.
config
Zod: z.record(z.unknown()).optional().
Static configuration consumed by the node type. Its shape is defined by the node
type, not by this schema. Config fields may be overridden at runtime by a wired
connection targeting that field — see expose below and
Connections. Text fields are templates.
Templates in text fields
A text config field of a node (String, Code or Uri) is a template:
{name} inserts the value of the node's input name, and
{context.<kind>.id} the run's context. When the node has no input of that
name, the variable becomes a required input port, wired like any other. A
variable can go through transforms, as in conditions: {url|trim},
{nom|default("—")} — see JEXL — transforms.
alerte:
type: notify.send
config:
channel: "garde-{service|lower}" # `service` becomes an input of the node
connections:
- { from: trigger.service, to: alerte.service }
Rules:
- Between braces: a path (
nom,obs.value,context.<kind>.id) followed by transforms whose arguments are written as literals. No computation, no condition, no function call. {{and}}write a brace. A{followed by a quote or a digit stays text, so JSON with quoted keys reads as is.- A variable whose input is absent, or that resolves to nothing
(
{context.sejour.id}on a run without a stay), skips the node — trace codenode-skipped-missing-inputornode-skipped-missing-variable. - The value is inserted as text: a quantity as
3.2 mg, a list joined with,, an object as JSON. An inserted value is never read again as a template. - A value wired onto the field (
expose) replaces the template and is not rendered. - A context kind read by a template must be in the workflow's
context. - Not templates: structural fields (
typeHint), closed vocabularies (options), a trigger's configuration, and four core fields —flow.guard.condition(a JEXL expression, which already reads the inputs and the context),transform.format.template(rendered by the node itself),workflow.execute.workflow(resolved before the run) andflow.wait.credentialId. - Static validation checks the syntax, the allowed constructs and the
transform names, and reports
Nœud « id » : « champ » : …. Syntax errors and unknown transforms read as in conditions (« {a ET b} » : « ET » inattendu. Opérateurs logiques : && et ||.); see JEXL — Static validation.
inputs
Zod: z.record(z.unknown()).optional().
Inline constants for input ports, in the "port → parameter" direction. A key is
an input port name; the value is a constant that the port takes when it is not
wired. This removes the need for a dedicated value.* node to supply a
constant.
nodes:
dbl: { type: clinical.double, inputs: { value: 21 } } # required input satisfied inline
Rules (enforced by static validation):
- The inline value is validated statically against the port's type.
- A port that is both wired and given an inline value is a validation error.
- For a wired port whose upstream branch is absent, absence propagation governs — there is no fallback to the inline constant (the wire is authoritative).
expose
Zod: z.array(z.string()).optional().
A list of config field names to display as input ports in the visual editor,
in the "parameter → port" direction. This is a UX hint only: whether a field is
listed here or not, any connection … → node.<configField> is valid and
overrides the config at runtime (falling back to the static config value if
the upstream branch is absent).
nodes:
build_cond: { type: transform.format, config: { template: "criteria > {seuil}" }, inputs: { seuil: 90 } }
garde: { type: flow.guard, config: { condition: "criteria > 1000" }, expose: [condition] }
connections:
- { from: trigger.value, to: garde.criteria }
- { from: build_cond.value, to: garde.condition } # wired config field
connection
Zod: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/).optional().
The identifier of a connection declared in the instance manifest or created in the
console, for a node type whose plugin declares connection (see
Plugin manifest — Connection types).
A type that requires a connection type accepts a connection of that type; a type that
requires a port (connection: { port }) accepts any connection whose type an adapter of
the port requires. Without connection, the node uses the default connection of the type,
or the port bound by the instance.
nodes:
alerte_garde: { type: notify.send, connection: slack-pharma, config: { severity: critical } }
validateSpec rejects connection on a node type that requires none
(le type « X » ne prend pas de connexion). Saving and publishing also check that the
connection exists and fits the node (connexion « x » introuvable, … de type « t », « u » attendu, … qui ne sert pas le port « p »). A connection cited by a published
workflow cannot be deleted.
connections
Zod: z.array(Connection), where Connection is
z.object({ from: PortRef, to: PortRef }).
An array of dataflow edges. Each edge carries the typed output of one node's port to the typed input of another's. Order within the array is not significant.
| Field | Type | Required | Description |
|---|---|---|---|
from |
port reference | yes | Source: an output port. |
to |
port reference | yes | Target: an input port (or an exposed config field). |
connections:
- { from: compute_dfg.egfr, to: store_dfg.egfr }
- { from: compute_dfg.egfr, to: detect_condition.egfr }
Static validation checks both endpoints exist and that their types are compatible (structural subtyping, value-set, dimension). See Two-level validation and the type system.
Port references
Zod: PortRef = z.string().regex(/^[\w-]+\.[\w-]+(\.[\w-]+)?$/).
A port reference is a dot-separated string. \w matches [A-Za-z0-9_]; a hyphen
is also allowed. There are two forms:
| Form | Grammar | Meaning |
|---|---|---|
nodeId.portName |
two segments | A whole port. |
nodeId.portName.fieldName |
three segments | A split-pin field of an object port. |
Each segment matches [A-Za-z0-9_-]+. A reference with fewer than two segments,
or more than three, fails the regex.
The helper parsePortRef(ref) (same file) parses a reference into
{ node, port, field? }: it splits on the first dot for node, then on the
next dot for port and the optional field.
| Input | node |
port |
field |
|---|---|---|---|
trigger.result |
trigger |
result |
— |
trigger.result.value |
trigger |
result |
value |
Split-pin field references
The three-segment form nodeId.portName.fieldName addresses a single field of an
object-typed port without an intermediate node. Field-level types are validated;
the interpreter extracts (on a source) or assembles (on a target) at runtime.
connections:
- { from: trigger.result.value, to: log_value.input } # field `value` of the Observation
Reserved identifiers
| Identifier | Kind | Meaning |
|---|---|---|
trigger |
node id | The trigger instance. Addressed as trigger.<port> in connections. |
done |
implicit output port | Present on every node. Emitted when the node has produced (at least one output, or no declared outputs). |
after |
implicit input port | Present on every node. Multi-wire join (AND): waits for all wired branches; skipped if any is absent. Received data is ignored. |
done and after are flow pins managed by the engine; node behaviors never see
them. They are used to sequence and join without transporting data — see
Sequence with after and done.
Exported types and helpers
From workflow-spec.ts:
| Export | Kind | Description |
|---|---|---|
WorkflowSpec |
Zod schema + inferred type | The document schema. |
WorkflowNodeInstance |
Zod schema + inferred type | A single node instance. |
Connection |
Zod schema + inferred type | A single edge. |
parsePortRef(ref) |
function | Parses a port reference into { node, port, field? }. |
Related
- Core primitives — the built-in
flow.*andtransform.*node types. - JEXL — the expression language used in
flow.guard/flow.switchconditions. - TypeRef — how port types are named.
- Wire ports, Branch with guards and switch, Sequence with after and done, Loop with map, Add human validation, Use agents in a workflow.
- Validate a workflow.
- The dataflow model, Branching and absence, Two-level validation.