Plugins: directory convention and discovery
Responsibilities
A plugin is OpenClaw's extension package — an npm package or source directory laid out by directory convention, declaring which tools, skills, channels, providers, commands, hooks, and RPC methods it provides. At startup the system scans multiple root directories to discover candidate plugins, loads them, and merges them into a PluginRegistry. The main loop, gateway, and agent session then pull extension capabilities from this registry.
The plugin system itself doesn't "execute" business logic — it does three things: scan multiple roots for candidates, parse manifests and bundle formats, and register into a unified registry. What actually does work are the tools, providers, and channels that the plugin registers — once loaded into PluginRegistry, they sit alongside capabilities from other sources (built-in, SDK custom) as equals.
OpenClaw also supports several bundle formats: .codex-plugin/plugin.json (Codex style), .claude-plugin/plugin.json (Claude style), .cursor-plugin/plugin.json (Cursor style), plus native plugin.json. This lets OpenClaw directly reuse plugin packages from other ecosystems without forcing authors to repackage.
Design motivation
Why directory convention over explicit imports? Because plugin sources vary wildly: some are npm packages, some are git-cloned source directories, some are bundled dist artifacts, some are folders the user dropped manually into ~/.openclaw/plugins/. Explicit imports require path and module-system readiness, while directory convention only needs "a manifest file exists in this directory" to identify a plugin. This lets discovery be pure filesystem operations without loading modules — module loading is expensive (Node ESM resolution, possibly tsx compilation), and separating identification from loading dramatically speeds up cold start.
bundle-manifest.ts (bundle-manifest.ts relative path constants:L21-L24) defines the relative paths for the three bundle formats:
/** Relative manifest path for Codex-style plugin bundles. */
export const CODEX_BUNDLE_MANIFEST_RELATIVE_PATH = ".codex-plugin/plugin.json";
export const CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH = ".claude-plugin/plugin.json";
export const CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH = ".cursor-plugin/plugin.json";Note they're all hidden subdirectories inside the package + plugin.json — so a plugin package can be an ordinary npm package with package.json, README.md, and source code, while carrying a .codex-plugin/plugin.json declaring the parts OpenClaw cares about (skills / hooks / capabilities).
The BundlePluginManifest type (src/plugins/bundle-manifest.ts:L27-L39) normalizes the formats:
export type BundlePluginManifest = {
id: string;
name?: string;
description?: string;
version?: string;
skills: string[];
settingsFiles?: string[];
hooks: string[];
bundleFormat: PluginBundleFormat;
activation?: PluginManifestActivation;
capabilities: string[];
};The bundleFormat field marks the source format; skills / hooks / capabilities are the capability declarations OpenClaw really cares about — arrays of paths relative to the plugin root. This normalization lets downstream loaders ignore which ecosystem a plugin came from.
The loading phase loadOpenClawPlugins (loader.ts loadOpenClawPlugins:L1821-L1895) has several key designs: cache reuse (the same cacheKey returns the already-loaded registry, avoiding repeated module loading), activation-state separation (shouldActivate controls whether to clear prior runtime state; non-activating loads are read-only with no side effects), and lazy module loader creation (when all plugins are disabled, no module loader is created at all, saving cold-start cost).
PluginRegistry (registry-types.ts PluginRegistry:L434-L484) is a big object with one array per capability:
export type PluginRegistry = {
plugins: PluginRecord[];
tools: PluginToolRegistration[];
hooks: PluginHookRegistration[];
typedHooks: TypedPluginHookRegistration[];
channels: PluginChannelRegistration[];
channelSetups: PluginChannelSetupRegistration[];
providers: PluginProviderRegistration[];
modelCatalogProviders: PluginModelCatalogProviderRegistration[];
// ... another dozen or so provider types and registration items
gatewayHandlers: GatewayRequestHandlers;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
commands: PluginCommandRegistration[];
// ...
diagnostics: PluginDiagnostic[];
};Note it's not a Map, but an object bucketed by capability. The reason is that different capabilities have different registration dimensions: tools have name+executor, channels have channelId+pluginId, providers have api+providerId — forcing them into a Map<string, T> would require inventing a fake key. Arrays + diagnostics let warnings and errors from discovery / loading ride all the way to runtime rather than being dropped on load failure.
Key files
bundle-manifest.ts three formats:L21-L24— Codex / Claude / Cursor bundle relative path constants.bundle-manifest.ts BundlePluginManifest:L27-L39— normalized bundle manifest type.discovery.ts discoverOpenClawPlugins:L1432-L1510— discovery entry point, scans multiple roots + extraPaths.discovery.ts workspace constraint:L1484-L1505— workspace auto-discovery is constrained to the extensions root, avoiding accidentally scanning the whole workspace.discovery.ts discoverFromPath:L1210-L1259— single-path discovery: file / directory branch handling.loader.ts PluginLoadOptions:L181-L232— full load options, including cache / onlyPluginIds / mode etc.loader.ts loadOpenClawPlugins:L1821-L1903— load entry, cache reuse + activation-state separation.registry-types.ts PluginRegistry:L434-L484— registry type, bucketed by capability.registry-types.ts PluginToolRegistration:L76-L86— example tool registration item.discovery.ts bundled alias rejection:L1459-L1470— extraPaths pointing at the bundled root only warns instead of double-scanning.
Data flow
Discovery starts from multiple roots (discovery.ts discoverOpenClawPlugins:L1432-L1510):
export function discoverOpenClawPlugins(params: {
workspaceDir?: string;
extraPaths?: string[];
installRecords?: Record<string, PluginInstallRecord>;
ownershipUid?: number | null;
env?: NodeJS.ProcessEnv;
}): PluginDiscoveryResult {
const env = params.env ?? process.env;
const workspaceDir = normalizeOptionalString(params.workspaceDir);
const workspaceRoot = workspaceDir ? resolveUserPath(workspaceDir, env) : undefined;
const roots = resolvePluginSourceRoots({ workspaceDir: workspaceRoot, env });
// ...
for (const extraPath of extra) {
// ...
const bundledAlias = resolvePackagedBundledLoadPathAlias({
bundledRoot: roots.stock,
loadPath: resolveUserPath(trimmed, env),
});
if (bundledAlias) {
result.diagnostics.push({
level: "warn",
source: trimmed,
message: `ignored plugins.load.paths entry that points at OpenClaw's ${bundledAlias.kind} bundled plugin directory; remove this redundant path or run openclaw doctor --fix`,
});
continue;
}
discoverFromPath({ rawPath: trimmed, origin: "config", /* ... */ });
}Note the bundled alias detection — if a user points plugins.load.paths at OpenClaw's built-in bundled plugin directory, it only warns instead of double-scanning, because that directory is already in the scan range. This is a precaution against "double-loading the same plugin causing duplicate-name errors".
The workspace auto-discovery has a key comment (src/plugins/discovery.ts:L1489-L1505):
if (roots.workspace && workspaceRoot && !workspaceMatchesBundledRoot) {
// Keep workspace auto-discovery constrained to the OpenClaw extensions root.
// Recursively scanning the full workspace treats arbitrary project folders as
// plugin candidates and causes noisy "plugin manifest not found" validation failures.
discoverInDirectory({
dir: roots.workspace,
origin: "workspace",
// ...
});
}This is a past pitfall: if the whole workspace is recursively scanned, any ordinary node_modules subdirectory or source folder would be treated as a plugin candidate, then fail with "plugin manifest not found" — noise that drowns out real errors. So workspace discovery is constrained to the extensions root.
The load phase (loader.ts cache + activation:L1862-L1903) uses cache reuse + activation-state separation:
const cacheEnabled = options.cache !== false && options.resolveRawConfigEnvVars !== true;
if (cacheEnabled) {
const cached = getReusableCachedPluginRegistry({
cacheKey,
onlyPluginIds,
runtimeSubagentMode,
options,
});
if (cached) {
if (shouldActivate) {
restoreRegisteredAgentHarnesses(cached.state.agentHarnesses);
restorePluginCommands(cached.state.commands ?? []);
// ... restore a pile of runtime state
activatePluginRegistry(
cached.state.registry,
cached.cacheKey,
cached.runtimeSubagentMode,
options.workspaceDir,
);
}
return cached.state.registry;
}
}
pluginLoaderCacheState.beginLoad(cacheKey);
try {
if (shouldActivate) {
clearActivatedPluginRuntimeState();
}
const loadPluginModule = createPluginModuleLoader({
devSourceRoot,
pluginSdkResolution: options.pluginSdkResolution,
});Cache reuse has to restore more than just the registry: agentHarnesses, commands, compactionProviders, interactiveHandlers, embeddingProviders, and many other global side effects — because plugin loading registers these into global tables, and re-activation has to put them back in their places. clearActivatedPluginRuntimeState clears prior state before a fresh load to prevent last round's tools/commands from leaking into this round.
Bundle manifest parsing lives in bundle-manifest.ts load file:L92-L110: readRootStructuredFileSync reads JSON at the root boundary; on failure, returns { ok: false, error, manifestPath } instead of throwing.
Boundaries and failures
- three bundle formats coexist:
.codex-plugin/.claude-plugin/.cursor-pluginare all hidden subdirectories +plugin.json(src/plugins/bundle-manifest.ts:L21-L24). A plugin can declare multiple formats at once — the loader picks the first matching format and normalizes toBundlePluginManifest. - workspace discovery scope is limited: auto-discovery only scans the extensions root, not the whole workspace recursively (
src/plugins/discovery.ts:L1489-L1505). Otherwise it would explode with "plugin manifest not found" noise. - bundled directories don't double-scan: pointing at a bundled root in
plugins.load.pathsonly warns instead of double-scanning (src/plugins/discovery.ts:L1459-L1470). Runopenclaw doctor --fixto auto-clean redundant config. - cache reuse restores side effects: on cache hit, more than just the registry is returned —
restoreRegisteredAgentHarnesses/restorePluginCommands/restoreRegisteredEmbeddingProvidersand a dozen other global restores (src/plugins/loader.ts:L1872-L1885) run. Missing one causes tools/commands to be "invisible". - activation-state separation: when
shouldActivateis false, prior registered runtime state isn't cleared — this is designed for snapshot loading (validate mode / parallel discovery), avoiding accidental clearing of other plugins' commands. - lazy module loading: when all plugins are disabled,
createPluginModuleLoaderis never called (src/plugins/loader.ts:L1904-L1908) — a common scenario in tests, saving cold-start cost. - diagnostics aren't lost: both
PluginDiscoveryResultandPluginRegistrycarry adiagnostics: PluginDiagnostic[]array; warnings / errors from discovery / loading ride all the way to runtime.openclaw doctorreads these diagnostics and suggests fixes. - plugins aren't tools: plugins can register tools, but a plugin itself is a package.
PluginRegistry.toolsis an array of tools, eachPluginToolRegistrationreferencing the plugin ID + tool definition — these tools ultimately merge into the Tool system Map. Similarly, a plugin can carry askills/SKILL.mdfield (see Skills), but it itself is an extension package discovered by directory convention, not a skill itself.
Summary
Plugins are extension packages discovered by directory convention: hidden subdirectories like .codex-plugin/plugin.json declare capabilities; discovery scans multiple roots + extraPaths for candidates; the loader normalizes them into a PluginRegistry bucketed by capability. Cache reuse restores a dozen-plus global side effects; activation-state separation ensures snapshot loading doesn't clear prior runtime state. Three bundle formats let OpenClaw directly consume Codex / Claude / Cursor ecosystem plugins. Difference from Tools: tools are functions in a Map; plugins are one source that produces tools. Difference from Skills: skills are Markdown instructions; plugins can carry skills as content. Channel registration and RPC method registration go through the Channel registry path.
Official references: Plugins docs · README.