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 + trigger + compute
src/behaviors/example.ts # the behaviors, 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 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 trigger node (turns an inbound event into a workflow
start) and a compute node (a number becomes a banded ScoreResult):
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. }
nodes:
- id: example.received
kind: trigger
eventType: ExampleReceived
establishes: [subject]
outputs:
- { name: value, type: { kind: primitive, name: Decimal } }
behavior: { module: ./behaviors/example.js, export: 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 }
src/behaviors/example.ts implements those two nodes as factories 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.
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):
- Connection score.result → ghost.input: target node "ghost" is unknown.
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) bundled, runtime deps included).
meridian-plugin build 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 published to gs://meridian-plugins (N file(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 lists the plugins it loaded, and ours appears alongside the built-ins:
Plugins: @posos/common@0.1.0, …, @acme/vitals@0.1.0
From here our nodes are available in the catalogue, the visual editor palette, and any workflow — exactly like the built-in ones. 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, and compute nodes.
- 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.