Documentation / Développement / Guides pratiques / Implement a node behavior

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 ambient ContextEnvelope, an open map keyed by the plugin-contributed context kinds (e.g. patient, encounter, order, document from @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 declares connection; pass it to ctx.connection or ctx.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 fromEvent again each time it resumes: no clock, no randomness, no port calls. Take a time from the event (occurredAt, set at ingestion) rather than from new Date().
  • Expect one call per receiving workflow. Every published workflow that receives the event type gets its own copy of the event, whose eventId is the run id (<eventId>:<slug>).
  • Declare a default filter when your platform's own writes come back as this event (defaultSelector on 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.

97 documents9 sectionssource : /docs · généré au build