Build a plugin, end to end
In this tutorial we take a plugin from an empty folder to a published, loadable
artifact. We work as a third-party developer, in our own repository, with no
access to the platform source tree. The meridian-plugin CLI does the heavy lifting:
it scaffolds the project, generates types, validates a workflow, and bundles the
result. We finish by publishing to a registry.
We do not need to understand the engine internals to follow along. If you want the why behind plugins, read What a plugin is afterwards — here we just build one.
Every step below produces something visible. Do them in order.
Before we start
We need:
- Node 20+ and npm.
- The SDK (
@meridian/shared) and the CLI (@meridian/plugin-cli), published by the platform team. Once you have registry access, thenpxandnpm installcommands below just work. (If you were handed tarballs instead, see Scaffold a plugin for the tarball install.)
We do not need a running platform until the very last step, and even then only the operator does — we just ship a folder.
Step 1 — Scaffold the project
We create a plugin named @acme/vitals:
npx @meridian/plugin-cli new @acme/vitals
cd vitals && npm install
The CLI prints where it wrote the project:
Scaffolded @acme/vitals in /…/vitals
Next: cd into it, `npm install`, then `npm run build`.
We now have a package that builds out of the box — a working example we will adapt:
vitals/
package.json # depends on the SDK; build / gen-types / validate scripts
tsconfig.json # Node ESM, emits to dist/
plugin.yaml # an example: value-set + type + context kind + event + trigger + compute
src/behaviors/example.ts # the compute node's behavior, typed
workflows/example.workflow.yaml # a sample workflow we can validate
Step 2 — Look at what we got
The single plugin.yaml manifest declares a small, coherent set of contributions —
our template for the real ones. It requires the plugin contract 0.1.1 (sdk: "^0.1.1", needed for declared events) and defines a value-set (example-band), a
resource type (ScoreResult), a context kind (subject — kinds are open and
plugin-contributed; a real plugin would more likely reference patient from its
dependsOn closure), a declared event (ExampleReceived: its context kind and its
typed fields, the contract its producers follow), a declarative trigger (turns that
event into a workflow start, with no code) and a compute node (a number becomes a
banded ScoreResult):
sdk: "^0.1.1"
contributes:
valueSets:
- id: example-band
system: urn:example:band
concepts:
- { code: low, display: Low }
- { code: high, display: High }
types:
- name: ScoreResult
layer: resource
fields:
score: { type: { kind: primitive, name: Decimal }, required: true }
band: { type: { kind: coded, valueSet: example-band }, required: true }
contextKinds:
- { name: subject, idField: subjectId, entity: Subject, description: The subject this example is about. }
events:
- type: ExampleReceived
context: [subject]
fields:
- { name: value, type: Decimal, required: true, description: The measured value. }
example: { subjectId: subject-1, value: 42 }
nodes:
- id: example.received
kind: trigger
event: ExampleReceived
- id: example.score
kind: compute
inputs:
- { name: value, type: { kind: primitive, name: Decimal }, required: true }
outputs:
- { name: result, type: { kind: object, name: ScoreResult } }
behavior: { module: ./behaviors/example.js, export: exampleScore }
The trigger has no behavior: it takes its event type, the context it establishes
(subject) and its example from the declaration, its outputs are the event's fields
(here value), and the core provides the code that turns an event into a run start.
See Declarative triggers.
The trigger is optional: without it, a workflow receives ExampleReceived with the core
trigger Événement reçu, the event chosen in its configuration (see
Declare an event).
src/behaviors/example.ts implements the compute node as a factory typed against the
SDK's generic NodeBehaviorFactory<Inputs, Outputs>, so it compiles before we generate
anything:
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 }) => ({
result: { score: inputs.value, band: inputs.value >= 50 ? "high" : "low" },
}),
});
Note that behavior.module points at the compiled path (./behaviors/example.js).
We ship the compiled dist/, where the manifest sits next to the JavaScript output.
The full manifest schema and the meaning of each TypeRef (primitive, coded,
object, …) live in the plugin manifest and
TypeRef references — no need to read them to finish here.
Step 3 — Generate types from the manifest
The manifest is the single source of truth for our types. We project it into TypeScript:
npm run gen-types # runs: meridian-plugin gen-types
This writes a generated/ folder:
types.ts—interface ScoreResult { score: number; band: "low" | "high" }(thecodedfield became a literal union);nodes.ts— aBehaviorFor<"id">alias per node that has abehavior(hereexample.score; the declarative trigger has none).
We can now switch a behavior from the hand-written generic form to the generated alias and 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 — see Generate types for the
resolution rules across dependsOn plugins. Commit generated/; never edit it by hand.
Step 4 — Validate a workflow
The scaffold ships a sample workflow that wires the trigger to the compute node. We static-check it:
npm run validate workflows/example.workflow.yaml # runs: meridian-plugin validate …
A valid workflow reports success:
✓ Example workflow: valid (ports exist, connection types compatible, context, required inputs).
The CLI loads our plugin metadata plus the engine primitives and checks the graph — no engine, no infrastructure. It exits non-zero on errors, so it drops straight into CI. If we broke a connection, we would see something like:
✗ Example workflow: 1 error(s):
- Connexion score.result → ghost.input : nœud cible « ghost » inconnu.
The same command checks the declared event and the trigger that references it with the
host's load checks; a problem there is printed under
✗ plugins : <n> problème(s) de chargement :.
More on what gets checked in Validate a workflow; the wiring grammar itself is the workflow DSL.
Step 5 — Build
We bundle the plugin into the folder we ship:
npm run build # runs: meridian-plugin build
Output:
✓ @acme/vitals@0.1.0 → dist/ (1 module(s) bundlé(s), deps runtime incluses).
Before writing anything, meridian-plugin build runs the same load checks as
validate: a problem in our manifest would stop the build with
✗ @acme/vitals : 1 problème(s) de chargement, dist/ non écrit. It then uses
esbuild to bundle every module referenced by the manifest,
inlining its runtime dependencies — the published plugin needs no npm install on
the host. Only two families of imports stay external, guaranteed by the host: Node
builtins (node:*) and the SDK (@meridian/*). The command also emits the .d.ts
declarations, copies plugin.yaml into dist/ (rewriting module: paths from .ts to
.js), and copies any i18n/*.yaml next to it.
The resulting dist/ is exactly what we ship. (For the mechanics and the avoid-native-
deps caveat, see Build and bundle and
Bundling and distribution.)
Step 6 — Publish to the registry
We publish the built dist/ to the plugin registry — a GCS bucket or a shared
directory. We pass the registry with --registry (or set MERIDIAN_REGISTRY):
meridian-plugin publish --registry gs://meridian-plugins
Output:
✓ @acme/vitals@0.1.0 publié sur gs://meridian-plugins (N fichier(s)).
Published versions are immutable — to release again, bump version in plugin.yaml
and publish once more. We can list what is published:
meridian-plugin versions @acme/vitals --registry gs://meridian-plugins
Details and access setup are in Publish to the registry; operators set the bucket up per Set up a plugin registry.
Step 7 — See it load
No platform rebuild is involved. The operator just declares our plugin in their instance manifest, and the host downloads it at boot:
plugins:
"@acme/vitals": "^0.1.0"
Version resolution uses standard semver ranges. At boot the host logs the plugins it
loaded (message plugins chargés, field plugins), and ours appears alongside the
others:
plugins chargés plugins: ["@posos/common@0.4.1", …, "@acme/vitals@0.1.0"]
The host needs the plugin contract 0.1.1 that our sdk: "^0.1.1" asks for; an older
host refuses the plugin and logs contrat plugin incompatible — plugin ignoré.
From here our nodes are available in the catalogue, the visual editor palette, and any workflow — exactly like the built-in ones. Three places in the console show our event:
- Plugins →
@acme/vitals→ Events tab:ExampleReceivedis listed under Declared events, with its contract, our trigger under Dedicated triggers, and its example; - in the workflow editor, selecting the
example.receivedtrigger shows the Declared event section: thesubjectcontext kind and the requiredvaluefield; - the Event list of Événement reçu, in the creation dialog and the trigger
inspector, offers
ExampleReceivedunder@acme/vitals.
The operator's side of this is Declare and install plugins.
What we did
We scaffolded, generated types, validated, built, and published a working plugin — without ever touching the engine. To go further:
- Replace the example with our own value-sets, types, events and triggers, and compute nodes: Declare an event.
- Assemble those nodes into a real workflow: Your first workflow.
- Add a source or sink, an agent, or an event source — each has a focused how-to under Developing.
- Understand the contract our behaviors sign: Behavior contract and the full CLI reference.