Skip to content

Plugins: directory convention and discovery

源码版本v2026.6.11

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:

typescript
/** 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:

typescript
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:

typescript
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

Data flow

Discovery starts from multiple roots (discovery.ts discoverOpenClawPlugins:L1432-L1510):

typescript
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):

typescript
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:

typescript
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-plugin are 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 to BundlePluginManifest.
  • 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.paths only warns instead of double-scanning (src/plugins/discovery.ts:L1459-L1470). Run openclaw doctor --fix to auto-clean redundant config.
  • cache reuse restores side effects: on cache hit, more than just the registry is returned — restoreRegisteredAgentHarnesses / restorePluginCommands / restoreRegisteredEmbeddingProviders and a dozen other global restores (src/plugins/loader.ts:L1872-L1885) run. Missing one causes tools/commands to be "invisible".
  • activation-state separation: when shouldActivate is 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, createPluginModuleLoader is never called (src/plugins/loader.ts:L1904-L1908) — a common scenario in tests, saving cold-start cost.
  • diagnostics aren't lost: both PluginDiscoveryResult and PluginRegistry carry a diagnostics: PluginDiagnostic[] array; warnings / errors from discovery / loading ride all the way to runtime. openclaw doctor reads these diagnostics and suggests fixes.
  • plugins aren't tools: plugins can register tools, but a plugin itself is a package. PluginRegistry.tools is an array of tools, each PluginToolRegistration referencing the plugin ID + tool definition — these tools ultimately merge into the Tool system Map. Similarly, a plugin can carry a skills/SKILL.md field (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.