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 + 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" } (the coded field became a literal union);
  • nodes.ts — a BehaviorFor<"id"> alias per node that has a behavior (here example.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: ExampleReceived is listed under Declared events, with its contract, our trigger under Dedicated triggers, and its example;
  • in the workflow editor, selecting the example.received trigger shows the Declared event section: the subject context kind and the required value field;
  • the Event list of Événement reçu, in the creation dialog and the trigger inspector, offers ExampleReceived under @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:

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