Implement a node behavior
A node's behavior is a factory exported from a plugin module: (ctx) => NodeBehavior.
The host calls the factory once per node with a context
(ports, locale, t), then invokes one method on the returned object depending on the
node's kind. This recipe walks through writing that module for a real node.
It assumes you already have a scaffolded plugin with the node declared in plugin.yaml
(see Scaffold a plugin) and generated types
(see Generate types). For the full contract, see the
behavior contract reference.
1. Pick the method for your node kind
The NodeBehavior object exposes three optional methods; the interpreter calls exactly
one, chosen by the node's kind:
kind |
Method | Returns |
|---|---|---|
source, sink, compute, gateway |
run(args) |
outputs by port name (may be async) |
human-task |
propose(args) |
a Proposal, or null to skip the node |
trigger |
fromEvent(event, config) |
{ context, outputs } — the initial context + outputs |
Implement only the method your kind requires. A human-task without propose, or a
non-human node without run, throws at execution time.
2. Write the factory
Import the contract types with import type (they are erased at build time — the emitted
dist/ has no runtime dependency on the SDK) and export one factory per node.
import type { NodeBehaviorFactory } from "@meridian/shared/plugin/behavior";
type Band = "low" | "high";
export const exampleScore: NodeBehaviorFactory<{ value: number }, { result?: { score: number; band: Band } }> =
() => ({
run: ({ inputs }) => {
const band: Band = inputs.value >= 50 ? "high" : "low";
return { result: { score: inputs.value, band } };
},
});
Prefer the generated BehaviorFor<"node.id"> alias over the generic form — it wires the
input/output/config types from the manifest so you drop the inline shapes:
import type { BehaviorFor } from "../generated/nodes.js";
export const exampleScore: BehaviorFor<"example.score"> = () => ({
run: ({ inputs }) => ({ result: { score: inputs.value, band: inputs.value >= 50 ? "high" : "low" } }),
});
Both forms are equivalent; run meridian-plugin gen-types to
produce the alias and the types.ts interfaces.
3. Read inputs, config, and context
run and propose receive RunArgs:
inputs— the values arriving on the node's declared input ports, keyed by port name.config— the node's static config from the workflow (keyed by config field).context— the ambientContextEnvelope, an open map keyed by the plugin-contributed context kinds (e.g.patient,encounter,order,documentfrom@posos/common), each entry carrying an{ id }. Guard the keys your node needs; they are optional.connectionId— the connection the node instance designates, for a node that declaresconnection; pass it toctx.connectionorctx.portFor(see Connection types).
function requirePatient(ctx: { patient?: { id: string } }): string {
if (!ctx?.patient) throw new Error("Missing patient context.");
return ctx.patient.id;
}
4. Call ports for side effects
Reading or writing the patient record, prescriptions, terminology, or outbound
notifications goes through ctx.ports — the hexagonal ports the host injects. Never reach
for a store or HTTP client directly; that is the operator's adapter choice
(see Configure ports and adapters).
Each port's contract is exported by its owning plugin (e.g.
PatientProfilePort from @posos/clinical/ports), which also augments the
SDK's open Ports interface — so with the owner in your dependsOn closure,
ports.patient is strongly typed from a plain type import. Every port is
optional at runtime; guard the access with the SDK's requirePort, which throws
a clear configuration error naming the port when the instance does not bind it:
import { requirePort } from "@meridian/shared/domain/ports";
import type { PatientProfilePort } from "@posos/clinical/ports";
import type { BehaviorFor } from "../generated/nodes.js";
export const appendDerivedValue: BehaviorFor<"patient.append-derived-value"> = ({ ports }) => ({
run: async ({ inputs, context }) => {
const patient = requirePort<PatientProfilePort>(ports, "patient");
await patient.addObservation(requirePatient(context), {
coding: { code: "eGFR", display: "Estimated glomerular filtration rate" },
value: inputs.egfr.value.value,
unit: inputs.egfr.value.unit,
observedAt: new Date().toISOString(),
derivedFrom: "creatinine",
note: inputs.egfr.equation,
});
return {};
},
});
For the full method set on each port, see the port contracts. Pure computation should live in a separate, dependency-free module the behavior imports — keep the factory thin.
5. Localize user-facing strings with t
Any string a human will read (notification bodies, validation titles) must go through
ctx.t, which resolves a runtime i18n key into the instance locale with {param}
interpolation. t is optional (older hosts may not provide it), so default it:
export const drugSafetyAlert: BehaviorFor<"notify.drug-safety-alert"> = ({ ports, t: ctxT }) => {
const t = ctxT ?? ((k: string) => k);
return {
run: async ({ inputs, context }) => {
const patient = requirePort<PatientProfilePort>(ports, "patient");
await patient.addNotification(requirePatient(context), {
severity: "warning",
category: "alert",
title: t("drug-safety-alert.title"),
body: inputs.report.issues
.map((i) => t("drug-safety-alert.issue-line", { severity: i.severity, detail: i.detail }))
.join("\n"),
});
return {};
},
};
};
Add the matching keys under the runtime section of your i18n bundles — see
Translate a plugin. Note ctx.locale is the instance
locale (the language of persisted content), distinct from the viewer's UI locale.
6. Control branching by what you emit
run returns outputs keyed by port name. Omitting a port emits nothing on it, which
stops that downstream branch — the standard way to express "no result":
export const detectConditionFromEgfr: BehaviorFor<"clinical.condition.detect-from-egfr"> = () => ({
run: ({ inputs }) => {
const proposal = suspectedConditionFromEgfr(inputs.egfr);
return proposal ? { proposal } : {}; // no proposal → branch stops
},
});
The runtime rejects outputs on ports the node did not declare, so emit only declared ports. For the modelling side of absence, see Branching and absence.
7. Human-task nodes: propose
A human-task builds a validation request with propose, then supplies the outputs once a
human decides (onResolve) or the SLA lapses (onTimeout). Return null from propose
to skip the node entirely (nothing to validate).
export const acknowledgeCritical: BehaviorFor<"human.acknowledge-critical"> = ({ t: ctxT }) => {
const t = ctxT ?? ((k: string) => k);
return {
propose: ({ inputs, config }) => {
const r = inputs.result;
const deadlineSeconds = typeof config.deadlineSeconds === "number" ? config.deadlineSeconds : undefined;
return {
title: t("acknowledge-critical.title", { display: r.code.display, value: r.value.value, unit: r.value.unit }),
payload: { analyte: r.code.display, value: r.value.value, unit: r.value.unit },
deadlineSeconds,
onResolve: (res) => ({ acknowledged: res }), // res: { decision, by, at, comment? }
onTimeout: () => ({ escalation: r }),
};
},
};
};
onResolve receives a ValidationResolution (decision: "accepted" | "rejected", plus
by, at, optional comment). Emit different output ports per outcome to feed
separate accept / reject / escalation branches. See
Add human validation and
Two-level validation.
A run keeps the request it published: in a run of
journal format 2, the
title, payload and deadline are journaled when the task starts, and the outputs of
onResolve or onTimeout once computed. A new version of the plugin that changes a
title (a new wording, a translation) applies to the tasks that start after it. A task
pending when the new version loads gets that version's onResolve or onTimeout: a
change of the outputs applies to it. When the new version's propose returns null for
that task, the run cannot apply the decision and Restate retries it until a version that
proposes the task is loaded again. A run of format 1 calls propose again at each
resumption: a new title blocks it while it waits on the task (see
Translate a plugin).
8. Trigger nodes: fromEvent
A trigger turns an inbound event into a workflow start: fromEvent(event, config)
establishes the ambient context and the initial outputs.
A trigger that references a declared event with event: and has no behavior needs no
code: the core provides its fromEvent, projected from the event's contract. Start
there — see Declare an event. The stay triggers of
@posos/clinical (trigger.patient-admitted, trigger.patient-discharged,
trigger.patient-transferred) and its trigger.discharge-prescription-edited work
this way: they declare their outputs, and from: encounterId or from: prescriptionId
feeds their ref output. This section covers a coded fromEvent.
Write code for a business projection — building an Observation or a
CodeableConcept from several fields, applying defaults. The clinical lab-result,
medication, procedure, allergy and condition triggers keep code for this reason. Keep
event: on the trigger (its eventType, establishes and example come from the
declaration either way), declare the outputs your code emits, and point behavior at
the factory. With the scaffold's ExampleReceived event (context subject, a required Decimal field
value):
nodes:
- id: example.received
kind: trigger
category: Example
label: Example value received
description: Fires when an example measurement arrives.
event: ExampleReceived
outputs:
- { name: value, type: { kind: primitive, name: Decimal } }
behavior: { module: ./behaviors/example.js, export: exampleReceived }
export const exampleReceived: NodeBehaviorFactory<{}, { value?: number }> = () => ({
fromEvent: (e) => {
const subjectId = typeof e.subjectId === "string" ? e.subjectId.trim() : "";
return {
context: subjectId ? { subject: { id: subjectId } } : {},
outputs: typeof e.value === "number" ? { value: e.value } : {},
};
},
});
Establish a context kind only when the event carries a usable identifier. A
placeholder id (String(e.subjectId) on an absent field gives "undefined") would
make every node of the run read and write that record. A kind left out is absent
from args.context, and the nodes that require it fail on the missing context. An
output the event does not carry is omitted: absence propagates downstream. The same
applies to an output built around a required key, such as the code of a
CodeableConcept: without a usable code, omit the output rather than emit the code
"undefined".
In a run of journal format 2, fromEvent runs once, in the durable step trigger, and
a replay reads its result from the journal. A run of format 1 calls it outside any durable step and again each time the run resumes, so it must
return the same thing for the same event: read no clock and no random value there. For
a missing timestamp, fall back on occurredAt, which ingestion sets and journals with
the event.
Three rules follow from how the host calls fromEvent:
- Keep it deterministic. A run of format 1 calls
fromEventagain each time it resumes: no clock, no randomness, no port calls. Take a time from the event (occurredAt, set at ingestion) rather than fromnew Date(). - Expect one call per receiving workflow. Every published workflow that receives
the event type gets its own copy of the event, whose
eventIdis the run id (<eventId>:<slug>). - Declare a default filter when your platform's own writes come back as this
event (
defaultSelectoron the event, see Declare an event).
The event follows its declared contract. Ingestion refuses a non-conforming event only
under EVENT_CONTRACTS=enforce: under observe, the default, or off, a field can be
missing or of another type. Fields the contract does not declare pass through as
received in every mode. Read each field defensively. For a pinned trigger's address,
which no contract describes, the shape is whatever the entry mechanism emits — see
Contribute an event source and binding.
9. Wire the module and build
Point each node's behavior at the exported factory in plugin.yaml:
nodes:
- id: example.score
kind: compute
behavior: { module: ./behaviors/example.js, export: exampleScore }
module names the module and export the factory; the build rewrites and bundles it into
dist/ (esbuild) so the manifest ends up beside the compiled output. Build with:
meridian-plugin build
Validate a workflow that uses the node with
meridian-plugin validate, then bundle and ship following
Build and bundle. For agent-backed nodes, see
Author an agent instead — agents run through the agent port, not a
hand-written behavior.