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) { 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.
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.

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