Autonomy, the SDK, and packaging
A plugin's code depends on exactly one thing: the SDK, @meridian/shared. Nothing
else — not the host application, not the engine's internals, not another plugin
unless it says so explicitly through dependsOn. That single, narrow dependency is
not a style preference; it is the mechanism that makes the rest of this page true —
that a plugin can be built, versioned and shipped by someone who has never seen the
platform's source tree, and loaded into a running instance without anyone rebuilding
anything.
What a plugin is covers the extension-host model and what a plugin contributes. This page stays on one question: what makes a plugin genuinely independent — from the shape of its one dependency, through the version contract, to how it actually gets from a third-party laptop onto a running deployment — and what that independence costs.
The SDK is the entire compile-time surface
@meridian/shared (libs/shared/package.json) is browser-safe: no filesystem
access, no host internals, no dependency on apps/api. In development its
exports map every subpath straight to TypeScript source ("./*": "./src/*.ts"),
resolved by a path alias (tsconfig.base.json for workspace consumers,
external-plugins/tsconfig.json for the plugins) — no build step for in-repo
work. What a
published plugin actually resolves against is different: publishConfig.exports
points the same subpaths at compiled dist/*.js plus .d.ts, a field pnpm publish/pnpm pack apply and plain npm ignores (which is why the SDK, the CLI
and @meridian/engine-core are always released with pnpm — see
SDK surface).
Most of what a plugin imports from the SDK is import type — NodeBehavior,
SourceFactory, the manifest's zod schema, TypeRef — erased at compile/bundle
time, so the emitted JavaScript carries no runtime reference back to the SDK
package for those. A companion package, @meridian/engine-core (the interpreter,
makeCatalog, validateSpec, the WorkflowContext seam —
libs/engine-core/package.json), also depends only on the SDK, but a plugin never
imports it: engine-core is what a host embeds — the dev server and the
Restate endpoint embed it identically, per its own description — not something a
plugin author reaches into. A plugin that imported engine-core would be reaching
into the engine's implementation, exactly the coupling the one-dependency rule
exists to prevent. The exhaustive list of what the SDK actually exports is SDK
surface; the manifest's sdk field is documented in
Plugin manifest.
Three semver questions, one library
A Meridian deployment asks "is this version compatible?" in three places. All
three are answered by the same standard npm semver package — never a hand-rolled
comparator — but they are three different questions, and it is worth keeping them
apart.
The SDK range: a compatibility gate at load time
A manifest declares sdk: "^0.1.0", or sdk: "^0.1.1" when it declares events. At
plugin discovery, the host compares that range against its own
PLUGIN_CONTRACT_VERSION (libs/shared/src/version.ts, 0.1.1) using
satisfies() from semver (apps/api/src/plugins.ts). A mismatch is a soft
failure: the plugin is logged and skipped, every other plugin still loads. This is
what makes it safe to release the core and a plugin on independent cycles — it is
a shape check, not a judgment about the plugin's code (see
Security posture for why it
isn't a security boundary either).
The contract version follows its own doctrine. A patch marks a new manifest key
that an older host would drop (the schema drops a key it does not know: a host that
reports unknown keys logs it at load, an earlier host drops it silently) while it changes
what gets loaded: 0.1.1 adds declared events (contributes.events, a trigger's
event, an output's from). A plugin that uses them declares ^0.1.1, so a host on
0.1.0 refuses it instead of loading its triggers without their event, and plugins
on ^0.1.0 stay accepted by both. 0.1.3 adds the default filter of a declared event
the same way, 0.1.4 the description of a protocol's reception (media types, an
example message, a transport's default port and hint), 0.1.5 conditional port
rules, and 0.1.6 connections
(contributes.connectionTypes, and connection on an adapter, an event source or a
node). A minor marks a real break of the plugin-facing surface. An addition
an older host can ignore without consequence moves nothing. See
SDK surface.
dependsOn: the graph and the version constraints
dependsOn: { "@posos/clinical": "^0.2.0" } declares another plugin this one builds
on, and the versions of it the plugin works with. The key drives three things: load
order (a topological sort in apps/api/src/plugins.ts), cross-plugin type resolution
(the codegen resolves the dependency closure through Node package resolution, then the
sibling directories — see Generate types), and the
closure in which a trigger's event, an event's context kinds and its field types must
resolve at load time (dependsOnClosure, the same function in the host and the CLI, so
both reach the same result whatever the discovery order).
The range is checked wherever the versions are known, by one function
(dependencyIssues, libs/shared/src/plugin/dependencies.ts): by the host, once every
discovered manifest is parsed, against the version of each dependency it is about to
load; by meridian-plugin validate, build and deps, against the plugin.yaml of
the dependency the CLI found. A plugin whose range is invalid or does not accept the
version found is refused, like a plugin whose sdk range does not accept the host's
contract; the version found counts whether that copy of the dependency loads or is
refused itself. A plugin whose dependency is absent, or refused with a version in the
range, loads without what cites it. When two copies of a plugin are found, only the
first root counts, for the check as for the load.
A plugin's package.json names no other plugin of the repository: the range lives in
plugin.yaml only, the one file the host, the CLI and the registry read. A third-party
author who installs a dependency through npm keeps that line in package.json for npm;
the check reads the plugin.yaml npm installed.
The ranges follow the doctrine of plugin
versions: a plugin
declares ^<major>.<minor>.0 of the series it is tested with, a dependency that breaks
its dependents moves to a new minor in 0.x, and each dependent then fails
meridian-plugin build until it is reviewed and its range moved to the new series.
* stays accepted and constrains nothing; the repository's plugins do not use it.
The instance's plugins: range: resolved against a registry
A third, separate use: an instance manifest's plugins: map names a required
plugin by version range ("@posos/demo": "^0.2.0"). ensureInstancePlugins
(apps/api/src/registry.ts) resolves that range against the versions a registry has
actually published (resolveRequiredPlugins, libs/plugin-cli/src/registry/resolve.ts),
downloads the selected version into a local cache (MERIDIAN_PLUGIN_CACHE), and adds
that folder as an extra discovery root before loadPlugins() runs. The required
plugins are resolved together: each gets a version of its range whose sdk range admits
the host's plugin contract, whose dependsOn ranges accept the local plugins (the
PLUGINS_PATH roots, such as the core plugins of the image, and the path: entries,
including a copy the host refuses, whose version still counts) and the versions chosen
for the other required plugins, and whose version their dependsOn ranges accept. A
required plugin that is also found locally is not resolved at all: the local copy loads.
The search tries the highest versions first and goes back
when a choice leaves another required plugin without a version: it finds a combination
whenever one exists within its bound of 10 000 versions examined, and the first it finds
is the highest in its search order. The same registry and the same local plugins
give the same choice to the endpoint and to the proxy, and a business plugin published
before the image that satisfies it is not selected until that image is deployed. The SDK and
dependsOn checks above still run afterward, once that plugin is actually loaded. See
Instance manifest and Declare
and install plugins.
The common thread is deliberate: reusing semver's satisfies, validRange,
compare, maxSatisfying, gtr and valid everywhere means every range a plugin
author or an operator writes behaves like the one they already know from package.json
— there was no reason to invent a fourth dialect for a fourth place. One exception: a
dependsOn range * (or an empty one) constrains nothing, prereleases included, while
npm's * excludes them.
The same plugin, two shapes, one loader
A plugin loads, unchanged, in one of two shapes, with identical files and
manifest: from TypeScript source — how the core plugins under
external-plugins/<name>/ run — or from a self-contained dist/ a build
produced, which is how the business plugins (and any third-party plugin) load at
runtime. Discovery is multi-root (pluginRoots(),
apps/api/src/plugins.ts): every path in
PLUGINS_PATH (split on , or the OS path delimiter), plus whatever
ensureInstancePlugins resolved from the registry for the instance's plugins:
map. Both kinds of roots are walked by the same discovery and
applyManifest code — there is no special case for "a plugin that came from the
registry" versus "a plugin that was always on disk". A small resolver
(resolveModule()) transparently swaps .ts ↔ .js, so behavior: { module: ./behaviors/x.ts } resolves whether the neighbouring file is TypeScript source
(the core plugins) or the compiled JS a build produced (dist/).
This sameness is not merely convenient — it is the proof that autonomy is real,
not aspirational. The exact loader that runs @posos/common from source while
someone iterates on the platform is the one that will run a third party's
@acme/vitals from a dist/ folder nobody on the platform team has ever opened.
And it is already exercised, not hypothetical: every plugin under
external-plugins/ is an independent
npm package with its own package.json — name @posos/common, its own
exports, its own publishConfig — sitting entirely outside
the pnpm workspace, and none of them is a workspace member. The business plugins
(demo, demo-softway, geriatrie, nephrology, posos) build with the same
meridian-plugin build invoked directly through tsx (they have no
node_modules/.bin of their own), and load in development purely through their
dist/ entries on PLUGINS_PATH — proof that the "hand a dist/ folder to
an operator" story already works against the platform's own example plugins, not
just in theory. See Bundled plugins
and Example plugins.
Packaging was built in phases, on purpose
Today's shape — the SDK, the engine, and every plugin as separately versioned,
independently buildable packages, plus an out-of-repo discovery mechanism —
replaced an earlier state where the whole platform ran from TypeScript source with
no build step anywhere (plain tsx). Getting from one to the other in a single
migration would have meant everything shipping in lockstep exactly once, which is
the opposite of the goal. The packaging record
(ADR 0001) lays out five phases, each landing (and
being typechecked/tested) independently: a generic behavior contract seeded in the
SDK; codegen resolved through the dependsOn closure with no any/casts; the SDK
itself made buildable and publishable; the engine extracted into its own buildable
package so a host doesn't compile the interpreter alongside application code; every
plugin turned into its own installable package; and, finally, out-of-repo discovery
(PLUGINS_PATH) plus the SDK-version gate described above.
One invariant was called out explicitly, and preserved throughout: the day-to-day
dev loop was never allowed to require a manual build step for core work. The
resolution is a deliberate hybrid — the core plugins load from .ts source
(and type-check against source through external-plugins/tsconfig.json's path
aliases); only building for
external delivery, or publishing, produces a dist/. That's why the core plugins
can already be independent packages (previous section) without the daily pnpm dev loop ever slowing down. A second invariant: plugin.yaml is always the
source of truth that codegen projects into TypeScript, never the reverse, and
dependsOn is a single graph used for both load order and type resolution, not
two separate ones that could drift apart.
A self-contained dist/, not a folder that needs npm install
meridian-plugin build (libs/plugin-cli/src/commands/build.ts) first runs the
host's load checks on the plugin (unknown manifest keys included) and writes nothing when
the plugin has a problem, then bundles every module the manifest references — node behavior,
receive and assist modules, adapters, event sources, message bindings, port
sandboxes — with esbuild: bundled, ESM, targeting Node 20, with external: ["node:*", "@meridian/*"]. Every runtime dependency the plugin's own code pulls in — a YAML
parser, a small math library, whatever it needs — is inlined into dist/; only
Node builtins and the SDK stay external, because the host guarantees both are
present. There is no separate build-manifest.mjs or hand-written bundling
script — the CLI's build command is the build.
The practical consequence: point PLUGINS_PATH at a dist/ folder, and the host
never runs npm install on the plugin's behalf — dist/ already contains
everything it needs except the two things the host promises. The one thing that
doesn't bundle is a native (compiled) npm dependency: esbuild can't inline machine
code, so a plugin that genuinely needs one has to get it from the host image
instead, not from its own dist/. Cross-plugin type imports
(@scope/dep/types) are the case that proves the no-runtime-coupling rule rather
than breaking it: they are import type only, erased at bundle time exactly like
SDK imports, so a plugin's dist/ never needs its dependsOn plugins installed
at runtime — only at build/codegen time: build stops on a dependsOn package it
cannot find (reported absent), and codegen skips it (see Generate
types).
The bundler's internals — chunk splitting, the ESM-compat banner, the two source- layout conventions it resolves against — are covered where they belong, in Bundling and distribution and Build and bundle; this page only needs the shape and its consequence.
Distribution: two channels, one loader
Once built, a plugin reaches an operator one of two ways, and it's worth naming what they have in common as much as what differs.
The registry — meridian-plugin publish, targeting a directory or a gs://
bucket. Plugins are laid out @publisher/name/<version>/…; a version is
immutable (publishing an existing one is refused outright), and plugin.yaml is
written last as the completeness marker, so an interrupted publish is invisible
to a downloader rather than half-visible. An operator then only writes a version
range into the instance manifest's plugins: map; ensureInstancePlugins
resolves it, downloads into the local cache, and hands the folder to the loader.
The drop folder — hand over dist/ directly; the operator points
PLUGINS_PATH at it. No registry is involved at all — the on-prem / air-gapped
route.
The design point worth naming explicitly: these are two ways to get a dist/
folder onto disk in front of the same loader, not two different loading
mechanisms. The registry buys version resolution and remote fetch; it changes
nothing about what gets loaded or how a behavior module is imported. That
equivalence was a deliberate choice — it's what let out-of-repo discovery and the
registry be built as two independently useful pieces instead of one feature that
only works together. A third channel, signed OCI bundles with capabilities, is on
the packaging plan's roadmap but not built; see Security
posture for what that would add
and why today's registry guarantee — a published version won't change under you —
is procedural, not cryptographic. The author- and operator-side mechanics are in
Publish to the registry, Set up a plugin
registry and Declare and
install plugins.
What this design trades away, on purpose
Compiling plugin code into the platform's own build was available and was rejected for the same reason the engine stays empty (see What a plugin is): it would tie every plugin's release to the core's release train, which is the opposite of what an autonomous, third-party-shippable plugin needs.
Resolving a plugin's dependencies from the registry is left out. The registry
resolves the plugins an instance requires, and takes the local plugins and the other
required plugins into account, but it never adds a plugin that nothing requires: a
dependency that is neither loaded locally nor listed under plugins: is reported absent
at load. The CLI resolves a dependency through node_modules or a sibling directory,
never through a registry. The core plugins are published nowhere, so a third-party
author who depends on one builds against a checkout or an installed copy of it.
An unbounded search is left out of the registry resolution: the search goes back only to the choices that ruled a version out, and it stops after 10 000 versions examined, failing the boot with a message that asks for a pinned version or a narrower range. A set of ranges that needs more to settle is better made explicit by the operator.
Sandboxing plugin execution — a WASM tier, an isolated worker, an
out-of-process protocol — is not part of this design. The loader's dynamic
import() is a plain Node import: a loaded plugin runs with the full privileges
of the host process, and no version range or registry check changes that. That
cost is real, and it is a named one, not a silent gap: the packaging plan already
marks that same import() as the seam a sandboxed execution tier would later
attach to, without changing how a plugin is declared, built, or discovered. What
that trust boundary means in practice — and why the SDK-range check above is a
compatibility gate rather than a security one — is the subject of Security
posture and the ADR on
packaging and SDK
externalization.
This page's job is only to note that the seam was left there deliberately.
Where to go next
- What a plugin is and what you get "for free": Plugins concept.
- The exhaustive SDK export map: SDK surface.
- The
sdkanddependsOnfields, field by field: Plugin manifest. - Bundler mechanics in depth: Bundling and distribution.
- Build and ship it yourself: Build and bundle, Publish to the registry, the CLI reference.
- The operator side: Set up a plugin registry, Declare and install plugins, Instance manifest, Environment variables.
- Trust consequences of loading arbitrary JS: Security posture.
- Terms used here: Glossary.