乐于分享
好东西不私藏

重读OpenClaw:Plugin SDK深度解析

重读OpenClaw:Plugin SDK深度解析

openclaw Plugin SDK 深度解析:从发现到调用的完整链路

这是「重读 OpenClaw」系列的第三篇,读完这篇,你将彻底理解 openclaw 的插件系统是如何工作的——插件怎么被发现、怎么加载、怎么注册、怎么被 Agent 调用。每一步的输入输出、每个关键对象的结构和作用、闭包绑定机制、handlers 传输容器模式、Factory 延迟实例化、按需激活的两层决策架构,全部讲清楚。


一、Plugin 在 openclaw 中是什么地位?

先说一个可能颠覆认知的事实:openclaw 几乎所有功能都是插件

它不是”框架 + 可选插件”的架构,而是”框架只是个骨架,器官全是插件”的架构。以下这些全是 plugin:

类别
插件举例
Model Provider
anthropic, openai, deepseek, moonshot, groq, ollama…
Channel(聊天平台)
discord, slack, telegram, whatsapp, line, irc, matrix, signal, imessage, feishu, qqbot…
Web Search
brave, duckduckgo, exa, searxng, firecrawl, perplexity…
Speech/TTS
elevenlabs, azure-speech, deepgram, senseaudio…
Memory
memory-core, memory-lancedb, memory-wiki…
Image/Video
fal, comfy, runway, pixverse…
Tool
browser, canvas, file-transfer, llm-task, phone-control…

extensions/ 目录下有 100+ 个插件,每个都是一个独立的目录,包含 openclaw.plugin.json + TypeScript 源码。

所以 Plugin 不是 openclaw 的”附加功能”,而是它的第一公民。理解 Plugin 机制,就是理解 openclaw 本身。


二、一个 Plugin 包里有什么?

2.1 物理结构

每个插件是 extensions/<name>/ 下的一个目录,最少包含两个文件:

extensions/brave/
├── openclaw.plugin.json    ← 静态 manifest(必须)
└── src/
    └── index.ts            ← 入口文件,导出 register()

2.2 openclaw.plugin.json —— 插件的”静态档案”

以 Brave Search 插件为例:

{
  "id"
: "brave",
  "name"
: "Brave",
  "description"
: "Brave Search provider plugin",
  "activation"
: {
    "onStartup"
:false
  }
,
  "setup"
: {
    "providers"
: [{
      "id"
: "brave",
      "authMethods"
: ["api-key"],
      "envVars"
: ["BRAVE_API_KEY"]
    }
]
  }
,
  "contracts"
: {
    "webSearchProviders"
: ["brave"]
  }
,
  "configSchema"
: {
    "type"
: "object",
    "properties"
: {
      "webSearch"
: {
        "type"
: "object",
        "properties"
: {
          "apiKey"
: { "type": ["string", "object"] },
          "mode"
: { "type": "string", "enum": ["web", "llm-context"] }
        }

      }

    }

  }

}

这个 manifest 文件的完整字段类型是 PluginManifest,包含:

字段
作用
id
唯一标识
activation
激活策略:onStartup 是否随启动激活,onProviders/onChannels/onCommands 按需激活
contracts
静态能力声明:toolswebSearchProvidersspeechProviders 等
setup
设置向导元数据:auth 方式、环境变量
configSchema
插件配置的 JSON Schema
channels

 / providers
声明自己拥有哪些 channel/provider

2.3 入口文件 —— 必须导出 register 函数

框架对插件的要求极简——只需要导出一个 register 函数:

// types.ts 中的类型定义
export
 type OpenClawPluginModule =
  | OpenClawPluginDefinition          // { register?: (api) => void }
  | ((api: OpenClawPluginApi) => void); // 直接就是一个函数!

也就是说下面这种写法完全合法:

// 最简插件 —— 导出一个函数即可
export
 default function register(api: OpenClawPluginApi) {
  api.registerTool({ name: "hello", /*...*/ });
}

2.4 插件的完整生命周期

发现 (Discovery) → 加载 (Load) → 注册 (Register) → 激活 (Activate) → 退役 (Retire)
阶段
谁执行
做什么
Discovery discoverOpenClawPlugins()
扫描文件系统,找到 openclaw.plugin.json
Load loadPluginModule()
用 jiti 动态编译 TS,拿到 register 函数
Register register(api)
调用 api.registerTool() 等方法,写入 registry
Activate activatePluginRegistry()
把 registry 设为全局活跃
Retire cleanupPreviousPluginHostRegistry()
清理旧 registry 的 hooks/workers

下面我们按这个顺序,一步一步深入。


三、第一步:发现(Discovery)—— 怎么找到插件?

3.1 入口函数

loadOpenClawPlugins() 是插件的总入口(src/plugins/loader.ts),它做的第一件事就是调用 discoverOpenClawPlugins() 扫描文件系统。

3.2 扫描 5 个来源

来源
路径
说明
bundled <install>/extensions/
内置插件,随 openclaw 分发
workspace <cwd>/.openclaw/plugins/
项目级插件
global ~/.openclaw/plugins/
用户级插件
package node_modules/

 中的 openclaw-plugin-*
npm 包插件
bundle
外部 bundle 目录
外部生态插件(Claude Code / Codex / Cursor)

每个目录递归扫描,找到 openclaw.plugin.json,记录为一个 PluginCandidate

type PluginCandidate = {
  idHint
: string;          // 插件 id
  source
: string;          // 入口文件路径
  rootDir
: string;         // 插件根目录
  origin
: PluginOrigin;    // "bundled" | "workspace" | "global" | "package" | "bundle"
  packageName
?: string;    // npm 包名
  bundledManifest
?: PluginManifest; // manifest JSON 内容
};

3.3 输入 → 输出

输入: workspaceDir(工作目录), env(环境变量)
  ↓
discoverOpenClawPlugins()
  ↓
输出: PluginCandidate[](候选插件列表)

四、第二步:加载(Load)—— 怎么把 TypeScript 变成可执行代码?

4.1 jiti 即时编译

openclaw 的插件不需要预编译loadPluginModule() 使用 jiti(JIT Instant TypeScript)即时编译:

// loader.ts
mod = loadPluginModule("extensions/brave/src/index.ts");
// jiti 即时编译 TS → 执行顶层代码 → 返回模块导出

jiti 还负责解析 import ... from "openclaw/plugin-sdk" 这种导入——它被 alias 到真实的实现文件,不需要 node_modules 里真有这个包。

4.2 提取 register 函数

插件模块导出后,resolvePluginModuleExport() 从模块中提取 register 函数,支持三种导出格式:

  • • export default function register(api) {}
  • • export function register(api) {}
  • • 整个模块就是一个函数

五、核心概念:PluginRecord —— 插件的”身份证”

在加载每个插件之前,框架会为它创建一张”身份证”——PluginRecord

5.1 它是什么

PluginRecordsrc/plugins/registry-types.ts)是一个包含 50+ 字段 的对象,记录了插件的一切:

type PluginRecord = {
  // ===== 身份信息 =====

  id
: "brave";              // 唯一标识
  name
: "Brave";            // 人类可读名
  version
?: "1.0.0";
  description
?: "Brave Search provider plugin";
  packageName
?: string;     // npm 包名

  // ===== 来源信息 =====

  source
: "extensions/brave/src/index.ts";  // 入口文件路径
  rootDir
: "extensions/brave";             // 插件根目录
  origin
: "bundled";                       // 来源类型

  // ===== 状态信息 =====

  status
: "loaded" | "disabled" | "error";
  enabled
: true;
  error
?: string;           // 失败原因
  failedAt
?: Date;           // 失败时间
  failurePhase
?: "validation" | "load" | "register";

  // ===== 能力清单 =====

  toolNames
: ["web_search"];         // 注册了哪些 tool
  hookNames
: ["before_agent_start"];  // 注册了哪些 hook
  channelIds
: ["discord"];           // 注册了哪些 channel
  providerIds
: ["brave"];            // 注册了哪些 provider
  webSearchProviderIds
: ["brave"];
  // ... 20+ 个能力 ID 数组


  // ===== 合同(manifest 里声明的)=====

  contracts
: { webSearchProviders: ["brave"], tools: ["web_search"] };
};

5.2 它从哪里来

在 loader 的加载循环中,对每个候选插件调用 createPluginRecord()

// loader.ts
const
 record = createPluginRecord({
  id
: pluginId,                        // "brave"
  name
: manifestRecord.name ?? pluginId, // "Brave"
  source
: candidate.source,            // 入口文件路径
  rootDir
: candidate.rootDir,          // 插件根目录
  origin
: candidate.origin,            // "bundled"
  enabled
: enableState.enabled,        // true
  contracts
: manifestRecord.contracts, // { webSearchProviders: ["brave"] }
});

5.3 它在整个流程中的角色

PluginCandidate (文件系统扫描的原始信息)
     ↓  createPluginRecord()
PluginRecord     (规范化后的"身份证")
     ↓  存入 registry.plugins[]
     ↓  传给 createApi(record) → 绑定到所有 register* 方法
     ↓  随 registry 一起被激活

一句话:record 是插件的”户口本”,记录了它是谁、从哪来、处于什么状态、拥有哪些能力。它在 createPluginRecord() 时出生,在 createApi() 时被绑定到所有注册方法上,伴随插件的整个生命周期。


六、核心概念:Registry —— 插件的”管理者”

6.1 它是什么

PluginRegistrysrc/plugins/registry-types.ts)是一个包含 ~40 个数组 的纯数据容器:

type PluginRegistry = {
  plugins
: PluginRecord[];              // 所有插件的档案
  tools
: PluginToolRegistration[];      // agent 工具注册
  hooks
: PluginHookRegistration[];      // 生命周期钩子
  channels
: PluginChannelRegistration[]; // 聊天平台
  providers
: PluginProviderRegistration[]; // 模型提供商
  webSearchProviders
: [...];
  webFetchProviders
: [...];
  speechProviders
: [...];
  httpRoutes
: PluginHttpRouteRegistration[];  // HTTP 路由
  commands
: PluginCommandRegistration[];      // CLI 命令
  // ... 还有 30+ 个数组

  diagnostics
: PluginDiagnostic[];      // 错误/警告
};

6.2 它从哪里来

createEmptyPluginRegistry()src/plugins/registry-empty.ts)创建全空的 registry:

function createEmptyPluginRegistry(): PluginRegistry {
  return
 {
    plugins
: [],
    tools
: [],      // ← 最终 tool 会进这里
    channels
: [],
    hooks
: [],
    // ... 全部 40 个空数组

    diagnostics
: [],
  };
}

6.3 同一时间只有一个活跃的 registry

Registry 通过全局单例管理(src/plugins/runtime.ts):

const state: RegistryState = {
  activeRegistry
: PluginRegistry | null,   // 唯一的"主"registry
  activeVersion
: 0,
  httpRoute
: { registry, pinned, version },  // HTTP 路由可以 pin 到特定 registry
  channel
: { registry, pinned, version },    // Channel 也可以 pin
};
  • • 同一时间只有一个 activeRegistry
  • • HTTP 路由和 Channel 可以 pin 到不同的 registry
  • • 切换 registry 时,旧的被清理(retire),新的接管

七、第三步:注册(Register)—— createPluginRegistry() 内部发生了什么?

这是整个插件系统最核心的部分。我们从 createPluginRegistry() 被调用开始,一步步追踪。

7.1 第一步:创建空壳 + 闭包函数

// loader.ts:1898
const
 { registry, createApi, ... } = createPluginRegistry({
  logger, runtime,
  coreGatewayHandlers,
  activateGlobalSideEffects
: shouldActivate,
});

进入 createPluginRegistry()src/plugins/registry.ts):

function createPluginRegistry(registryParams) {
  // 1. 创建空壳 —— 40 个空数组

  const
 registry = createEmptyPluginRegistry();

  // 2. 在闭包内定义 ~30 个 register* 函数

  //    每个都能访问 registry!

  const
 registerTool = (record, tool, opts) => {
    // 校验 contracts.tools 声明

    // 构建 factory

    // registry.tools.push({ pluginId, factory, names, ... })

  };
  const
 registerChannel = (record, channel) => { ... };
  const
 registerHook = (record, events, handler, ...) => { ... };
  // ... 还有 20+ 个


  // 3. 定义 createApi —— 这是关键!

  //    把 record 绑定到 register* 函数上

  const
 createApi = (record, params) => {
    return
 buildPluginApi({
      handlers
: {
        registerTool
: (tool, opts) => registerTool(record, tool, opts),
        //               ↑ 闭包捕获了 record,插件不需要知道自己是谁

        registerChannel
: (ch) => registerChannel(record, ch),
        // ... 全部 50+ 个方法

      }
    });
  };

  // 4. 返回

  return
 { registry, createApi, ... };
}

此时内存状态:

registry = {
  tools: [], channels: [], hooks: [], ...  ← 全部空数组
}

闭包内(外部不可访问):
  registerTool = (record, tool, opts) => { registry.tools.push(...) }
  registerChannel = (record, ch) => { ... }
  createApi = (record, params) => { ... }  // 绑定函数

7.2 第二步:jiti 加载插件模块

// loader.ts:2358
mod = loadPluginModule("extensions/brave/src/index.ts");
// jiti 即时编译 TS → 返回 { default: function register(api) { ... } }

7.3 第三步:createApi(record) —— 绑定身份证到 API

// loader.ts:2718
const
 api = createApi(record, {
  config
: cfg,                    // 全局配置
  pluginConfig
: validatedConfig.value,  // 插件自己的配置段
  registrationMode
: "full",
});

这一步产生 OpenClawPluginApi 对象:

api = {
  id: "brave",
  name: "Brave",
  config: { ...全局配置... },
  pluginConfig: { webSearch: { apiKey: "BSA..." } },
  runtime: { logger, llm, tasks, state, ... },
  logger: { info, warn, error },

  // 以下每个方法都已绑定 brave 的 record:
  registerTool:            (tool, opts) => registerTool(braveRecord, tool, opts),
  registerChannel:         (ch)        => registerChannel(braveRecord, ch),
  registerWebSearchProvider: (p)      => registerWebSearchProvider(braveRecord, p),
  // ... 50+ 个方法
}

关键设计:插件代码里写 api.registerTool(myTool) 时,完全不需要传自己的 pluginId——record 已经被闭包捕获了,registerTool 内部自动知道 record.id === "brave"

7.3.1 为什么用 handlers 包装?—— 内部传输容器模式

你可能注意到了一个细节:createApi 里先把所有 register 方法包在 handlers 对象里,但最终 api 对象上并没有 handlers 属性。这是为什么?

看 buildPluginApi() 的实现(src/plugins/api-builder.ts):

export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi {
  const
 handlers = params.handlers ?? {};  // ← 取出 handlers

  // 每个方法从 handlers 里取出,没有就用 noop

  const
 api = {
    id
: params.id,
    config
: params.config,
    runtime
: params.runtime,

    registerTool
:            handlers.registerTool            ?? noopRegisterTool,
    registerChannel
:         handlers.registerChannel         ?? noopRegisterChannel,
    registerProvider
:        handlers.registerProvider        ?? noopRegisterProvider,
    // ... 50+ 个方法,逐个解包摊平

  };

  return
 attachPluginApiFacades(api);  // 再加 facade 层
}

handlers 在这里被”拆开”了——每个方法被逐个取出,用 ?? 提供了 noop 默认值,然后直接赋到 api 对象的顶层。最终的 api 上是平的 50+ 个方法,没有 handlers 属性。

为什么要多这一层包装?三个原因:

原因一:避免 50+ 个函数参数。buildPluginApi 需要接收 50+ 个 register 方法。如果不用 handlers 对象包装,函数签名会变成:

// ❌ 不可维护
function
 buildPluginApi(
  id, name, config, runtime, logger,
  registerTool,        // 第 7 个
  registerChannel,     // 第 8 个
  registerProvider,    // 第 9 个
  // ... 50+ 个参数

) { ... }

用 handlers: Partial<Pick<OpenClawPluginApi, "registerTool" | "registerChannel" | ...>>,一个对象装所有方法,类型安全、可选、易扩展。新增一个 register 方法只需加一行类型定义。

原因二:不同 registrationMode 传不同的 handler 集合。createApi 根据 registrationMode 决定传哪些 handler。例如 cli-metadata 模式只需要 registerCli,其他 49 个不需要:

handlers: {
  ...(registrationCapabilities.capabilityHandlers ? {
    // full 模式:传全部 50+ 个

    registerTool
: (tool, opts) => registerTool(record, tool, opts),
    registerChannel
: (ch) => registerChannel(record, ch),
    // ... 全部

  } : {}),
  // setup-runtime 模式:只传 channel 相关的几个

  ...(registrationCapabilities.setupRuntimeHandlers ? {
    registerChannel
: (ch) => registerChannel(record, ch),
  } : {}),
}

原因三:优雅降级。 没有在 handlers 里传的方法,buildPluginApi 自动用 ?? 降级为 noop(空函数)。插件如果误调了一个不适用的 register 方法,不会报错,只是静默丢弃。这在 discovery 和 tool-discovery 等受限模式下特别重要——那些模式下很多能力确实不应该注册。

一句话:handlers 是 createApi 和 buildPluginApi 之间的内部传输容器,作用是让调用方只传需要的 handler,其余自动 noop。buildPluginApi 收到后逐个解包摊平到 api 对象顶层,handlers 用完即弃,不出现在最终产物上。

7.4 第四步:mod.register(api) —— 插件代码执行

// loader.ts:2740
runPluginRegisterSync
(register, api);
// 等价于: register(api)

插件代码执行,调用 api 上的各种 register 方法:

function register(api) {
  // api 已经绑定到 braveRecord


  api.registerWebSearchProvider({
    id
: "brave",
    search
: async (params) => { /* HTTP 请求 */ }
  });
  // → registerWebSearchProvider(braveRecord, { id:"brave", ... })

  // → registry.webSearchProviders.push({ pluginId:"brave", ... })


  api.registerTool((ctx) => ({
    name
: "web_search",
    description
: "Search the web",
    parameters
: Type.Object({ query: Type.String() }),
    execute
: async (params) => { /* 调 brave search */ }
  }));
  // → registerTool(braveRecord, factory, undefined)

}

7.5 第五步:registry.tools.push() —— 写入 registry

registerTool 内部(src/plugins/registry.ts):

const registerTool = (record, tool, opts) => {
  // 1. 校验:manifest 的 contracts.tools 必须声明了 tool 名

  const
 declaredNames = normalizePluginToolContractNames(record.contracts);
  // brave 的 manifest: "contracts": { "tools": ["web_search"] }


  // 2. 统一成 factory 格式

  const
 factory = typeof tool === "function"
    ? tool                           // 已经是 factory
    : (_ctx) => tool;                // 包装成 factory

  // 3. 写入 registry.tools[]

  registry.tools.push({
    pluginId
: "brave",
    pluginName
: "Brave",
    factory,                     // ← 工具工厂函数
    names
: ["web_search"],       // ← Agent 用这个名字调用
    declaredNames
: ["web_search"],
    optional
: false,
    source
: "extensions/brave/src/index.ts",
  });

  // 4. 更新 record 的能力清单

  record.toolNames.push("web_search");
};

此时 registry.tools[] 内存状态:

registry.tools = [
  {
    pluginId: "brave",
    pluginName: "Brave",
    factory: (ctx) => ({ name:"web_search", execute: [Function], ... }),
    names: ["web_search"],
    source: "extensions/brave/src/index.ts",
  },
  // ... 其他插件注册的 tool
]

7.6 第六步:全部插件加载完毕

// loader.ts:2851
if
 (shouldActivate) {
  activatePluginRegistry
(registry, cacheKey, runtimeSubagentMode, options.workspaceDir);
}
return
 registry;

7.7 整个注册过程的输入→输出串讲

Step 1: PluginLoadOptions
  → loadOpenClawPlugins()
  → discoverOpenClawPlugins() → PluginCandidate[]
  → loadPluginManifestRegistry() → PluginManifestRecord[]
  → createPluginRegistry() → { registry (空壳), createApi }

Step 2: for each candidate:
  → createPluginRecord(candidate, manifest) → PluginRecord

Step 3: createApi(record, {config, pluginConfig})
  → 闭包绑定 record 到所有 register* 方法
  → OpenClawPluginApi

Step 4: mod.register(api)
  → 插件代码调 api.registerTool(), api.registerChannel() 等
  → 每个调用进入对应的 register* 闭包

Step 5: registerTool(record, tool)
  → registry.tools.push({ pluginId, factory, names, ... })

Step 6: activatePluginRegistry(registry)
  → globalThis[...].activeRegistry = registry
  → 所有 tool/channel/hook 全局可见

八、核心概念:Factory —— 为什么 tool 不能直接注册?

8.1 问题:启动时注册 vs 运行时上下文

假设没有 factory,插件只能这样注册:

// ❌ 静态注册 —— 在启动时就创建好了 tool 对象
api.registerTool({
  name
: "web_search",
  execute
: async (params) => {
    // 问题:这里拿不到 sessionKey、拿不到 agentId、拿不到最新的 config

    // 因为这个对象在启动时创建,但 session 是后来才有的

  }
});

8.2 解决方案:Factory 模式

Factory 是一个函数,注册时只存函数,调用时才生产 tool

// ✅ factory 模式
api.registerTool((ctx) => ({
  name
: "web_search",
  execute
: async (params) => {
    // ctx.config       ← 最新全局配置

    // ctx.sessionKey   ← 当前会话

    // ctx.agentId      ← 当前 agent

    // ctx.workspaceDir ← 工作目录

    // ctx.sandboxed    ← 是否沙箱

    // ctx.fsPolicy     ← 文件系统权限

  }
}));

8.3 Factory 的类型定义

// src/plugins/tool-types.ts
type
 OpenClawPluginToolFactory = (
  ctx
: OpenClawPluginToolContext,
) =>
 AnyAgentTool | AnyAgentTool[] | null | undefined;

输入OpenClawPluginToolContext(运行时上下文,包含 20+ 个字段)
输出:真正的 AnyAgentTool(或数组,或 null 表示当前上下文不注册)

8.4 OpenClawPluginToolContext —— Factory 收到的上下文

type OpenClawPluginToolContext = {
  config
?: OpenClawConfig;          // 全局配置(可能过期)
  runtimeConfig
?: OpenClawConfig;   // 当前运行时配置快照
  getRuntimeConfig
?: () => OpenClawConfig | undefined;  // 获取最新配置

  // 会话信息

  sessionKey
?: string;              // 会话标识
  sessionId
?: string;               // 会话 UUID(/new 时会刷新)

  // Agent 信息

  agentId
?: string;                 // 当前 agent ID
  agentDir
?: string;                // agent 目录
  workspaceDir
?: string;            // 工作空间目录

  // 安全上下文

  fsPolicy
?: ToolFsPolicy;          // 文件系统权限策略
  sandboxed
?: boolean;              // 是否在沙箱中

  // 模型信息

  activeModel
?: {                   // 当前使用的模型
    provider
?: string;
    modelId
?: string;
  };

  // 消息上下文

  messageChannel
?: string;          // 来自哪个 channel
  agentAccountId
?: string;
  requesterSenderId
?: string;       // 谁发的消息
  deliveryContext
?: DeliveryContext; // 投递路由
};

8.5 Factory 的四种返回形式

// 形式 1:返回单个 tool
api.registerTool((ctx) => ({ name: "t1", execute: ... }));

// 形式 2:根据上下文动态决定

api.registerTool((ctx) => {
  if
 (ctx.sandboxed) return null;  // 沙箱里不注册
  return
 { name: "t1", execute: ... };
});

// 形式 3:返回多个 tool

api.registerTool((ctx) => [
  { name: "t1", execute: ... },
  { name: "t2", execute: ... },
]);

// 形式 4:兼容模式(直接传 tool 对象,自动包装成 factory)

api.registerTool({ name: "t1", execute: ... });
// 等价于 api.registerTool((_ctx) => ({ name: "t1", execute: ... }));

8.6 Factory 在何时被调用?

不是在 register() 时,而是在 Agent 实际需要 tool 时。调用链(src/plugins/tools.ts):

function resolvePluginToolFactory(entry: PluginToolRegistration, ctx: OpenClawPluginToolContext) {
  return
 runWithPluginToolScope(entry, () =>
    wrapPluginToolFactoryResult
(entry, entry.factory(ctx)),
    //                                      ^^^^^^^^^^^^

    //                                      这里!调 factory

  );
}
Agent turn 开始
  → 构建 tool catalog
    → 遍历 registry.tools[]
      → 对每条: factory(ctx)  ← ctx 携带当前 session/agent/config
        → 返回 AnyAgentTool (name, description, parameters, execute)
          → 加入 catalog
  → Agent 推理
    → 决定调 tool_call("web_search", {query:"hi"})
      → 在 catalog 中找到 → 调 execute({query:"hi"})

一句话:factory 是”tool 模板的工厂函数”。注册时只存模板,调用时注入运行时上下文,动态生产出适应当前 session/config/agent 的 tool 实例。这解决了”启动时注册 vs 运行时上下文”的时间差矛盾。


九、第四步:激活(Activate)—— registry 变成全局可用

9.1 激活做了什么

所有插件 register 完毕后,loader 调用 activatePluginRegistry()

// src/plugins/runtime.ts
function
 setActivePluginRegistry(registry, cacheKey, subagentMode, workspaceDir) {
  const
 previousRegistry = state.activeRegistry;
  state.activeRegistry = registry;       // ← 写入全局单例
  state.activeVersion += 1;

  // 同步 HTTP 路由和 Channel 表面

  syncTrackedSurface
(state.httpRoute, registry, true);
  syncTrackedSurface
(state.channel, registry, true);

  // 建立 agent 事件桥

  syncPluginAgentEventBridge
();

  // 如果之前有活跃的 registry,清理它

  if
 (previousRegistry && previousRegistry !== registry) {
    cleanupRetiredPluginHostRegistry
(previousRegistry);
  }
}

9.2 按需激活 —— 不在启动时加载的插件,怎么被唤醒?

不是所有插件都在启动时激活。Brave 插件的 manifest 里写了 "onStartup": false,意味着它随 Gateway 启动时只被发现、不被加载——openclaw.plugin.json 的内容被读到内存,但 src/index.ts 从未被 jiti 执行,tool 和 provider 都不可用。

那么它什么时候被加载?答案是:当有人”点名”需要它时

整个按需激活机制分两层:决策层(纯读 manifest 元数据,零代码加载)和执行层(真正触发 loadOpenClawPlugins)。

第一层:决策 —— resolveManifestActivationPlan()

这个函数(src/plugins/activation-planner.ts)接收一个 trigger(”谁需要被激活?”),扫描所有已知插件的 manifest 元数据,找出能响应这个 trigger 的插件。整个过程只读 manifest JSON 字段,不 import 任何 TS 代码

Trigger 有 6 种:

type PluginActivationPlannerTrigger =
  | { kind: "command";      command: string }     // 用户敲了某命令
  | { kind: "provider";     provider: string }    // 需要某模型 provider
  | { kind: "channel";      channel: string }     // channel 收到消息
  | { kind: "route";        route: string }       // HTTP 路由被命中
  | { kind: "agentHarness"; runtime: string }     // agent harness 被引用
  | { kind: "capability";   capability: "provider"|"channel"|"tool"|"hook" };

匹配逻辑——每种 trigger 检查 manifest 的不同字段:

Trigger
检查的 manifest 字段
示例
{ kind: "provider", provider: "brave" } providers

activation.onProviderssetup.providers
manifest 里写了 "providers": ["brave"] → 匹配
{ kind: "channel", channel: "discord" } channels

activation.onChannels
manifest 里写了 "channels": ["discord"] → 匹配
{ kind: "command", command: "search" } activation.onCommands

commandAliases
manifest 里写了 "onCommands": ["search"] → 匹配
{ kind: "route", route: "api" } activation.onRoutes
manifest 里写了 "onRoutes": ["api"] → 匹配
{ kind: "capability", capability: "tool" } contracts.tools

activation.onCapabilities
manifest 里写了 "contracts": {"tools": [...]} → 匹配
{ kind: "agentHarness", runtime: "..." } activation.onAgentHarnesses
manifest 里写了 "onAgentHarnesses": [...] → 匹配

具体实现(src/plugins/activation-planner.ts):

function resolveManifestActivationPlan(params): PluginActivationPlan {
  // 遍历所有已发现插件的 manifest 记录(纯 JSON 元数据,不加载 TS 代码)

  const
 entries = registry.plugins.flatMap((plugin) => {
    // 检查这个插件的 manifest 字段是否匹配 trigger

    const
 reasons = listManifestActivationTriggerReasons(plugin, params.trigger);
    if
 (reasons.length === 0) return [];  // 不匹配,跳过
    return
 [{ pluginId: plugin.id, origin: plugin.origin, reasons }];
  });
  return
 { trigger, pluginIds: [...], entries, diagnostics: [] };
}

第二层:执行 —— 用 onlyPluginIds 只加载目标插件

拿到插件 ID 列表后,调用方再次调 loadOpenClawPlugins(),但这次传入 onlyPluginIds 限定范围。框架只加载这一个(或几个)插件,走完整流程。

以 Provider 被引用为例(src/plugins/providers.runtime.ts):

// 用户配置了 models.providers.brave,或 agent 需要调 brave 模型
function
 resolveExplicitProviderOwnerPluginIds(params, snapshot) {
  return
 params.providerRefs.flatMap((provider) => {
    // 第一步:纯读 manifest,找出谁拥有这个 provider(零代码加载)

    const
 plannedPluginIds = resolveManifestActivationPluginIds({
      trigger
: { kind: "provider", provider: "brave" },
      manifestRecords
: snapshot.manifestRegistry.plugins,
    });
    return
 plannedPluginIds;  // ["brave"]
  });
}

// 第二步:用 onlyPluginIds 只加载 brave 这一个插件

const
 loadOptions = buildPluginRuntimeLoadOptionsFromValues({...}, {
  onlyPluginIds
: ["brave"],  // ← 只加载这一个!
  activate
: true,
});
// loadOpenClawPlugins(loadOptions)

// → 走完整加载流程,但 for 循环只处理 brave 这一个候选

以 Channel 收到消息为例(src/plugins/channel-presence-policy.ts):

// Discord 消息到达,需要激活 discord 插件来处理
const
 candidateIds = channelIds.flatMap((channelId) =>
  resolveManifestActivationPluginIds
({
    trigger
: { kind: "channel", channel: "discord" },
    manifestRecords
: records,
  })
);
// → ["discord"] → loadOpenClawPlugins({ onlyPluginIds: ["discord"] })

完整时序:Brave 插件从”休眠”到”可用”

Gateway 启动

├─ loadOpenClawPlugins({ mode: "full" })
│   ├─ discoverOpenClawPlugins()
│   │   └─ 扫描到 brave 的 openclaw.plugin.json
│   │      读取 manifest → PluginManifestRecord {
│   │        id: "brave",
│   │        activation: { onStartup: false },  ← 不随启动激活
│   │        contracts: { webSearchProviders: ["brave"] },
│   │        providers: ["brave"]
│   │      }
│   │
│   └─ for (candidate of candidates) {
│         if (brave) {
│           // onStartup=false → enableState.enabled=false
│           record.status = "disabled"
│           record.error = "not enabled at startup"
│           registry.plugins.push(record)  ← 进了 registry 但是 disabled
│           continue  ← 跳过!不加载 TS 代码!
│         }
│       }
│   → activatePluginRegistry(registry)
│     brave 在 registry.plugins[] 里,但 status="disabled"
│     没有 brave 的 tool,没有 brave 的 provider

├─ 用户发消息 → Agent 决定用 web_search
│   │
│   └─ resolvePluginProviders({ providerRefs: ["brave"] })
│       │
│       ├─ resolveManifestActivationPluginIds({
│       │     trigger: { kind: "provider", provider: "brave" }
│       │     manifestRecords: [...所有 manifest 元数据...]
│       │   })
│       │   └─ 遍历 manifest 记录
│       │       brave.providers 里有 "brave"!→ reason = "manifest-provider-owner"
│       │       → 返回 ["brave"]
│       │
│       └─ loadOpenClawPlugins({
│             onlyPluginIds: ["brave"],  ← 只加载 brave!
│             activate: true
│           })
│           ├─ discoverOpenClawPlugins() → 找到 brave
│           ├─ for (candidate of candidates) {
│           │     if (candidate 不是 brave) continue  ← 只处理 brave
│           │     record = createPluginRecord(...)   ← 创建身份证
│           │     mod = loadPluginModule("extensions/brave/src/index.ts")
│           │     api = createApi(record, {...})
│           │     mod.register(api)
│           │       → api.registerWebSearchProvider(...)
│           │       → api.registerTool(web_search_factory)
│           │       → registry.tools.push({ pluginId:"brave", ... })
│           │   }
│           └─ activatePluginRegistry(registry)
│               → brave 的 tool 和 provider 现在可用了!

└─ Agent: tool_call("web_search", {query:"hi"})
    → getActivePluginRegistry().tools[] 里有 web_search 了 ✅
    → factory(ctx) → execute() → 返回结果

一句话:不在启动时加载的插件 = manifest 已读入内存但 TS 代码未执行。当 trigger 发生时,resolveManifestActivationPlan() 纯读 manifest 字段找出需要哪些插件(零 I/O、零代码加载),然后 loadOpenClawPlugins({ onlyPluginIds: [...] }) 只加载这几个插件,走完整编译→注册→激活流程。


十、第五步:Agent 调用 Tool —— 完整链路

10.1 Agent 怎么发现 tool?

Agent 通过 Tool Search 系统(src/agents/tool-search.ts)发现和调用 tool。系统注入 4 个 meta-tool:

Meta-tool
作用
tool_search
搜索可用工具(输入关键词,返回匹配的工具列表)
tool_describe
获取某工具的完整参数 schema
tool_call
实际调用工具(传入 toolName + input)
tool_search_code
在 code mode 下搜索工具

10.2 完整调用链

1. Agent turn 开始
   → Tool Search 调 getActivePluginRegistry()
   → 遍历 registry.tools[]
   → 对每条: resolvePluginToolFactory(entry, ctx)
     → entry.factory(ctx)  ← 注入当前运行时上下文
     → 返回 AnyAgentTool
   → 加入 tool catalog

2. Agent 推理
   → 决定: tool_call({ toolName: "web_search", input: { query: "hello" } })

3. Tool Search 处理 tool_call
   → 在 catalog 中找到匹配条目
   → 调 tool.execute({ query: "hello" })
   → execute 内部发 HTTP 请求到 Brave Search API
   → 返回搜索结果

4. 结果返回 Agent
   → Agent 基于搜索结果继续推理

10.3 Tool 的执行方式

Tool 的执行方式有多种:

执行方式
说明
本地代码
factory 返回的 execute 就是 TypeScript 函数,jiti 已经编译过,直接调
HTTP 调用
如 brave search 的 execute() 里发 HTTP 请求
MCP 调用
tool-search 支持 CatalogSource = "mcp",MCP tool 走 MCP 协议
CLI 调用
通过 api.registerCliBackend() 注册,tool_call 时 fork 子进程

十一、槽位机制 —— 互斥能力如何管理?

有些能力同一时间只能有一个插件占据。openclaw 有两种”排他性槽位”:

// src/plugins/slots.ts
const
 SLOT_BY_KIND = {
  memory
: "memory",                     // 同一时间只有一个 memory 插件
  "context-engine"
: "contextEngine",    // 同一时间只有一个 context engine
};

冲突解决策略

策略
说明
同 ID 去重
bundled > workspace > global > package > bundle
Hook 同名冲突
后注册的失败,报错 hook already registered
Gateway Method 冲突
后注册的失败,报错 gateway method already registered
HTTP Route 重叠
检测路径重叠,报错
信任边界
非 bundled 且非显式启用的插件不能注册敏感能力

十二、Bundle 机制 —— 跨生态兼容

Bundle 是 openclaw 加载外部生态插件的机制。它支持三种外部格式:

type PluginBundleFormat = "codex" | "claude" | "cursor";

工作原理

  1. 1. 读取外部 manifest(如 .claude-plugin/plugin.json
  2. 2. 探测目录结构(skills/、commands/、hooks/、.mcp.json 等)
  3. 3. 生成标准化的 BundlePluginManifest
  4. 4. openclaw 将外部插件的 skills/commands/MCP servers 映射到自己的对应能力上

能力映射

能力
Codex
Claude
Cursor
skills
commands
agents
hooks
mcpServers
rules
settings

十三、总结:一张全景图

┌─ 发现 ───────────────────────────────────────────────────────┐
│ discoverOpenClawPlugins()                                      │
│   扫描 5 个来源 → PluginCandidate[]                            │
│   加载 manifest → PluginManifestRecord[]                       │
└──────────────────────────────────────────────────────────────┘
                            ↓
┌─ 创建 Registry ──────────────────────────────────────────────┐
│ createPluginRegistry()                                         │
│   → createEmptyPluginRegistry()  // { tools:[], channels:[], }│
│   → 定义 30 个 register* 闭包函数                              │
│   → 定义 createApi = (record) => bind(record, register*们)    │
│   → return { registry, createApi, ... }                       │
└──────────────────────────────────────────────────────────────┘
                            ↓
┌─ 加载循环 ───────────────────────────────────────────────────┐
│ for each candidate:                                            │
│   → createPluginRecord(candidate, manifest) → PluginRecord    │
│   → loadPluginModule(source) → jiti 编译 TS → 拿到 register  │
│   → api = createApi(record, {config, pluginConfig})           │
│       → 闭包绑定: api.registerTool = (t) => registerTool(rec,t)│
│   → mod.register(api)                                         │
│       → 插件代码: api.registerTool(factory)                   │
│       → registerTool(record, factory)                         │
│       → registry.tools.push({ pluginId, factory, names, ... })│
│   → registry.plugins.push(record)                             │
└──────────────────────────────────────────────────────────────┘
                            ↓
┌─ 激活 ───────────────────────────────────────────────────────┐
│ activatePluginRegistry(registry)                               │
│   → globalThis[...].activeRegistry = registry                 │
│   → 所有 tool/channel/hook 全局可见                            │
└──────────────────────────────────────────────────────────────┘
                            ↓
┌─ 运行时调用 ─────────────────────────────────────────────────┐
│ Agent: tool_call({ name: "web_search", args: {query:"hi"} }) │
│   → getActivePluginRegistry().tools[]                         │
│   → factory(ctx) → AnyAgentTool                               │
│   → tool.execute({ query: "hi" })                             │
│   → 返回结果给 Agent                                          │
└──────────────────────────────────────────────────────────────┘

关键设计原则

  1. 1. 数据与操作分离registry 是纯数据容器(struct),操作是闭包函数
  2. 2. 通过 createApi 绑定:把抽象的 registerTool(record, tool) 变成具体的 api.registerTool(tool)record 被闭包捕获
  3. 3. handlers 传输容器createApi 和 buildPluginApi 之间用 handlers 对象传递方法,支持按 registrationMode 选择性传参,未传的方法自动降级为 noop
  4. 4. 注册 ≠ 可用registry.tools.push() 只是写入数据,Agent 能看到必须等 activatePluginRegistry()
  5. 5. Factory 延迟实例化:注册时存工厂函数,调用时注入运行时上下文,解决启动时 vs 运行时的时间差
  6. 6. 两级按需激活:决策层(resolveManifestActivationPlan 纯读 manifest) + 执行层(onlyPluginIds 限定范围重新加载),零 I/O、零代码加载即可完成激活决策
  7. 7. 来源优先级去重:同 ID 插件按 bundled > workspace > global > package > bundle 优先级去重

本文基于 openclaw 源码分析,核心文件:

  • • src/plugins/discovery.ts — 插件发现
  • • src/plugins/loader.ts — 插件加载主流程
  • • src/plugins/registry.ts — Registry 创建与注册
  • • src/plugins/registry-types.ts — 类型定义
  • • src/plugins/types.ts — OpenClawPluginApi 类型
  • • src/plugins/tool-types.ts — Factory 与 ToolContext 类型
  • • src/plugins/api-builder.ts — handlers 解包与 api 对象组装
  • • src/plugins/activation-planner.ts — 按需激活决策引擎
  • • src/plugins/runtime.ts — 全局单例与激活
  • • src/plugins/slots.ts — 槽位机制
  • • src/plugins/bundle-manifest.ts — Bundle 格式转换
  • • src/agents/tool-search.ts — Agent Tool 搜索与调用
  • • src/plugin-sdk/ — 对外暴露的 SDK 类型