What a plugin is
A plugin is the unit in which everything domain-specific about Meridian is delivered. A creatinine result, an eGFR calculation, a FHIR connection, an AI triage agent, a way to poll a lab system for new results — none of that lives in the engine. It all arrives as plugins. This page explains why the platform is built that way, what the design buys you, and what it costs.
If you want to build one, start with the plugin tutorial and the scaffold how-to. If you want the exact schema of a manifest or the shape of a behavior, go to the reference. This page is the understanding behind those.
The engine is deliberately empty
Meridian is a workflow engine whose vocabulary is fully clinical and typed: events, triggers, computations and actions all carry rich types — FHIR-style resources, coded terminologies, dimension-aware quantities. The important move is that that vocabulary is not baked into the engine.
The core ships with only two things:
- a meta type-system — the language in which a vocabulary is described
(
TypeRef, value-sets, dimensions), plus a set of open registries that plugins fill at start-up; - the dataflow primitives — constants, transformations, flow control — and an interpreter that can run any workflow expressed in that language.
Everything else — datatypes, clinical resources, terminologies, context kinds, conversions, domain events, calculations, human validations, notifications, port contracts and their adapters to external systems, simulation sandboxes — is contributed by plugins. The core knows nothing about creatinine, nothing about FHIR, nothing about "a lab result". This principle, and the three-plane structure it produces, is the subject of the architecture explanation and the three planes; this page only summarises what the emptiness means for a plugin author.
The extension-host model
The mental model is the one editors use for extensions. The host exposes extension points and never depends on any particular extension; plugins plug into those points; the host discovers and loads whatever is present. VS Code does not know about your language server until it loads the extension that provides one — and Meridian does not know about eGFR until it loads a plugin that contributes it.
Concretely, at boot the host walks each plugin's manifest and applies its contributions into the shared registries, in a fixed order so that a type is always registered before a node that references it. From that point on, a plugin's nodes, types and value-sets are indistinguishable from the core's: they sit in the same registries, are served by the same catalogue, and run on the same interpreter. The mechanics of discovery, ordering and failure isolation are documented in plugin host loading.
Two consequences of this model are worth pulling out, because they are the whole point.
You extend the product without touching the engine
Adding a clinical calculation, a new integration or an AI agent never means editing — or recompiling — the core. You write a manifest and some behavior code, and the running platform picks it up. The engine's release cycle and your plugin's release cycle are independent. That independence is not a convenience bolted on afterwards; it is enforced (see autonomy and packaging below and the SDK externalization ADR).
Your contributions are first-class for free
Because a plugin's types and nodes join the same registries as the core, they inherit every capability the engine already provides, with no extra work from you:
- Static validation of workflow connections — structural subtyping over your types, value-sets and dimensions, checked before anything runs;
- Runtime validation of values at every port boundary;
- The visual editor — your nodes appear in the palette and connection validity follows from the types you declared;
- Durable execution — runs survive crashes and replay deterministically, and human-in-the-loop steps get SLAs (why this matters is why durable execution);
- Internationalisation — every label and description in your manifest is translatable through sidecar bundles whose keys are derived from the manifest structure, so you never invent a key.
This "for free" is the return on the empty-engine bet: the cost of keeping the core free of domain content is paid back as capabilities that every plugin gets automatically.
What a plugin contributes
A plugin declares its contributions in a single plugin.yaml manifest, and it may
contribute any subset of a fixed menu: value-sets and dimensions (the terminology and
unit backing for coded and quantity values), types (datatypes and clinical
resources), conversions, nodes (the workflow building blocks — computes, triggers,
sources, sinks, human-tasks), adapters (implementations of a hexagonal port),
agents (LLM agents projected as nodes), and event sources and bindings (how the
outside world triggers a workflow).
This page deliberately does not enumerate the fields of each of those — that is the job of the plugin manifest reference, with the type grammar in TypeRef, the node contract in the behavior contract, and the port catalogue in port contracts. What matters conceptually is that these are all declarations plus a little behavior code: the manifest says what exists and what its ports are; a behavior module says how it runs. The engine does the rest.
Most plugins contribute compute, trigger, source, sink and human-task nodes,
plus agents. Branching and type adaptation are engine primitives (flow.*,
transform.*) — a plugin never has to implement them, which keeps the plugin surface
small. How those primitives compose into a workflow is the authoring concern covered by
the dataflow model.
Autonomy: a plugin depends only on the SDK
A plugin's code depends on exactly one thing: the SDK, the @meridian/shared
package. You import its contracts — mostly as import type, which is erased at runtime —
and nothing else. You never reach into the host application's internals. That single, thin
dependency is what makes a plugin autonomous: it can be built, versioned and shipped on
its own, and loaded into a platform it was never compiled against.
Autonomy is guarded by a version contract. Each manifest declares the SDK range it needs
(sdk: "^0.1.0"); at discovery the host compares that range against its own
PLUGIN_CONTRACT_VERSION using the standard semver package — there is no hand-rolled version
logic — and refuses and logs any plugin that is incompatible, while the others still
load. This is precisely what makes it safe to release the core and the plugins on
independent cycles. The SDK surface itself is catalogued in
SDK surface; the reasoning about autonomy and versioning is
expanded in autonomy and packaging.
The same plugin, two places
A plugin lives, unchanged, in one of two places — the files and the manifest are identical:
- Internal — inside the platform repository, loaded from TypeScript source during development;
- External — anywhere else, built to a self-contained
dist/and loaded at runtime.
Discovery is multi-root: the host scans a built-in directory plus every path in the
PLUGINS_PATH environment variable. This is how third-party plugins are delivered without
a platform rebuild. The bundled and example plugins that ship in-repo are catalogued in
bundled plugins and
example plugins.
Building an external plugin is a single command — meridian-plugin build — which uses
esbuild under the hood to produce a self-contained artifact: every module the manifest
references is bundled with its runtime dependencies inlined, so the host never has to run
npm install. (There is no separate build script to invoke — the CLI is the build.)
Only two families of imports stay external, guaranteed by the host: Node builtins and the
SDK. Why bundling works this way, and its one caveat — native npm dependencies cannot be
inlined — is bundling and distribution. The full CLI is
in the CLI reference.
Distribution: the registry and the drop folder
Once built, a plugin can be distributed two ways. It can be published to the plugin
registry — meridian-plugin publish, targeting a GCS bucket or a shared directory
(from --registry or the MERIDIAN_REGISTRY environment variable), where versions are
immutable — after which an operator merely declares the plugin in the instance manifest's
plugins: map and the host downloads it at boot. Or it can be handed over as a dist/
folder that the operator points at with PLUGINS_PATH — the on-prem / air-gapped route.
The author's side of publishing is publish to the registry; standing up the registry and declaring plugins into an instance are the operator's, covered by set up a plugin registry and declare and install plugins.
Why this shape, and what it costs
It is worth being explicit about the alternatives the extension-host model was chosen over, because the trade-offs explain the friction you will occasionally feel.
A monolith with the clinical vocabulary compiled in would be simpler on day one — no registries, no SDK boundary, no version negotiation. It was rejected because it couples every domain change to an engine release: a new calculation or a new integration would mean a core deploy, and third parties could never extend the product at all. The empty engine trades that simplicity for independent release cycles and genuine extensibility.
A generic plugin API with untyped payloads (pass Record<string, unknown> around and
let plugins sort it out) would have avoided the code-generation machinery entirely. It was
rejected because it throws away the thing that makes a clinical engine trustworthy:
static and runtime type checking across every port boundary. Keeping types first-class is
why a plugin describes its data in the meta type-system and why the CLI projects that
description into TypeScript — see generate types and
the type system.
The costs you inherit from these choices are real but bounded: you write a manifest and keep generated types in sync; you must stay within the SDK contract rather than reaching into the host; and a type or value-set that fails to resolve causes the node that uses it to be skipped rather than silently mis-run. The last one is a feature — failure is isolated and logged, and one broken plugin never blocks start-up — but it means "my node didn't appear" is usually a resolution error to read in the logs, not a mystery.
Where to go next
- Build one end to end: the plugin tutorial.
- The exact declarations: plugin manifest, TypeRef, behavior contract.
- The event side of the model: the open event model.
- Packaging and versioning in depth: autonomy and packaging, bundling and distribution.
- Terms used here: glossary.