Documentation / Développement / Explications / Autonomy, the SDK, and packaging

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 typeNodeBehavior, 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 registrymeridian-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

75 documents11 sectionssource : /docs · généré au build