Declare and install plugins
This recipe adds a plugin to a running Meridian instance: declare it in the
instance manifest and let boot resolve it, either from a registry (by version
range) or from a local build (path: override). It also covers PLUGINS_PATH,
the filesystem-only alternative that bypasses the manifest's plugins: list
entirely.
It assumes you already have an instance manifest (see First instance) and, if you're pulling from a registry, a reachable one (see Set up a plugin registry). For the full manifest schema, see Instance manifest.
Add a plugin from the registry
Make sure
registry:is set in the manifest, orMERIDIAN_REGISTRYis exported — the manifest key wins over the env var (apps/api/src/registry.ts,ensureInstancePlugins):apiVersion: meridian/instance-v1 registry: gs://meridian-plugins # or a local dir, or file:///srv/pluginsAdd an entry under
plugins:, keyed by the plugin's package name. The shorthand form is a bare semver range; the long form is an object with aversionkey (libs/shared/src/plugin/instance.ts,PluginRequirement):plugins: "@posos/demo": "^0.1.0" "@posos/geriatrie": { version: "0.1.0" }The range is resolved with the npm
semverpackage against the versions published on the registry — highest match wins (libs/plugin-cli/src/registry/registry.ts,ensurePlugin/maxSatisfying). Any rangesemveraccepts works (^,~,1.2.x,>=1.0.0 <2.0.0, an exact version,*).Optionally confirm a matching version is actually published before you restart:
meridian-plugin versions "@posos/demo" --registry gs://meridian-pluginsRestart the process. Resolution happens once at boot, before plugins are loaded (
apps/api/src/server/api.tsandapps/api/src/restate/server.tsboth callloadPlugins(await ensureInstancePlugins(instance))):pnpm --filter @meridian/api run server # catalog/API process pnpm --filter @meridian/api run restate # workflow engine processEach process resolves
plugins:independently. A version missing from the local cache is downloaded from the registry intoMERIDIAN_PLUGIN_CACHE(default<repo>/.data/plugins, relative paths resolved from the repo root); a version already cached is reused without contacting the registry. The resulting folder becomes an extra discovery root passed toloadPlugins.
Pin an exact version instead of a range
Use an exact version string ("1.4.2") instead of a range when you need
reproducible boots regardless of what gets published later — maxSatisfying
against a single exact version only matches that version. There's no separate
"pinning" syntax: it's the same version field, just a range with one match.
Override with a local path (skip the registry)
Use this for local development against an unpublished plugin, or a hotfix you don't want to push through the registry yet.
Build the plugin so it has a
dist/(meridian-plugin build, esbuild — see Build and bundle). The registry-based flow above downloads this same shape of folder;path:just points at one directly instead.Replace the version with a
path:for that plugin's entry:plugins: "@acme/vitals": { path: ../vitals/dist }A requirement declares
versionorpath, never both — the manifest schema rejects an entry that sets neither or sets both (libs/shared/src/plugin/instance.ts,PluginRequirement.refine).Relative paths are resolved from the repository root, not from the manifest file's own location — the same rule
INSTANCE_CONFIGitself uses (apps/api/src/registry.ts,fromRepoRoot).Restart the process. No registry is contacted for this entry at all — the path is used as-is, so nothing is downloaded and nothing is cached (
apps/api/src/registry.ts,ensureInstancePlugins: thereq.pathbranch returns before a registry store is even opened).
Load a plugin without declaring it (PLUGINS_PATH)
PLUGINS_PATH is independent of the manifest's plugins: list — it's a
discovery root the plugin host scans on every boot regardless of what the
instance declares (apps/api/src/plugins.ts, pluginRoots). Use it to mount
externally delivered plugins onto the host filesystem (on-prem, air-gapped,
container image layering) without touching the instance manifest at all. There
is no version resolution here: whatever is on disk at that path loads, as-is.
Point it at one or more compiled plugin directories — each one either a
dist/folder itself (containsplugin.yamldirectly) or a parent folder whose immediate subfolders each contain one. Separate multiple entries with a comma or the OS path-list delimiter (:on Linux/macOS,;on Windows):export PLUGINS_PATH=/opt/acme/plugins/vitals # or a parent of several plugin folders: export PLUGINS_PATH=/opt/acme/pluginsRestart the process:
PLUGINS_PATH=/opt/acme/plugins pnpm --filter @meridian/api run restatePLUGINS_PATHfolders are added alongside whatever the instance'splugins:resolved to; both kinds of roots are loaded the same way — there is no implicit in-repo root. See Plugin host loading for how discovery and load order work across these roots.
Verify
Watch the boot log: each resolved
plugins:entry logs its plugin name, resolved version, whether it was downloaded or served from cache (registry entries) or its local directory (path:entries), and a final line listing every plugin actually loaded (apps/api/src/registry.tsandapps/api/src/plugins.ts,loadPlugins).A plugin that is listed in
plugins:but cannot be resolved — no registry configured, range matches no published version, path doesn't exist — fails the boot loudly. Treat that the same as an invalid manifest: it's a configuration error, not a runtime warning.
Related
- Instance manifest — full
registry:/plugins:/ports:schema. - Environment variables —
MERIDIAN_REGISTRY,MERIDIAN_PLUGIN_CACHE,PLUGINS_PATH,INSTANCE_CONFIG. - Set up a plugin registry — stand up the registry this doc pulls from.
- Publish to the registry
and Build and bundle — the
author side: producing and publishing the
dist/these mechanisms consume. - Bundling and distribution — why published versions are immutable and what's inside a built plugin.
- Plugin host loading — how discovery roots, load order, and SDK compatibility checks work.
- First instance — creating the manifest this recipe edits.