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) |
{ 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.
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 fan out to
accept / reject / escalation branches. See
Add human validation and
Two-level validation.
8. Trigger nodes: fromEvent
A trigger turns an inbound event into a workflow start: fromEvent(event) establishes the
ambient context and the initial outputs. Declare which context keys it establishes with
establishes in the manifest.
export const exampleReceived: NodeBehaviorFactory<{}, { value?: number }> = () => ({
fromEvent: (e) => ({
context: { patient: { id: String(e.patientId ?? "unknown") } },
outputs: { value: Number(e.value) },
}),
});
The event shape is whatever your event source and binding project — 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.