Documentation / Développement / Tutoriels / Build a plugin, end to end

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, the npx and npm install commands 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.tsinterface ScoreResult { score: number; band: "low" | "high" } (the coded field became a literal union);
  • nodes.ts — a BehaviorFor<"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.
75 documents9 sectionssource : /docs · généré au build