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". At plugin discovery, the host compares that
range against its own PLUGIN_CONTRACT_VERSION (libs/shared/src/version.ts) 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).
dependsOn: a graph key, not yet a version gate
dependsOn: { "@posos/clinical": "*" } declares another plugin this one builds
on. It drives two things: load order (a topological sort in
apps/api/src/plugins.ts) and cross-plugin type resolution (the codegen resolves
the dependency closure through Node package resolution — see Generate
types). The range string itself is not yet
enforced — presence in the closure and the load order it implies is what's
load-bearing today, not whether an installed dependency's version actually
satisfies the declared range. That's a scoped, known gap rather than an
oversight: the closure is resolved statically at build/codegen time, so there is
no single moment analogous to plugin discovery where "the wrong version of a
dependency" could be checked the same way sdk is.
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.1.0"). ensureInstancePlugins
(apps/api/src/registry.ts) resolves that range with maxSatisfying() against
the versions a registry has actually published (libs/plugin-cli/src/registry/registry.ts),
downloads the highest match into a local cache
(MERIDIAN_PLUGIN_CACHE), and adds that folder as an extra discovery root
before loadPlugins() runs. This is where "the highest compatible published
version" gets decided; the SDK range check above still runs afterward,
independently, once that plugin is actually loaded. See Instance
manifest and Declare and
install plugins.
The common thread is deliberate: reusing semver's satisfies, compare,
maxSatisfying and valid everywhere means every range a plugin author or an
operator writes behaves exactly like the one they already know from package.json
— there was no reason to invent a fourth dialect for a fourth place.
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 six 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) bundles every
module the manifest references — node behaviors, adapters, event sources — 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, where an uninstalled dependsOn
package is simply skipped (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.
Enforcing the dependsOn range the way the sdk range is enforced was left
for later, not ruled out. It stays a graph key today because the dependency
closure is resolved by static Node package resolution at build/codegen time,
not by a runtime registry lookup — there is no moment analogous to plugin
discovery where a version mismatch could be caught the same way. It's tracked as
a scoped gap, not a design endpoint.
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.