Plugin maintainer reference
Plugin load pipeline and registry
Discovery, safety gates, manifest-first metadata, the plugin cache boundary, and the registry that core reads from. Part of the Plugin architecture internals guide.
Load pipeline
At startup, OpenClaw does roughly this:
- discover candidate plugin roots
- read native or compatible bundle manifests and package metadata
- reject unsafe candidates
- normalize plugin config (
plugins.enabled,allow,deny,entries,slots,load.paths) - decide enablement for each candidate
- load enabled native modules: built bundled modules use a native loader; third-party local source TypeScript uses the emergency Jiti fallback
- call native
register(api)hooks and collect registrations into the plugin registry - expose the registry to commands/runtime surfaces
Safety gates run before runtime execution. Discovery blocks a candidate when:
- its resolved entry escapes the plugin root
- its path (or its root directory) is world-writable
- for non-bundled plugins, path ownership does not match the current uid (or root)
World-writable bundled directories get an in-place chmod repair attempt
first (npm/global installs can ship package dirs at 0777) before the gate
re-checks; ownership checks are skipped for bundled origin entirely.
Blocked candidates still carry their plugin id in the emitted diagnostic when one is known (including ids resolved from a manifest inside an otherwise-rejected directory), so config referencing that id sees a blocked plugin tied to a path-safety warning instead of an unrelated "unknown plugin" error.
Manifest-first behavior
The manifest is the control-plane source of truth. OpenClaw uses it to:
- identify the plugin
- discover declared channels/skills/config schema or bundle capabilities
- validate
plugins.entries.<id>.config - augment Control UI labels/placeholders
- show install/catalog metadata
- preserve cheap activation and setup descriptors without loading plugin runtime
For native plugins, the runtime module is the data-plane part. It registers actual behavior such as hooks, tools, commands, or provider flows.
Optional manifest activation and setup blocks stay on the control plane.
They are metadata-only descriptors for activation planning and setup discovery;
they do not replace runtime registration, register(...), or setupEntry.
Live activation consumers use manifest command, channel, and provider hints to
narrow plugin loading before broader registry materialization:
- CLI loading narrows to plugins that own the requested primary command
- channel setup/plugin resolution narrows to plugins that own the requested channel id
- explicit provider setup/runtime resolution narrows to plugins that own the requested provider id
- Gateway startup planning uses
activation.onStartupfor explicit startup imports; plugins without startup metadata load only through narrower activation triggers
The activation planner exposes both an ids-only API for existing callers and a
plan API for diagnostics. Plan entries report why a plugin was selected,
separating explicit activation.* hints from manifest-ownership fallback:
Reason (from activation.* hints) |
Reason (from manifest ownership) |
|---|---|
activation-agent-harness-hint |
— |
activation-capability-hint |
— |
activation-channel-hint |
manifest-channel-owner (channels) |
activation-command-hint |
manifest-command-alias (commandAliases) |
activation-provider-hint |
manifest-provider-owner (providers), manifest-setup-provider-owner (setup.providers) |
activation-route-hint |
— |
| — (hook trigger has no hint variant) | manifest-hook-owner (hooks), manifest-tool-contract (contracts.tools) |
That reason split is the compatibility boundary: existing plugin metadata keeps working, while new code can detect broad hints or fallback behavior without changing runtime loading semantics.
Request-time runtime preloads that ask for the broad all scope still derive
an explicit effective plugin id set from config, startup planning, configured
channels, slots, and auto-enable rules
(resolveEffectivePluginIds in src/plugins/effective-plugin-ids.ts). If that
derived set is empty, OpenClaw keeps the scope empty instead of widening to
every discoverable plugin.
Setup discovery prefers descriptor-owned ids such as setup.providers and
setup.cliBackends to narrow candidate plugins before falling back to
setup-api for plugins that still need setup-time runtime hooks. Provider
setup lists use manifest providerAuthChoices, descriptor-derived setup
choices, and install-catalog metadata without loading provider runtime. Explicit
setup.requiresRuntime: false is a descriptor-only cutoff; omitted
requiresRuntime keeps the legacy setup-api fallback for compatibility. If
more than one discovered plugin claims the same normalized setup provider or
CLI backend id, setup lookup refuses the ambiguous owner instead of relying on
discovery order. When setup runtime executes, registry diagnostics reject
undeclared provider and CLI backend registrations. CLI backend descriptors also
report missing runtime registrations; provider descriptors may stay
metadata-only while the setup module contributes other setup hooks.
Plugin cache boundary
One PluginCache owns plugin facts from first access until Gateway shutdown.
CLI preflight and startup progressively fill the same cache; later access fills
only facts not yet acquired. Its immutable metadata snapshot combines the installed index, manifests, owner maps, and available
discovery facts from every configured agent workspace. Disabled plugins remain
in the inventory so later enablement does not require discovery. Conflicting
plugin IDs from different workspace sources remain rejected.
Runtime readers use this PluginMetadataSnapshot, a derived PluginLookUpTable,
or an explicit manifest registry. Plugin scopes are in-memory projections;
config changes, account changes, and run workspace changes must not trigger
filesystem scanning, stat/realpath freshness polling, manifest rereads, or
hashing. Activation and runtime service generations can change while their
package metadata stays fixed. Account health and authentication state are not
part of the immutable package inventory.
The same cache generation prepares installed-index scope lookups, compiled model matching patterns, parsed install-record projections, and manifest fingerprints once per immutable index. Mutable management indexes remain uncached. Lookup methods and install-record results remain caller-owned; enablement and trust are evaluated from the current operation's policy rather than stored in these facts.
Outside a retained generation, reusing a loaded plugin requires the selected ID, origin, root, entry point, and artifact-selection inputs to agree. The loader records source/build selection and any executed setup entry on the loaded owner. Explicit manifest and discovery source selections, including admitted sidecars, also participate in the loader cache key. Raw discovery additionally needs matching load identity before active reuse because its manifest winners have not been established. Retained generations remain authoritative, including empty selections. Source/build views may share an owner only when the loader resolves them to the same execution root and entry under the retained preference. Path names or matching entry stems alone do not establish ownership. Runtime reuse and loading share the existing lifecycle-owned artifact facts and final execution step, including executed setup entries. Discovery and artifact identity retain their selected paths.
Bounded loaded-owner lookups retain the owner's artifact policy when no preference is specified. An explicit preference is checked; exact loader requests apply the full cache identity and cold-load defaults.
Provider lookup uses an explicit caller workspace first, then the workspace recorded by its metadata snapshot, including an explicitly shared-root scope. Only narrowed metadata views without a workspace field inherit the active workspace. The registry's existing load context retains its workspace so a request or active registry from another workspace cannot replace a prepared selection.
Provider hooks share the canonical provider registry selection. Declared providers reserve their names, while provider-triggered helpers still activate beside them. Required load owners and eligible hook receivers are captured separately in that selection. Declared provider owners need matching physical records and provider registrations before reuse; activation-only helpers may have no provider rows, but need a successfully completed runtime registration pass. A setup-only pass cannot prove their runtime contributions are complete. References without a static owner select only their matching runtime aliases within the requested scope. Loaded aliases can identify owners to reload, but only the selected registry's current registrations determine alias receivers. Loaded-only failover inspection never discovers or activates plugins. When it uses registry-owned metadata, the caller's discovery and policy inputs must match the recorded fingerprint. The loader captures normalized registration inputs, including paired source config, once; later metadata updates preserve that record. Ordinary lookups reuse callbacks only when those inputs match; retained generations stay authoritative. Model-reference parsing reads the same declared-owner facts from the registry or retained request scope, so a failed declared provider cannot be replaced by another provider's hook alias.
Provider auth aliases are normalized and indexed with the snapshot. Lookups select among those prepared candidates using the current workspace trust config; they do not cache trust decisions or credentials. Callers supplying a partial manifest view keep fresh per-call projection rather than sharing mutable metadata.
Explicit install, update, registry refresh, and doctor operations use isolated
generations of the same cache type, acquired after their lifecycle lease. They may inspect changed files and rebuild the persisted
installed index, but cannot clear or replace the running Gateway's inventory.
The new inventory takes effect after restart. The plugins.refresh RPC reports
restartRequired: true; with reload disabled, it leaves the running inventory
in place until a manual restart.
The shared cache owns checked file contents, parsed package and manifest data, bundle MCP/LSP/settings files, plugin skill paths, discovery paths, installed-index projections, compiled model policies, SDK aliases, artifact locations, and lazy module exports. Missing files and artifacts are facts too: they remain missing until a new generation. Discovery, registry assembly, and index hashing reuse the same checked bytes rather than reopening a file at each stage.
Actual code imports retain their boundary and file-identity checks before first execution. Consent checks use a fresh inspection after an awaited approval so changed artifacts cannot inherit approval for older capabilities. Failed module evaluation remains retryable; a successful import is shared across consumers.
The CLI invocation owns one operation cache across config reads, output metadata, command ownership, nested registration, and actions. Standalone registration uses its caller's active generation. Config validation covers every workspace; execution uses the original selected workspace snapshot, or shared roots when no workspace owner is proven. Exact config/source identities and revision checks fence retained registrars. Preparation closes before Commander actions, while its cache scope lasts through action completion for late imports. Changed package files require a new operation; changing activation inputs does not retire compatible package facts. SDK alias maps are prepared on first alias or transformer demand under their captured host and permission scope. State registration uses a light facade that the full runtime later adopts; only the registry proxy grants store access. Config reads import the writer only when an actual write begins.
Registered services, hooks, tools, session MCP overlays, generated skill-link publication, and activation state remain runtime-owned. An active registry pins its chosen artifact binding so source and built modules cannot split its registrations. Native ESM module lifetime still follows Node's module loader. Manifest-derived questions such as "which plugin owns this provider?" use the metadata snapshot without executing plugin code. The persisted installed index belongs to management and startup; it is not a freshness signal for runtime readers.
Registry model
Loaded plugins do not directly mutate random core globals. They register into a
central plugin registry (PluginRegistry in src/plugins/registry-types.ts),
which tracks plugin records (identity, source, origin, status, diagnostics)
plus arrays for every capability: tools, legacy hooks and typed hooks,
channels, providers, gateway RPC handlers, HTTP routes, CLI registrars,
background services, plugin-owned commands, and dozens more typed provider
families (speech, embeddings, image/video/music generation, web
fetch/search, agent harnesses, session actions, and so on).
Core features then read from that registry instead of talking to plugin modules directly. This keeps loading one-way:
- plugin module -> registry registration
- core runtime -> registry consumption
That separation matters for maintainability. It means most core surfaces only need one integration point: "read the registry", not "special-case every plugin module".