OpenClaw 设计与实现

第9章 插件与扩展系统

作者 杨艺韬 · 20,737 字 · 发布于 · 更新于

第9章 插件与扩展系统

平台的生命周期取决于一个残酷的比率:用户能扩展的能力 ÷ 用户必须 fork 源码的次数。当这个比率趋近于零,平台就死了。

本章要点

  • 理解插件系统的三层架构:注册表、加载器、运行时
  • 掌握插件生命周期钩子:从发现、加载到执行的完整链路
  • 深入插件 SDK:工具注册、HTTP 路由、CLI 命令扩展
  • 实战演练:从零开发一个天气预报插件

9.1 引言

让我们先回答一个根本问题——"为什么 OpenClaw 需要插件系统"?表面上看、插件系统是"技术架构"问题;深层上、它是"生态策略"问题。核心团队再强、也无法预见所有用户场景;核心代码再全、也无法支持所有可能的通道和工具。插件系统、是"把扩展权交给社区"的工程机制——让任何人都能为 OpenClaw 添加新能力、而不需要修改核心代码、不需要等官方发版、不需要和核心维护者协调。这种"开放的可扩展性"、让 OpenClaw 从"一个项目"有可能演化为"一个生态"。生态的力量、远大于任何单一团队的力量——Linux、WordPress、VS Code 都是这个规律的证明。

前两章,我们深入了通道系统的设计与实现。通道是 OpenClaw 连接外部世界的窗口,但窗户的数量终究有限。如果用户需要一扇你没有预装的窗户呢?

OpenClaw 的 CHAT_CHANNEL_ORDER 里内建了 9 个聊天通道(Telegram、WhatsApp、Discord、IRC、Google Chat、Slack、Signal、iMessage、LINE),另有 13 个通道只以扩展插件形式提供(这 22 个通道的实现其实都在 extensions/ 下),模型供应商插件也有三十多个。但如果你需要接入钉钉或企业微信这类仓库里根本没有的平台?如果你想用一个内部部署的自研模型?如果你需要一个自定义的安全审计钩子?

在没有插件系统的框架中,答案是:fork 源码,改核心代码,然后祈祷下次升级不会冲突。这条路走得通,但走得痛苦。

OpenClaw 的答案干脆利落:写一个插件,五分钟搞定。

当你需要的通道不在内置列表里时,你面对的是两条路:fork 源码(成本 O(n) 随升级线性增长),或者写一个插件(成本 O(1),一次投入永久受益)。插件系统的全部价值,就是让第一条路从"不得不走"变成"没人想走"。

🔥 深度洞察:生态系统的进化论

插件系统的成败,决定了一个平台是"产品"还是"生态"——这个区分关乎生死。iPhone 初代没有 App Store,它是一个优秀的产品;有了 App Store,它变成了一个生态系统。产品的价值由创造者决定,生态的价值由参与者共同创造。OpenClaw 的插件系统扮演的正是 App Store 的角色——它把 OpenClaw 从"一个人写的 Agent 运行时"变成"所有人都能扩展的 Agent 平台"。生物学中有个概念叫共生进化(coevolution):花朵进化出蜜腺吸引蜜蜂,蜜蜂进化出长喙采集花蜜,两者在互利中共同变得更好。插件系统就是 OpenClaw 与社区之间的共生接口——核心平台越稳定,插件越多;插件越多,核心平台越有价值。

关键概念:插件系统(Plugin System) 插件系统是 OpenClaw 实现"开闭原则"(对扩展开放,对修改封闭)的核心机制。通过统一的插件接口,开发者可以在不修改核心代码的前提下添加新通道、新工具、新 Provider 甚至新的 CLI 命令。随包分发的内置扩展(extensions/ 下的通道、Provider 等)和第三方插件走的是同一套注册接口——在接口层面没有"一等公民"和"二等公民"之分(差别只在默认启用规则和同名时的优先级,见 9.12)。

插件系统是 OpenClaw 可扩展性的基石。通过插件,开发者可以添加新通道、新模型供应商、新工具、新钩子、新 HTTP 路由、新 CLI 命令——而不需要修改核心代码的一行一字。本章将深入 src/plugins/、src/plugin-sdk/ 和 src/extensionAPI.ts 的实现,揭示这套系统如何在开放性与安全性之间走钢丝——既要敞开大门迎接创新,又要守住底线不放风险进门。

graph TB
    subgraph PluginSystem["插件系统核心"]
        LOADER[Plugin Loader]
        REGISTRY[Plugin Registry]
        RUNTIME[Plugin Runtime]
    end
    subgraph PluginTypes["插件类型"]
        CHANNEL[Channel Plugins]
        PROVIDER[Provider Plugins]
        TOOL[Tool Plugins]
        HOOK[Hook Plugins]
        SERVICE[Service Plugins]
    end
    subgraph ExtensionPoints["扩展点"]
        GATEWAY[Gateway Methods]
        HTTP[HTTP Routes]
        CLI[CLI Commands]
        ACTIONS[Message Actions]
    end
    LOADER --> REGISTRY
    REGISTRY --> RUNTIME
    RUNTIME --> PluginTypes
    PluginTypes --> ExtensionPoints
    style PluginSystem fill:#e3f2fd
    style PluginTypes fill:#f3e5f5
    style ExtensionPoints fill:#e8f5e9

图 9-1:OpenClaw 插件系统架构

9.2 插件类型与定义

"插件类型"是插件系统的"分类学"——它决定了用户能扩展哪些方面、不能扩展哪些方面。OpenClaw 的插件类型覆盖了核心扩展点——通道、Provider、工具、钩子、HTTP 路由、CLI 命令。这些类型的选择、反映了 OpenClaw 对"什么是可扩展的"的判断。值得注意的是——插件类型不是越多越好。过多的扩展点会让插件系统本身变得复杂、也会增加核心与插件的耦合面、使得核心演化变得困难。OpenClaw 选择的这几大类扩展点、覆盖了绝大多数常见的扩展场景、同时保持了核心的相对独立——这是一个精心的工程平衡。

9.2.1 OpenClawPluginDefinition

每个插件通过 OpenClawPluginDefinition 定义其身份和功能:

// src/plugins/types.ts:1662
export type OpenClawPluginDefinition = {
  id?: string;
  name?: string;
  description?: string;
  version?: string;
  kind?: PluginKind | PluginKind[];
  configSchema?: OpenClawPluginConfigSchema;
  register?: (api: OpenClawPluginApi) => void | Promise<void>;
  activate?: (api: OpenClawPluginApi) => void | Promise<void>;
};

这个定义比想象中"小"——只有八个字段,而且连 id 和 name 都是可选的:插件的身份以 manifest(openclaw.plugin.json,见 9.12.3)里必填的 id 为准,导出里若也写了 id 且与 manifest 不一致,加载器会报 plugin id mismatch 并拒绝加载该插件;name 缺省时沿用 manifest 的 name,再缺省则用 id。注意插件模块本身还可以直接导出一个函数:

export type OpenClawPluginModule =
  | OpenClawPluginDefinition
  | ((api: OpenClawPluginApi) => void | Promise<void>);

也就是说,最短的插件入口就是一行 export default (api) => api.registerTool(myTool)——当然,旁边还得放一份 openclaw.plugin.json,否则加载器根本不会认它。

kind 字段用于两类特殊插件(export type PluginKind = "memory" | "context-engine"):

  • "memory" — 记忆存储插件(如 LanceDB)
  • "context-engine" — 上下文引擎插件

register 与 activate 都接收同一个 OpenClawPluginApi,但它们并不是两个阶段:加载器取的是 def.register ?? def.activate(src/plugins/loader.ts 的 resolvePluginModuleExport),只调用其中一个,activate 实际上是 register 的别名。类型上两者都允许返回 Promise,可加载器是同步调用的——如果返回了 Promise,只会记一条 plugin register returned a promise; async registration is ignored 警告,异步部分不会被等待。

9.2.2 插件注册 API

OpenClawPluginApi 是插件与系统交互的核心接口:

// src/plugins/types.ts:1680 — 插件与系统交互的核心 API(节选)
export type OpenClawPluginApi = {
  id: string;
  name: string;
  version?: string;
  source: string;                   // 入口文件路径
  rootDir?: string;
  registrationMode: PluginRegistrationMode;
  config: OpenClawConfig;           // 当前配置快照
  pluginConfig?: Record<string, unknown>;  // 本插件那一段配置
  runtime: PluginRuntime;           // 受控的系统访问入口
  logger: PluginLogger;

  // === 能力注册 ===
  registerTool: (tool, opts?) => void;
  registerHook: (events, handler, opts?) => void;
  registerHttpRoute: (params) => void;
  registerChannel: (registration) => void;
  registerGatewayMethod: (method, handler, opts?) => void;
  registerCli: (registrar, opts?) => void;
  registerService: (service) => void;
  registerCliBackend: (backend) => void;
  registerProvider: (provider) => void;
  registerSpeechProvider: (provider) => void;
  registerMediaUnderstandingProvider: (provider) => void;
  registerImageGenerationProvider: (provider) => void;
  registerWebSearchProvider: (provider) => void;
  registerInteractiveHandler: (registration) => void;
  registerCommand: (command) => void;
  registerContextEngine: (id, factory) => void;
  registerMemoryPromptSection: (builder) => void;
  registerMemoryFlushPlan: (resolver) => void;
  registerMemoryRuntime: (runtime) => void;
  registerMemoryEmbeddingProvider: (adapter) => void;
  // 另有 description?、onConversationBindingResolved、resolvePath,
  // 以及注册类型化钩子的 on(hookName, handler, opts?)(见 9.7.2)
};

在 v2026.3.31 上,OpenClawPluginApi 顶层共有 20 个 register* 方法(口径:src/plugins/types.ts 里该类型顶层以 register 开头的字段数)。可以看到扩展点的分布很不均匀——光"记忆"一个领域就占了 4 个(其中 3 个是同一时刻只能有一个插件占用的独占槽位)。

registrationMode 有四种值(PluginRegistrationMode):

export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime" | "cli-metadata";
  • "full" — 完整注册所有能力,正常运行时用
  • "setup-only" / "setup-runtime" — 安装向导路径,只要 schema 与安装期能力,不启动后台服务
  • "cli-metadata" — 只为列出 CLI 命令元数据而加载,最轻量

9.3 插件注册表(Plugin Registry)

"注册表"是所有插件系统的核心数据结构——它像一个"插件身份证数据库"、记录着系统里每个插件的存在、它们提供了什么、来自哪里。注册表的设计好坏、直接决定了插件系统的几个关键属性:查询效率(能否快速找到某个插件)、冲突检测(能否发现"两个插件想占用同一个名字")、诊断能力(能否在插件加载失败时给出清晰的错误信息)。OpenClaw 的注册表、在这三个维度上都做得不错——结构上就是一组按能力分开的数组(插件数量有限、线性查找足够)、冲突靠注册时按名字比对检测、诊断有专门的 diagnostics 收集器。这些看起来不起眼的细节、其实是插件系统"是否好用"的关键——一个没有冲突检测的注册表、在插件多起来后会变成调试噩梦;一个没有诊断能力的注册表、会让插件作者无法理解自己的插件为什么没加载成功。

9.3.1 注册表结构

PluginRegistry 存储所有已注册的插件组件:

// src/plugins/registry.ts:220 — 注册表存储所有插件组件
export type PluginRegistry = {
  plugins: PluginRecord[];                    // 已注册的插件列表
  tools: PluginToolRegistration[];            // 工具
  hooks: PluginHookRegistration[];            // 生命周期钩子(旧式)
  typedHooks: TypedPluginHookRegistration[];  // 生命周期钩子(类型化)
  channels: PluginChannelRegistration[];      // 通道
  channelSetups: PluginChannelSetupRegistration[];  // 通道安装向导
  providers: PluginProviderRegistration[];    // LLM 提供商
  cliBackends?: PluginCliBackendRegistration[];
  speechProviders: PluginSpeechProviderRegistration[];
  mediaUnderstandingProviders: PluginMediaUnderstandingProviderRegistration[];
  imageGenerationProviders: PluginImageGenerationProviderRegistration[];
  webSearchProviders: PluginWebSearchProviderRegistration[];
  gatewayHandlers: GatewayRequestHandlers;    // WebSocket 方法
  gatewayMethodScopes?: Partial<Record<string, OperatorScope>>;
  httpRoutes: PluginHttpRouteRegistration[];  // HTTP 路由
  cliRegistrars: PluginCliRegistration[];
  services: PluginServiceRegistration[];      // 后台服务
  commands: PluginCommandRegistration[];      // CLI 命令
  conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[];
  diagnostics: PluginDiagnostic[];            // 冲突/错误诊断
};

留意 hooks 与 typedHooks 并存:前者是历史的字符串事件钩子,后者是带类型约束的新形态,注册表同时保留两条通路以维持向后兼容。

注册项普遍带有 pluginId 和 source(源文件路径),工具、通道、Provider 等多数注册项还带 pluginName 与 rootDir,用于追溯插件来源。

9.3.2 注册函数实现

createPluginRegistry 创建一个注册表实例,提供各种 register* 方法:

// src/plugins/registry.ts:272 — 注册表工厂(简化:只展示 registerTool 核心逻辑)
export function createPluginRegistry(registryParams: PluginRegistryParams) {
  const registry = createEmptyPluginRegistry();

  const registerTool = (
    record: PluginRecord,
    tool: AnyAgentTool | OpenClawPluginToolFactory,
    opts?: { name?: string; names?: string[]; optional?: boolean },
  ) => {
    const names = opts?.names ?? (opts?.name ? [opts.name] : []);
    // 统一为工厂函数:静态工具对象 → 包装为 (_ctx) => tool
    const factory = typeof tool === "function" ? tool : (_ctx) => tool;
    if (typeof tool !== "function") names.push(tool.name);
    const normalized = names.map((name) => name.trim()).filter(Boolean);
    // ……normalized 同时记入 record.toolNames
    registry.tools.push({ pluginId: record.id, pluginName: record.name, factory,
      names: normalized, optional: opts?.optional === true,
      source: record.source, rootDir: record.rootDir });
  };

  // 类似的 registerHook, registerChannel, registerProvider, registerHttpRoute...
  // 多数都做同样的事:验证 → 冲突检测 → 推入 registry 对应数组
  return { registry, createApi, pushDiagnostic, registerTool, registerChannel, /* ... */ };
}

9.3.3 冲突检测

注册表会检测并记录冲突:

// src/plugins/registry.ts:393 — 冲突检测示例(Gateway 方法注册,节选)
const registerGatewayMethod = (record: PluginRecord, method: string, handler, opts?) => {
  const trimmed = method.trim();
  if (!trimmed) return;
  // 检测冲突:核心方法不可覆盖,已注册方法不可重复
  if (coreGatewayMethods.has(trimmed) || registry.gatewayHandlers[trimmed]) {
    pushDiagnostic({ level: "error", pluginId: record.id, source: record.source,
      message: `gateway method already registered: ${trimmed}` });
    return;  // 记录诊断但不崩溃——一个插件的错误不影响其他插件
  }
  registry.gatewayHandlers[trimmed] = handler;
  // ……opts.scope 记入 gatewayMethodScopes,方法名记入 record.gatewayMethods
};

冲突会被记录为 PluginDiagnostic,而不是抛出异常,确保一个插件的错误不会影响其他插件。

9.4 插件加载器(Plugin Loader)

"加载器"是插件系统中最容易被忽视、但最多坑的地方。看起来它只是"把一个 JS/TS 文件 require 进来"——但实际上涉及的问题包括:模块解析(找到插件文件)、版本兼容(插件是否匹配当前 OpenClaw 版本)、依赖处理(插件可能依赖其他插件)、错误隔离(一个插件加载失败不能拖垮整个系统)、热重载(不重启服务就能更新插件)。OpenClaw 的 Plugin Loader 在模块解析和错误隔离上下了最多功夫、特别是通过引入 jiti(一个 Node.js 运行时 TypeScript 编译器)让插件可以直接用 .ts 文件运行——这大大降低了插件开发的门槛(不需要单独构建,改完重启网关即可生效)。这种"开发者体验优先"的设计取舍、对插件生态的繁荣至关重要——毕竟、愿意写插件的人、常常是业余时间贡献的开发者、不能让他们觉得"写插件太麻烦"。

9.4.1 加载流程

入口函数是 loadOpenClawPlugins(src/plugins/loader.ts)。第一个反直觉的事实是:它是同步的——签名为 (options: PluginLoadOptions = {}) => PluginRegistry,没有 async、没有 Promise。从代码看这是刻意为之(异步 register 的返回值会被明确忽略并告警):同步加载省掉了整条"半初始化状态"的心智负担;能同步的原因则是 jiti 提供了同步的 require 式加载。

// src/plugins/loader.ts:814(骨架)
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
  // 快照式(不激活)加载必须禁用缓存,否则会污染全局命令注册表
  if (options.activate === false && options.cache !== false) {
    throw new Error(
      "loadOpenClawPlugins: activate:false requires cache:false to prevent command registry divergence",
    );
  }
  const { env, cfg, onlyPluginIds, shouldActivate, cacheKey, /* ... */ } =
    resolvePluginLoadCacheContext(options);

  if (cacheEnabled) {
    const cached = getCachedPluginRegistry(cacheKey);
    if (cached) {  }
  }
  if (shouldActivate) {
    clearPluginCommands(); clearPluginInteractiveHandlers(); clearMemoryPluginState();
  }

  // 惰性:所有插件都被禁用时(单测常见)根本不创建 Jiti loader
  const getJiti = createPluginJitiLoader(options);
  // 惰性:不到真正需要时不构造 PluginRuntime,避免把整棵通道依赖树拉进内存
  const resolveRuntime = (): PluginRuntime => { /* ... */ };
  // ... 发现 → 逐个加载 → 注册 → (可选)激活
}

PluginLoadOptions 里有几个关键开关值得记住:mode: "full" | "validate"(只做校验不真正注册)、onlyPluginIds(只加载指定插件)、cache、activate、throwOnLoadError(默认不抛,改为记诊断)。

⚠️ 注意:插件加载是顺序的、单线程的——src/plugins/loader.ts:999 是一个普通 for (const candidate of orderedCandidates) 循环,每个插件用 getJiti(safeSource)(safeSource) 同步加载,没有 Promise.all,也没有 await。但它是容错的:单个插件加载失败只会在 registry.diagnostics 里记一条诊断(throwOnLoadError 默认为假),不会阻止其他插件注册。部署后务必检查 openclaw plugins doctor(或 openclaw doctor)的输出,确认所有预期的插件都已成功加载。

💡 最佳实践:register 应该尽可能轻量——只做能力声明和回调注册(注意 activate 只是 register 的别名,不是另一个"激活阶段")。重量级初始化(如建立长连接、加载大型资源)放进 api.registerService({ id, start, stop }) 注册的后台服务里:服务由网关启动流程(src/gateway/server-startup.ts 调用 startPluginServices())统一拉起,不会拖慢加载,也不会在只为列 CLI 命令、做配置校验而加载插件时被触发。

flowchart TD
    A["🔄 loadOpenClawPlugins()"] --> B["📋 解析配置<br/>+ 缓存命中检查"]
    subgraph Discovery["🔍 四源发现 discoverOpenClawPlugins()"]
        B --> C1["origin=config<br/>plugins.load.paths"]
        B --> C2["origin=workspace<br/>&lt;工作区&gt;/.openclaw/extensions"]
        B --> C3["origin=bundled<br/>随包分发的 extensions/"]
        B --> C4["origin=global<br/>&lt;配置目录&gt;/extensions"]
    end
    C1 & C2 & C3 & C4 --> D["📦 顺序加载模块<br/>for 循环 + Jiti 同步编译"]
    D --> E{加载成功?}
    E -->|"✅ 是"| F["📝 注册到 Registry<br/>plugin.register(api)"]
    E -->|"❌ 否"| G["⚠️ 记录诊断<br/>跳过该插件,继续下一个"]
    subgraph Registration["注册能力"]
        F --> R1["registerChannel"]
        F --> R2["registerTool"]
        F --> R3["registerProvider"]
        F --> R4["registerHook"]
        F --> R5["registerHttpRoute"]
        F --> R6["registerService"]
    end
    R1 & R2 & R3 & R4 & R5 & R6 --> H{需要激活?}
    H -->|"是"| I["🚀 activatePluginRegistry()<br/>设为活动注册表 + 初始化钩子运行器"]
    H -->|"否"| J["✅ 返回 PluginRegistry"]
    I --> J
    style A fill:#e1f5fe,stroke:#2196f3
    style J fill:#c8e6c9,stroke:#4caf50
    style Discovery fill:#fff3e0,stroke:#ff9800
    style Registration fill:#f3e5f5,stroke:#9c27b0

图 9-2:插件加载与注册流程(后台服务不在这里启动,而是由网关启动流程另行调用 startPluginServices())

9.4.2 模块解析与别名

插件可能通过不同的模块加载器(ESM、CommonJS、jiti)加载。为确保 openclaw/plugin-sdk 和 openclaw/extension-api 正确解析,加载器使用别名机制:

// src/plugins/sdk-alias.ts:355(loader.ts 从这里导入;argv1 / moduleUrl / pluginSdkResolution 等参数的透传从略)
export function buildPluginLoaderAliasMap(modulePath: string, /* ... */): Record<string, string> {
  const pluginSdkAlias = resolvePluginSdkAliasFile({
    srcFile: "root-alias.cjs",
    distFile: "root-alias.cjs",
    modulePath, /* ... */
  });
  const extensionApiAlias = resolveExtensionApiAlias({ modulePath, pluginSdkResolution });
  return {
    ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}),
    ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
    ...resolvePluginSdkScopedAliasMap({ modulePath, /* ... */ }),   // openclaw/plugin-sdk/<子路径>
  };
}

这确保无论打包或加载方式如何,插件都能正确引用 OpenClaw SDK。

9.4.3 Jiti 运行时编译

对于 TypeScript 插件,OpenClaw 使用 Jiti 进行运行时编译:

// src/plugins/loader.ts:168-176 —— 每个 aliasMap 组合缓存一个 loader 实例
const loader = createJiti(import.meta.url, {
  ...buildPluginLoaderJitiOptions(aliasMap),  // src/plugins/sdk-alias.ts:409
  tryNative,                                  // 源码入口关掉原生加载,让 jiti 改写 ".js" 说明符
});

// src/plugins/loader.ts:1211 —— 注意是 **同步** 调用,不是 await jiti.import()
mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule;

buildPluginLoaderJitiOptions() 给出的是 { interopDefault: true, tryNative: true, extensions: [".ts",".tsx",".mts",".cts",".mtsx",".ctsx",".js",".mjs",".cjs",".json"], alias? }——没有 esmResolve,也没有 moduleCache: false。Jiti 允许直接加载 .ts 文件,无需预编译,加速开发迭代;更关键的是它提供了 require 式的同步加载,这正是 loadOpenClawPlugins() 能做成同步函数的前提。

9.5 插件运行时(Plugin Runtime)

"插件运行时"是插件访问系统能力的"官方入口"——配置、子 Agent、媒体、通道辅助函数等,都通过 api.runtime 递给插件。但要先把一个常见误解说清楚:它不是沙箱。OpenClaw 的原生插件与网关在同一个进程里运行,官方把已安装、已启用的插件视为受信代码(SECURITY.md 的「Plugin Trust Boundary」一节:插件以与 OpenClaw 进程相同的系统权限运行);插件技术上仍然可以直接 import Node.js 模块、读写文件、发起网络请求。PluginRuntime 约束的是集成方式——给插件一个稳定、收敛的能力面,免得它们各自伸手进核心的内部模块——而不是权限边界。iOS 的 app sandbox、Chrome 的 extension permission 那种"最小权限"隔离,在 v2026.3.31 的原生插件上并不存在;真正的防线是安装前的危险代码扫描、plugins.allow 白名单和"只装你信任的插件"。

9.5.1 Runtime 结构

PluginRuntime 是插件访问系统功能的入口:

// src/plugins/runtime/types.ts:54
export type PluginRuntime = PluginRuntimeCore & {
  subagent: {
    run: (params: SubagentRunParams) => Promise<SubagentRunResult>;
    waitForRun: (params: SubagentWaitParams) => Promise<SubagentWaitResult>;
    getSessionMessages: (params: SubagentGetSessionMessagesParams) => Promise<...>;
    /** @deprecated Use getSessionMessages. */
    getSession: (params: SubagentGetSessionParams) => Promise<SubagentGetSessionResult>;
    deleteSession: (params: SubagentDeleteSessionParams) => Promise<void>;
  };
  channel: PluginRuntimeChannel;
};

PluginRuntimeCore(src/plugins/runtime/types-core.ts)的顶层字段是:version、config、agent、system、media、tts、mediaUnderstanding、imageGeneration、webSearch、stt、events、logging、state、modelAuth。它的写法很有意思——几乎每个成员都用 typeof import("...") 直接引用核心实现的类型:

// src/plugins/runtime/types-core.ts:23(节选)
export type PluginRuntimeCore = {
  version: string;
  config: {
    loadConfig: typeof import("../../config/config.js").loadConfig;
    writeConfigFile: typeof import("../../config/config.js").writeConfigFile;
  };
  logging: {
    shouldLogVerbose: typeof import("../../globals.js").shouldLogVerbose;
    getChildLogger: (bindings?, opts?) => RuntimeLogger;
  };
  state: {
    resolveStateDir: typeof import("../../config/paths.js").resolveStateDir;
  };
  // ...
};

这样做的收益是"类型跟着实现走"——核心函数改签名,运行时契约立刻跟着报错;代价则是插件的类型检查会拉进核心源码树的类型。

PluginRuntimeChannel(src/plugins/runtime/types-channel.ts)的分组是按能力而不是按通道:text、reply、routing、pairing、media、activity、session、mentions、reactions、groups、debounce、commands、outbound、threadBindings,之后才是少数几个按通道命名的桥接口 discord、slack、matrix、signal、line。换句话说,并不存在 runtime.channel.telegram 这样的通用入口——Telegram、WhatsApp、Feishu 等通道插件主要经由 openclaw/plugin-sdk/<子路径> 导入所需能力,只有需要跨插件边界的少数几个通道才在这里开了口子。

9.5.2 延迟加载

运行时使用 createLazyRuntimeNamedExport 实现延迟加载,避免循环依赖:

// src/shared/lazy-runtime.ts:1(由 src/plugin-sdk/lazy-runtime.ts 转发)
export function createLazyRuntimeSurface<TModule, TSurface>(
  importer: () => Promise<TModule>,
  select: (module: TModule) => TSurface,
): () => Promise<TSurface> {
  let cached: Promise<TSurface> | null = null;
  return () => {
    cached ??= importer().then(select);   // Promise 本身就是缓存
    return cached;
  };
}


export function createLazyRuntimeNamedExport<TModule, const TKey extends keyof TModule>(
  importer: () => Promise<TModule>,
  key: TKey,
): () => Promise<TModule[TKey]> {
  return createLazyRuntimeSurface(importer, (module) => module[key]);
}
// 两者之间的 createLazyRuntimeModule(原样返回整个模块)从略

这里没有 Proxy、没有同步"假对象"——返回的是一个 () => Promise<T>,所以调用点长这样:(await loadWhatsAppChannelRuntime()).webAuthExists(...)。实现只有寥寥数行,关键就在 cached ??= importer().then(select):Promise 天然是一次性的、可重复 await 的缓存,用它当 memo 容器比手写状态机干净得多。同族的 createLazyRuntimeMethod / createLazyRuntimeMethodBinder 再往前一步,直接把"加载 + 取方法 + 调用"包成一个普通异步函数,调用点连 await load() 都省了。

9.6 插件 SDK

"SDK"这三个字母、对开发者来说就是"欢迎进入这个世界的门票"。一个系统有没有认真对待"外部开发者"、看 SDK 就知道——没有 SDK 或者 SDK 残缺不全的系统、等于说"我们只欢迎会读源码的人";SDK 完整、类型清晰、文档详尽的系统、才是真正把"外部开发者"当成"合作伙伴"。OpenClaw 的 plugin-sdk 目录结构、能看出设计者对"插件开发者需要什么"有过系统的思考——不是一个类型定义文件应付了事、而是按照插件开发的不同需要(通道、路由、消息、配置、白名单)拆分成多个文件。这种"按开发者心智模型组织 SDK"的方式、是提升 DX(Developer Experience)的具体手段——《Vue 3 设计与实现》专栏第 14 章(依赖注入与插件系统)讲的是同一类问题:怎么把"可扩展"这件事变成外部开发者能一眼看懂的接口。

9.6.1 SDK 结构

src/plugin-sdk/ 目录提供插件开发所需的类型和工具函数,经根 package.json 的 exports 以 openclaw/plugin-sdk 及其子路径对外发布(v2026.3.31 上共 229 个子路径):

src/plugin-sdk/
├── core.ts              # 核心类型和工具函数
├── channel-actions.ts   # 通道消息操作
├── channel-config-helpers.ts  # 通道配置辅助函数
├── channel-pairing.ts   # 配对适配器
├── channel-send-result.ts  # 发送结果处理
├── allowlist-config-edit.ts  # 白名单配置
├── routing.ts           # 路由工具
├── infra-runtime.ts     # 基础设施运行时
├── lazy-runtime.ts      # 延迟加载工具
├── telegram-core.ts     # Telegram 专用工具(另有 telegram-account/-allow-from/-surface)
├── discord.ts           # Discord 专用工具
├── whatsapp.ts          # WhatsApp 专用工具
├── signal.ts            # Signal 专用工具
├── slack.ts             # Slack 专用工具
├── feishu.ts            # Feishu 专用工具
└── ...                  # 更多通道专用工具

9.6.2 defineChannelPluginEntry

defineChannelPluginEntry 是创建通道插件入口的标准模板(通道插件的完整架构设计详见第7章,各通道的具体实现详见第8章):

// src/plugin-sdk/core.ts:278 — 通道插件入口模板(略有简化)
export function defineChannelPluginEntry<TPlugin>({
  id, name, description, plugin,
  configSchema = emptyPluginConfigSchema,
  setRuntime, registerCliMetadata, registerFull,
}: DefineChannelPluginEntryOptions<TPlugin>): DefinedChannelPluginEntry<TPlugin> {
  const resolvedConfigSchema = typeof configSchema === "function" ? configSchema() : configSchema;
  const entry = {
    id, name, description, configSchema: resolvedConfigSchema,
    register(api: OpenClawPluginApi) {
      if (api.registrationMode === "cli-metadata") {   // 只列 CLI 元数据:不碰通道
        registerCliMetadata?.(api);
        return;
      }
      setRuntime?.(api.runtime);                               // 设置运行时引用
      api.registerChannel({ plugin: plugin as ChannelPlugin }); // 注册通道
      if (api.registrationMode !== "full") return;             // setup-* 模式到此为止
      registerCliMetadata?.(api);
      registerFull?.(api);                                     // 完整模式额外注册
    },
  };
  return { ...entry, channelPlugin: plugin, ...(setRuntime ? { setChannelRuntime: setRuntime } : {}) };
}

9.6.3 createChannelPluginBase

createChannelPluginBase 创建通道插件的基础对象,减少重复代码:

// src/plugin-sdk/core.ts:541
export function createChannelPluginBase<TResolvedAccount>(
  params: CreateChannelPluginBaseOptions<TResolvedAccount>,
): CreatedChannelPluginBase<TResolvedAccount> {
  return {
    id: params.id,
    meta: {
      ...getChatChannelMeta(params.id as Parameters<typeof getChatChannelMeta>[0]),
      ...params.meta,
    },
    ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}),
    ...(params.capabilities ? { capabilities: params.capabilities } : {}),
    ...(params.agentPrompt ? { agentPrompt: params.agentPrompt } : {}),
    ...(params.streaming ? { streaming: params.streaming } : {}),
    ...(params.reload ? { reload: params.reload } : {}),
    ...(params.gatewayMethods ? { gatewayMethods: params.gatewayMethods } : {}),
    ...(params.configSchema ? { configSchema: params.configSchema } : {}),
    ...(params.config ? { config: params.config } : {}),
    ...(params.security ? { security: params.security } : {}),
    ...(params.groups ? { groups: params.groups } : {}),
    setup: params.setup,
  } as CreatedChannelPluginBase<TResolvedAccount>;
}

9.6.4 共享适配器工厂

SDK 提供多种适配器工厂函数,简化常见模式的实现:

createTextPairingAdapter — 文本配对适配器:

// src/channels/plugins/pairing-adapters.ts:21(经 src/plugin-sdk/channel-pairing.ts 转发)
export function createTextPairingAdapter(params: {
  idLabel: string;
  message: string;
  normalizeAllowEntry?: ChannelPairingAdapter["normalizeAllowEntry"];
  notify: (params: PairingNotifyParams & { message: string }) => Promise<void> | void;
}): ChannelPairingAdapter {
  return {
    idLabel: params.idLabel,
    normalizeAllowEntry: params.normalizeAllowEntry,
    notifyApproval: async (ctx) => {
      await params.notify({ ...ctx, message: params.message });
    },
  };
}

注意 message 并不会原样出现在返回的适配器上——它被闭包进 notifyApproval 里。适配器对外只暴露"批准时通知一下"这一个动作,文案是它的实现细节。同文件里还有两个小工具:createPairingPrefixStripper(/^(telegram|tg):/i) 生成前缀剥离器,createLoggedPairingApprovalNotifier(format) 生成只打日志的通知器(给没有出站能力的通道兜底)。

createScopedDmSecurityResolver — DM 安全策略解析器:

// src/plugin-sdk/channel-config-helpers.ts:804
export function createScopedDmSecurityResolver<
  ResolvedAccount extends { accountId?: string | null },
>(params: {
  channelKey: string;
  resolvePolicy: (account: ResolvedAccount) => string | null | undefined;
  resolveAllowFrom: (account: ResolvedAccount) => Array<string | number> | null | undefined;
  resolveFallbackAccountId?: (account: ResolvedAccount) => string | null | undefined;
  defaultPolicy?: string;
  allowFromPathSuffix?: string;
  policyPathSuffix?: string;
  approveChannelId?: string;
  approveHint?: string;
  normalizeEntry?: (raw: string) => string;
}) {
  // 返回 ({ cfg, accountId, account }) => buildAccountScopedDmSecurityPolicy({ ... })
}

createAttachedChannelResultAdapter — 出站结果适配器:

// src/plugin-sdk/channel-send-result.ts:40
export function createAttachedChannelResultAdapter(params: {
  channel: string;
  sendText?: (ctx: SendTextParams) => MaybePromise<Omit<OutboundDeliveryResult, "channel">>;
  sendMedia?: (ctx: SendMediaParams) => MaybePromise<Omit<OutboundDeliveryResult, "channel">>;
  sendPoll?: (ctx: SendPollParams) => MaybePromise<Omit<ChannelPollResult, "channel">>;
}): Pick<ChannelOutboundAdapter, "sendText" | "sendMedia" | "sendPoll"> {
  return {
    sendText: params.sendText
      ? async (ctx) => attachChannelToResult(params.channel, await params.sendText!(ctx))
      : undefined,
    sendMedia: params.sendMedia
      ? async (ctx) => attachChannelToResult(params.channel, await params.sendMedia!(ctx))
      : undefined,
    sendPoll: params.sendPoll
      ? async (ctx) => attachChannelToResult(params.channel, await params.sendPoll!(ctx))
      : undefined,
  };
}

9.7 插件钩子系统

9.7.1 钩子类型

OpenClaw 的钩子系统允许插件在系统事件发生时执行自定义逻辑:

// src/plugins/types.ts:1796 —— v2026.3.31 的全部 26 个钩子
export type PluginHookName =
  | "before_model_resolve"       // 决定用哪个模型/供应商之前
  | "before_prompt_build"        // 组装 system prompt 之前
  | "before_agent_start"         // legacy:上面两个阶段的合体
  | "llm_input" | "llm_output"
  | "agent_end"
  | "before_compaction" | "after_compaction"
  | "before_reset"
  | "inbound_claim"              // 抢占式接管一条入站消息
  | "message_received" | "message_sending" | "message_sent"
  | "before_tool_call" | "after_tool_call"
  | "tool_result_persist"
  | "before_message_write"
  | "session_start" | "session_end"
  | "subagent_spawning" | "subagent_delivery_target"
  | "subagent_spawned" | "subagent_ended"
  | "gateway_start" | "gateway_stop"
  | "before_dispatch";

名单本身值得读两遍——它就是"OpenClaw 认为哪些时刻值得让外人插一脚"的完整答案。同一批名字还被复制成一个运行期常量数组 PLUGIN_HOOK_NAMES,紧跟着用一段类型断言把两者钉在一起:

type MissingPluginHookNames = Exclude<PluginHookName, (typeof PLUGIN_HOOK_NAMES)[number]>;
type AssertAllPluginHookNamesListed = MissingPluginHookNames extends never ? true : never;
const assertAllPluginHookNamesListed: AssertAllPluginHookNamesListed = true;

加了新钩子名却忘了同步数组,这行赋值会在编译期直接失败。这是本专栏里会反复看到的一种手法——用类型系统守护"两处必须同步"的不变量,而不是靠代码评审记性。

9.7.2 类型化钩子

类型化钩子提供更严格的类型安全和更好的开发体验:

类型化钩子的入口是 api.on(...),它用 PluginHookHandlerMap 把钩子名和事件/结果类型绑死:

// src/plugins/types.ts:1772
on: <K extends PluginHookName>(
  hookName: K,
  handler: PluginHookHandlerMap[K],
  opts?: { priority?: number },
) => void;

// src/plugins/types.ts:2471
export type PluginHookRegistration<K extends PluginHookName = PluginHookName> = {
  pluginId: string;
  hookName: K;
  handler: PluginHookHandlerMap[K];
  priority?: number;   // 数字越大越先执行
  source: string;
};

以 before_agent_start 为例,它的事件与结果类型是这样拼出来的:

// src/plugins/types.ts:1942
export type PluginHookBeforeAgentStartEvent = {
  prompt: string;
  
  messages?: unknown[];
};

// legacy 钩子 = 两个新阶段的结果并集
export type PluginHookBeforeAgentStartResult = PluginHookBeforePromptBuildResult &
  PluginHookBeforeModelResolveResult;

export type PluginHookBeforeModelResolveResult = {
  modelOverride?: string;      // 换模型,如 "llama3.3:8b"
  providerOverride?: string;   // 换供应商,如 "ollama"
};

export type PluginHookBeforePromptBuildResult = {
  systemPrompt?: string;
  prependContext?: string;
  prependSystemContext?: string;   // 进系统提示头部,可被 provider 缓存
  appendSystemContext?: string;    // 进系统提示尾部,可被 provider 缓存
};

这里藏着一个很实用的设计提示:注释明确建议把静态的插件指引放进 prependSystemContext / appendSystemContext 而不是 prependContext——前者进系统提示、能吃到 provider 的 prompt caching,后者每轮都要重新计费。

还有一处安全收口,由运维方按插件开关:在 plugins.entries.<id>.hooks.allowPromptInjection 设为 false 时,该插件的 before_prompt_build 注册会被直接拒绝(记一条警告诊断);遗留的 before_agent_start 则会在注册时被 constrainLegacyPromptInjectionHook() 包一层,用 stripPromptMutationFieldsFromLegacyHookResult() 把 systemPrompt / prependContext / prepend|appendSystemContext 这四个字段剥掉,只保留模型/供应商覆盖(src/plugins/registry.ts 的 registerTypedHook)。默认没有这项配置时,两者都照常生效。新插件想改提示词,应当用 before_prompt_build,而不是继续依赖遗留钩子。

9.7.3 钩子注册

插件通过 registerHook 或类型化钩子字段注册钩子:

// 方式 1:registerHook —— 挂到内部钩子系统,事件键是 "类型:动作" 形式(如 "message:received"),
// handler 是宽松的 InternalHookHandler;必须给 name,否则只记一条 "hook registration missing name" 警告、不注册
api.registerHook(["message:received", "message:sent"], async (event) => {
  /* ... */
}, { name: "my-plugin.message-audit" });

// 方式 2:api.on —— 类型化钩子,事件与返回值都由 PluginHookHandlerMap 约束
export default {
  id: "my-plugin",
  register(api) {
    api.on("before_model_resolve", async (event) => ({ providerOverride: "ollama", modelOverride: "llama3.3:8b" }), {
      priority: 10,  // 数字越大越先执行
    });
  },
} satisfies OpenClawPluginDefinition;

两者注册到注册表的不同数组——registerHook 落到 registry.hooks(激活加载时再经 registerInternalHook 挂进内部钩子表),api.on 落到 registry.typedHooks。两套事件名互不相通:message_received 是类型化钩子名,message:received 是内部钩子事件键,给 registerHook 传类型化钩子名不会报错,只是永远不会触发;反过来给 api.on 传内部事件键,会得到一条 unknown typed hook ... ignored 警告。需要改写模型、提示词、工具调用这类有返回值语义的场景,用 api.on。

9.7.4 钩子执行

钩子通过 hook runner 执行,支持优先级排序和异步执行。这里要注意类型是怎么来的:HookRunner 不是手写的 interface,而是 ReturnType<typeof createHookRunner>(src/plugins/hooks.ts:1040,工厂在同文件 :176)——也就是说它的方法集合由工厂实现直接决定,加一个 runXxxHook 就自动进类型,不需要两头同步。

src/plugins/hooks.ts 里的 hook runner 并不是"跑一遍收集所有结果"那么简单,它按语义分了好几种执行形态。最典型的是 runModifyingHook——顺序执行、结果合并、且支持提前终止:

// src/plugins/hooks.ts:156(节选)
function getHooksForName<K extends PluginHookName>(registry, hookName: K) {
  return (registry.typedHooks as PluginHookRegistration<K>[])
    .filter((h) => h.hookName === hookName)
    .toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0));   // 高优先级在前
}

async function runModifyingHook(hookName, event, ctx, policy = {}) {
  const hooks = getHooksForName(registry, hookName);
  let result;
  for (const hook of hooks) {
    try {
      const handlerResult = await hook.handler(event, ctx);
      if (handlerResult != null) {
        result = policy.mergeResults ? policy.mergeResults(result, handlerResult, hook) : handlerResult;
        if (result && policy.shouldStop?.(result)) {
          // 已经拿到终局决定,后面的 handler 不必再跑
          policy.onTerminal?.({ hookName, pluginId: hook.pluginId, result });
          break;
        }
      }
    } catch (err) {
      handleHookError({ hookName, pluginId: hook.pluginId, error: err });  // 单个钩子抛错不影响其他
    }
  }
  return result;
}

三个细节值得注意:(1) 排序用的是 toSorted() 而非 sort()——不就地修改注册表数组;(2) 合并策略由 policy.mergeResults 注入,不同钩子对"多个插件都返回了值"的处理方式不同(如 override 类字段保留第一个定义值,即高优先级者胜);(3) 每个 handler 的异常被单独捕获,一个插件炸掉不会中断整条钩子链。此外还有 runClaimingHook(第一个返回 { handled: true } 的接管)和并行执行的通知型钩子(await Promise.all(...))。

sequenceDiagram
    participant System as OpenClaw 系统
    participant Runner as Hook Runner
    participant Hook1 as Hook 1 (priority=10)
    participant Hook2 as Hook 2 (priority=5)
    participant Hook3 as Hook 3 (priority=0)
    System->>Runner: 触发 before_agent_start
    Runner->>Runner: 按优先级排序
    Runner->>Hook1: 执行 (priority=10)
    Hook1-->>Runner: { modelOverride: "gpt-4" }
    Runner->>Hook2: 执行 (priority=5)
    Hook2-->>Runner: { appendSystemContext: "..." }
    Runner->>Hook3: 执行 (priority=0)
    Hook3-->>Runner: null
    Runner-->>System: 合并结果

图 9-3:钩子执行流程

9.8 提供商插件

9.8.1 ProviderPlugin 接口

ProviderPlugin 描述的是文本推理(LLM)提供商;语音、媒体理解、图像生成、网页搜索提供商各有独立的类型,分别走 registerSpeechProvider 等专门的注册方法:

// src/plugins/types.ts:825 — 提供商插件接口(核心字段)
export type ProviderPlugin = {
  id: string;
  pluginId?: string;
  label: string;                            // 展示名(不是 name)
  docsPath?: string;
  aliases?: string[];
  hookAliases?: string[];                   // 仅内部用于兼容路由的别名
  envVars?: string[];
  auth: ProviderAuthMethod[];               // 必填字段(可以是空数组)
  catalog?: ProviderPluginCatalog;          // 模型目录发现
  discovery?: ProviderPluginDiscovery;      // catalog 的遗留别名
  resolveDynamicModel?: (ctx) => ...;       // 目录里查不到的模型 id 兜底
  capabilities?: ...;                       // 能力声明
  createStreamFn?: ...; wrapStreamFn?: ...; // 流式函数创建/包装
  prepareRuntimeAuth?: ...;                 // 运行时认证准备
  fetchUsageSnapshot?: ...;                 // 使用量快照
  refreshOAuth?: ...; buildAuthDoctorHint?: ...;
  // ……全表 40 余个可选钩子
};

这个类型有两个容易踩的点:其一,展示名字段叫 label 而不是 name;其二,auth 是必填字段——哪怕不需要交互式认证也得显式写出来,写成空数组即可(如 extensions/amazon-bedrock/index.ts 里直接 registerProvider({ ..., auth: [] }))。至于那 40 多个可选钩子,绝大多数插件一个都不用写,它们是给那些行为与主流 API 不一致的供应商(思考模式怪、usage 字段怪、模型 id 命名怪)留的补丁位。源码里对 resolveDynamicModel 的注释还写明了完整的解析顺序:静态/已发现目录查找 → 插件的 resolveDynamicModel → 核心启发式兜底 → 通用 provider 配置兜底。

9.8.2 认证流程

提供商插件可以定义多种认证方式:

// src/plugins/types.ts:255
export type ProviderAuthMethod = {
  id: string;
  label: string;
  hint?: string;
  kind: ProviderAuthKind;  // "oauth" | "api_key" | "token" | "device_code" | "custom"
  wizard?: ProviderPluginWizardSetup;
  run: (ctx: ProviderAuthContext) => Promise<ProviderAuthResult>;
  runNonInteractive?: (ctx: ProviderAuthMethodNonInteractiveContext) => Promise<OpenClawConfig | null>;
};

一个 provider 可以同时声明多种认证方式,比如 API Key 与 OAuth(下面是示意代码,不是 extensions/openai/ 的原文):

// 提供商认证示例:一个 provider 声明 API Key 与 OAuth 两种方式
// 注意字段名:展示名是 `label`(不是 `name`),认证方式数组是 `auth`(不是 `authMethods`)
const openaiPlugin: ProviderPlugin = {
  id: "openai", label: "OpenAI",
  auth: [
    { id: "api_key", label: "API Key", kind: "api_key",
      run: async (ctx) => {
        // ctx.prompter 是 WizardPrompter:text / select / confirm 等
        const apiKey = await ctx.prompter.text({ message: "Enter OpenAI API Key" });
        return {
          profiles: [{
            profileId: "openai:default",
            credential: { type: "api_key", provider: "openai", key: apiKey },
          }],
        };
      },
    },
    { id: "oauth", label: "OAuth", kind: "oauth", run: async (ctx) => { /* OAuth 流程 */ } },
  ],
};

9.8.3 模型目录

catalog 钩子让提供商插件可以声明支持的模型:

// src/plugins/types.ts:305 — 模型目录发现接口
export type ProviderPluginCatalog = {
  order?: ProviderCatalogOrder;  // "simple" | "profile" | "paired" | "late"
  run: (ctx: ProviderCatalogContext) => Promise<ProviderCatalogResult>;
};

// 示意:一个 OpenAI 兼容的自建模型服务(同样:label + 必填的 auth)
const myLlmPlugin: ProviderPlugin = {
  id: "my-llm", label: "My LLM", auth: [],
  catalog: { order: "simple", run: async (ctx) => {
    const apiKey = ctx.resolveProviderApiKey("my-llm").apiKey;
    if (!apiKey) return null;  // 无凭证则跳过
    // 返回的是 ModelProviderConfig(src/config/types.models.ts):baseUrl 必填,models 是数组
    return { provider: {
      baseUrl: "https://llm.example.internal/v1", api: "openai-completions", apiKey,
      models: [{ id: "my-model-70b", name: "My Model 70B", reasoning: false, input: ["text"],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 131072, maxTokens: 4096 }],
    } };
  }},
};

9.8.4 模型切换与降级:Provider 的实战运用

Provider 插件系统不只是"注册模型列表"——它是 OpenClaw 实现模型韧性的关键基础设施。在生产环境中,模型不可用的情况远比想象中频繁:API 限流(429)、服务宕机、配额耗尽、网络超时。一个只依赖单一模型的 Agent 在这些场景下会完全停摆。

场景1:API 限流降级

降级链不配在 providers 下,而是配在模型选择上。AgentModelConfig 接受字符串或 { primary, fallbacks } 两种写法(类型定义在 src/config/types.agents-shared.ts,agents.defaults.model 的声明在 src/config/types.agent-defaults.ts):

// ~/.openclaw/openclaw.json(按 JSON5 解析)
{
  "agents": {
    "defaults": {
      "model": {
        "primary": "anthropic/claude-opus-4-6",
        "fallbacks": [
          "openai/gpt-4o",           // 第一降级
          "google/gemini-2.5-pro",   // 第二降级
        ],
      },
    },
  },
}

同一形状还用在 imageModel / imageGenerationModel / pdfModel 上——按模态分别配主备,而不是全局一条链。这个划分很务实:主模型不支持读 PDF 是常态,为此整体降级毫无必要,只在需要那个能力时切过去就好。

场景2:逐任务模型选择

不同任务对模型能力的需求差异巨大。一个 Agent 可能需要 Opus 来做复杂的代码重构,但用 Haiku 就足以回答"今天天气如何":

// ~/.openclaw/openclaw.json
{
  "agents": {
    "defaults": { "model": "anthropic/claude-sonnet-4-20250514" },
    // 每个 agent 可以覆写;子 Agent 还能单独配 model(同样支持 {primary, fallbacks})
  },
}

需要说明的是:定时任务的模型不配在这里。Cron 作业不存在配置文件里,而是存在 cron.store 指向的作业存储中(src/config/types.cron.ts 里 CronConfig 只有 enabled / store / maxConcurrentRuns / retry / failureAlert 等全局项,没有逐作业定义),逐作业的模型选择属于作业记录本身——详见第 12 章。

降级顺序完全由上面配置的 fallbacks 决定,OpenClaw 不会自己挑"能力最接近"的替代模型。Provider 插件的 catalog 提供的是每个模型的能力元数据(输入模态、上下文窗口、maxTokens、compat 兼容开关等)——切到哪个模型,就按那个模型的元数据来组装请求。

场景3:用量可见

// fetchUsageSnapshot 只服务于 /usage 等用量报告,不参与降级决策
const myProvider: ProviderPlugin = {
  id: "zai", label: "Z.AI", auth: [/* auth 是必填 */],
  fetchUsageSnapshot: async (ctx) => {
    // ctx 带 token / timeoutMs / fetchFn;返回 ProviderUsageSnapshot(src/infra/provider-usage.types.ts)
    const quota = await fetchQuota(ctx.token, ctx.timeoutMs, ctx.fetchFn);  // fetchQuota 为示意
    return { provider: "zai", displayName: "Z.AI",
      windows: [{ label: "5h", usedPercent: quota.usedPercent, resetAt: quota.resetAt }] };
  },
};

要注意边界:在 v2026.3.31 上,这个钩子在 resolveUsageAuth 之后,只被 /usage、/status、openclaw status 这类状态与用量报告调用,ProviderUsageSnapshot.provider 还是一个封闭的联合类型(anthropic、github-copilot、zai 等 7 个 id)。用量数据不参与模型选择,OpenClaw 没有"按预算自动切模型"的机制——自动切换只发生在请求失败时,沿着 fallbacks 链走。

9.8.5 Provider 接口的设计哲学

Provider 抽象层的设计哲学可以用一句话概括:模型是可替换的资源,不是系统的身份。

很多 Agent 应用的现实做法并非如此:提示词为某个模型调优过,换一家模型就表现异常;角色与工具定义里隐含着对某家模型能力的假设。这种绑定使得模型切换成为一个"大手术"——你不只是换了一个 API endpoint,你可能需要重写提示词、调整工具格式、修改输出解析逻辑。

OpenClaw 的 Provider 插件通过三个层面解耦 Agent 与模型:

  1. 协议标准化:Agent 运行时面对的是 @mariozechner/pi-agent-core 的统一 StreamFn 与消息事件,而不是各家 SDK 的原始流式分片。各家协议差异由底层 pi-ai 按模型的 api 字段(如 "openai-completions")选择的适配器消化;Provider 插件需要时再用 createStreamFn 自带传输实现,或用 wrapStreamFn 在通用包装之后补请求头、改请求体。

  2. 方言声明:不同厂商在工具 schema 形态、tool call id 格式、thinking 签名的保留与清洗上各有方言。Provider 插件通过 capabilities(src/agents/provider-capabilities.ts 的 anthropicToolSchemaMode、transcriptToolCallIdMode、preserveAnthropicThinkingSignatures 等开关)把这些差异声明出来,核心据此转写请求与会话记录。

  3. 模型级开关:模型定义里的 compat.supportsTools 为 false 时,嵌入式运行器不会向该模型发送工具定义(src/agents/model-tool-support.ts 的 supportsModelTools(),调用点在 src/agents/pi-embedded-runner/run/attempt.ts),而不是发了之后等报错。

Provider 接口的终极价值不是"能换模型"——而是"换了模型之后,系统的其余部分完全不受影响"。这就像换电池不需要换整个遥控器——接口标准化让组件可以独立演化

9.9 工具注册

9.9.1 工厂函数模式

工具可以通过工厂函数注册,接收上下文参数:

// src/plugins/types.ts:141 — 工具工厂函数(支持条件性注册和上下文注入)
export type OpenClawPluginToolFactory = (
  ctx: OpenClawPluginToolContext,       // agentId / sessionKey / workspaceDir / config / sandboxed 等
) => AnyAgentTool | AnyAgentTool[] | null | undefined;   // null/undefined = 不注册

// 示例:沙箱会话里不提供这个工具(工具形状是 pi-agent-core 的 AgentTool)
api.registerTool((ctx) => {
  if (ctx.sandboxed) return null;  // 条件不满足 → 这次不提供
  return {
    name: "my_tool", label: "My Tool", description: "A tool that does something",
    parameters: Type.Object({ /* ... */ }),          // TypeBox schema,不是 inputSchema
    async execute(_toolCallId, params) {
      return { content: [{ type: "text", text: "done" }], details: {} };
    },
  };
}, { name: "my_tool" });   // 工厂形态建议报上工具名,见 9.9.2

工厂可以一次返回一组工具(AnyAgentTool[]),这对"一个功能拆成 read/write/list 三个工具"的场景很实用。

9.9.2 可选工具

OpenClawPluginToolOptions 一共只有三个字段:

// src/plugins/types.ts:145
export type OpenClawPluginToolOptions = {
  name?: string;      // 工厂形态下声明工具名(否则注册表无从提前得知)
  names?: string[];   // 工厂返回多个工具时的名字列表
  optional?: boolean; // 标记为可选工具
};

api.registerTool(myTool, { optional: true });

可选工具默认不提供给 Agent:只有工具白名单里点名了该工具名、插件 id 或 group:plugins 时才会放行(src/plugins/tools.ts 的 isOptionalToolAllowed)。

name / names 存在的原因很实际:传对象时注册表能直接读 tool.name,传工厂时则要等真正调用才知道注册了什么——所以需要作者提前把名字报上来,否则插件状态、工具清单这类展示都无从下手(同名冲突则要等工厂被调用、拿到真实工具名时才查得出来)。

9.10 HTTP 路由

9.10.1 路由注册

插件可以注册自定义 HTTP 路由:

// src/plugins/types.ts:1581
export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";

export type OpenClawPluginHttpRouteHandler = (
  req: IncomingMessage,
  res: ServerResponse,
) => Promise<boolean | void> | boolean | void;

export type OpenClawPluginHttpRouteParams = {
  path: string;
  handler: OpenClawPluginHttpRouteHandler;
  auth: OpenClawPluginHttpRouteAuth;       // 必填,没有默认值
  match?: OpenClawPluginHttpRouteMatch;    // 默认 "exact"
  replaceExisting?: boolean;
};

// 示例:一个走插件自有鉴权的 webhook 入口
api.registerHttpRoute({
  path: "/my-plugin/webhook",
  auth: "plugin",       // "gateway" = 复用网关鉴权;"plugin" = 由 handler 自己校验
  match: "exact",
  handler: async (req, res) => {
    // 只有显式返回 false 才会继续尝试下一条匹配的路由;返回 true 或什么都不返回都算"已处理"
    res.end(JSON.stringify({ ok: true }));
    return true;
  },
});

auth 是必填的——注册表在缺失或取值非法时会直接记一条 http route registration missing or invalid auth 诊断并拒绝注册。这是一个很值得学的 API 设计决定:安全相关的开关不给默认值,逼作者显式表态。另一个细节是 handler 的返回值语义——网关侧的判断是 handled !== false(src/gateway/server/plugins-http.ts):只有显式返回 false 才让请求交给下一条匹配的插件路由,返回 undefined 同样算已消费;handler 抛错时网关会在未发送响应头的情况下回 500。

9.10.2 路由冲突检测

加载器会检测 HTTP 路由冲突:

// src/plugins/registry.ts:446
const match = params.match ?? "exact";
const overlappingRoute = findOverlappingPluginHttpRoute(registry.httpRoutes, {
  path: normalizedPath,
  match,
});
// 注意条件:只有"重叠且鉴权级别不同"才拒绝
if (overlappingRoute && overlappingRoute.auth !== params.auth) {
  pushDiagnostic({
    level: "error",
    pluginId: record.id,
    source: record.source,
    message:
      `http route overlap rejected: ${normalizedPath} (${match}, ${params.auth}) ` +
      `overlaps ${overlappingRoute.path} (${overlappingRoute.match}, ${overlappingRoute.auth}) ` +
      `owned by ${describeHttpRouteOwner(overlappingRoute)}`,
  });
  return;
}

这个判据很讲究:overlappingRoute.auth !== params.auth —— 重叠本身不是错,鉴权级别不一致才是错。因为路径重叠且鉴权不同时,攻击者可以用一条 prefix 的低鉴权路由"盖住"一条高鉴权路由,从而绕过鉴权。同一鉴权级别下的路径重叠则被视为插件作者的正常安排(真正完全相同的 path + match 走的是另一条"已注册/替换"分支,需要显式 replaceExisting,而且只能替换本插件自己注册的路由)。

9.11 CLI 命令

9.11.1 命令注册

插件可以注册 CLI 命令:

首先要分清 OpenClaw 里的两种"命令",它们走完全不同的 API:

  • 聊天斜杠命令(用户在 Telegram/Discord 里打 /tts)——用 api.registerCommand(...)
  • 终端 CLI 子命令(用户在 shell 里打 openclaw foo)——用 api.registerCli(registrar),registrar 拿到一个 Commander 的 program 自己挂命令

registerCommand 注册的是前者:

// src/plugins/types.ts:1398
export type OpenClawPluginCommandDefinition = {
  /** 命令名,不带前导斜杠(例如 "tts") */
  name: string;
  
  nativeNames?: Partial<Record<string, string>> & { default?: string };
  
  description: string;
  acceptsArgs?: boolean;
  
  requireAuth?: boolean;
  handler: PluginCommandHandler;
};

两个细节:requireAuth 默认为 true——插件命令默认是"只有授权用户能用",要开放必须显式关掉;nativeNames 的存在是因为各平台对斜杠命令的命名限制不同(长度、字符集、与既有命令冲突),允许同一个命令在不同平台叫不同名字。

CLI 那一侧则是把 Commander 的 program 直接交给插件:

export type OpenClawPluginCliContext = {
  program: Command;
  config: OpenClawConfig;
  workspaceDir?: string;
  logger: PluginLogger;
};
export type OpenClawPluginCliRegistrar = (ctx: OpenClawPluginCliContext) => void | Promise<void>;

注册时通过 opts.commands(或带说明的 opts.descriptors)声明"我拥有哪些顶层命令名",供冲突检测使用。

9.11.2 命令冲突

命令名必须唯一,但检测方式和"抛异常"无关——和注册表里其余所有冲突一样,走的是记诊断、拒绝注册、不打断其他插件的路子。CLI 侧的检测在 registerCli 里:

// src/plugins/registry.ts:743(commands = opts.commands 与 opts.descriptors 的 name 合并、trim 去空)
if (commands.length === 0) {
  pushDiagnostic({ level: "error", pluginId: record.id, source: record.source,
    message: "cli registration missing explicit commands metadata" });
  return;   // 不声明自己占用哪些顶层命令,直接不给注册
}
const existing = registry.cliRegistrars.find((entry) =>
  entry.commands.some((command) => commands.includes(command)),
);
if (existing) {
  const overlap = commands.find((command) => existing.commands.includes(command));
  pushDiagnostic({ level: "error", pluginId: record.id, source: record.source,
    message: `cli command already registered: ${overlap ?? commands[0]} (${existing.pluginId})` });
  return;
}

这里有个必须显式声明的强制项:registerCli 不接受"我不告诉你我占哪些命令"——opts.commands 和 opts.descriptors 都没给就直接拒绝。原因显而易见,registrar 是个不透明的回调,不先报备就无从做冲突检测。

聊天命令侧(registerCommand)多了一层考量:快照式加载(activateGlobalSideEffects === false)会跳过跨插件重名检测,只做定义合法性校验。源码注释解释得很清楚——快照注册表是隔离的、从不写全局命令表,真正的冲突会在网关启动那次正常激活时才浮现。

9.12 插件发现

9.12.1 发现源

discoverOpenClawPlugins(同步函数,返回 { candidates, diagnostics })从四类来源发现插件。来源用 PluginOrigin 标记:

// src/plugins/types.ts:1779
export type PluginOrigin = "bundled" | "global" | "workspace" | "config";

扫描目录由 resolvePluginSourceRoots()(src/plugins/roots.ts)给出:

const stock = resolveBundledPluginsDir(env);                                  // 随包分发
const global = path.join(resolveConfigDir(env), "extensions");                // 全局用户扩展
const workspace = workspaceRoot
  ? path.join(workspaceRoot, ".openclaw", "extensions") : undefined;          // 工作区扩展

扫描顺序是:config(extraPaths,来自 plugins.load.paths)→ workspace → bundled → global。函数内部的 seen 集合只按路径去重(同一个物理目录不会被收两次)。同一个插件 id 出现在多处时谁生效,是加载器另行排序决定的:src/plugins/loader.ts 的 resolveCandidateDuplicateRank() 给出的优先级是 config → 经 openclaw plugins install 装进全局目录且有安装记录的 global → bundled → workspace → 其余 global,排在后面的同名插件被标记为 overridden by <origin> plugin 并禁用。也就是说,显式配置的路径优先级最高,内置插件的 id 默认"保留",工作区和自动发现的全局扩展都盖不掉它。discovery 的源码注释也写明了这层意图:"Keep auto-discovered global extensions behind bundled plugins. Users can still intentionally override via plugins.load.paths (origin=config)."

被发现并不等于被启用。启用规则在 src/plugins/config-state.ts 的 resolveEnableState():plugins.enabled: false 全部关闭;plugins.deny 命中即关闭;plugins.entries.<id>.enabled: false 关闭;workspace 来源的插件默认关闭,必须在 plugins.allow 里点名或 entries.<id>.enabled: true;plugins.allow 非空时不在名单里的一律关闭(plugins.slots.memory 选中的记忆插件除外);内置插件只有 manifest 声明了 enabledByDefault: true(或对应通道已在配置里启用)才默认开启;其余来源默认开启。

发现结果还带缓存(可用 cache: false 关掉,TTL 由环境变量 OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS 控制),因为这条路径在 CLI 里会被反复调用。

9.12.2 插件格式

插件可以是多种格式:

// src/plugins/types.ts:1781
export type PluginFormat = "openclaw" | "bundle";

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

这两个枚举的语义常被误读。PluginFormat 区分的不是模块系统(ESM/CJS 由 jiti 透明处理),而是"这是一个原生 OpenClaw 插件,还是一个从别的 Agent 工具生态搬过来的 bundle"。PluginBundleFormat 则说明这个 bundle 出自哪家——Codex、Claude Code、Cursor,各自的 manifest 路径写死在 src/plugins/bundle-manifest.ts:.codex-plugin/plugin.json、.claude-plugin/plugin.json、.cursor-plugin/plugin.json。也就是说,OpenClaw 在插件层面直接吃下了另外三家 Agent 工具的扩展格式。

9.12.3 Manifest 文件

插件通过 openclaw.plugin.json 声明元数据(常量定义在 src/plugins/manifest.ts:export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json")。类型上只有 id 和 configSchema 是必填:

// src/plugins/manifest.ts:19
export type PluginManifest = {
  id: string;
  configSchema: Record<string, unknown>;   // 内联 JSON Schema,不是文件路径
  enabledByDefault?: boolean;
  legacyPluginIds?: string[];              // 旧插件 id → 归一到本 id
  autoEnableWhenConfiguredProviders?: string[];
  kind?: PluginKind | PluginKind[];
  channels?: string[];
  providers?: string[];
  cliBackends?: string[];
  providerAuthEnvVars?: Record<string, string[]>;
  providerAuthChoices?: PluginManifestProviderAuthChoice[];
  skills?: string[];
  name?: string; description?: string; version?: string;
  uiHints?: Record<string, PluginConfigUiHint>;
  contracts?: PluginManifestContracts;     // 静态能力归属快照
  channelConfigs?: Record<string, PluginManifestChannelConfig>;
};

manifest 的设计目标写在源码注释里:让核心不必启动插件运行时就能回答"谁提供了这个 provider / 这个 channel / 这个语音后端"。providerAuthEnvVars、providerAuthChoices、contracts 这几个字段全是为此存在——配置校验、CLI help、鉴权路由都靠读 manifest 走捷径。真实例子(extensions/memory-lancedb/openclaw.plugin.json,节选:uiHints 只保留一项,configSchema.properties 只保留 embedding 且删去其内部字段):

{
  "id": "memory-lancedb",
  "kind": "memory",
  "uiHints": {
    "embedding.apiKey": {
      "label": "OpenAI API Key",
      "sensitive": true,
      "placeholder": "sk-proj-...",
      "help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})"
    }
  },
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": { "embedding": { "type": "object", "required": ["apiKey"] } },
    "required": ["embedding"]
  }
}

注意 uiHints 的键是点分路径(embedding.apiKey),能一路指到嵌套字段。

9.13 配置 Schema

9.13.1 Schema 定义

插件导出里可以带一个配置 schema 对象,用于验证和 UI 提示。但要分清两处 schema:加载器和配置校验(src/config/validation.ts)真正用来校验 plugins.entries.<id>.config 的,是 manifest 里的 configSchema(JSON Schema);manifest 缺了它,插件会以 missing config schema 加载失败。导出里的 configSchema 不参与这一步校验。

// src/plugins/types.ts:101 — 插件配置 Schema(验证 + UI 提示)
export type OpenClawPluginConfigSchema = {
  safeParse?: (value: unknown) => {                // Zod 风格
    success: boolean;
    data?: unknown;
    error?: { issues?: Array<{ path: Array<string | number>; message: string }> };
  };
  parse?: (value: unknown) => unknown;             // 抛错风格
  validate?: (value: unknown) => PluginConfigValidation;  // 轻量自定义校验
  uiHints?: Record<string, PluginConfigUiHint>;    // 每个字段的 UI 渲染提示
  jsonSchema?: Record<string, unknown>;            // JSON Schema 定义
};

export type PluginConfigUiHint = {
  label?: string;       // 显示名称
  help?: string;        // 帮助文案
  tags?: string[];
  advanced?: boolean;   // true → 折叠到"高级"区域
  sensitive?: boolean;  // true → 密码输入框
  placeholder?: string; // 输入提示
};

9.13.2 使用 TypeBox

插件可以使用 TypeBox 定义 schema(TypeBox 的 schema 对象本身就是 JSON Schema):

// 使用 TypeBox 定义配置 Schema(类型安全 + 天然是 JSON Schema)
import { Type } from "@sinclair/typebox";

const configSchema = Type.Object({
  apiKey: Type.String({ description: "API Key for the service" }),
  maxRetries: Type.Optional(Type.Number({ default: 3 })),
});

const myPlugin: OpenClawPluginDefinition = {
  id: "my-plugin", name: "My Plugin",
  configSchema: {
    jsonSchema: configSchema,
    uiHints: { apiKey: { label: "API Key", sensitive: true } },  // 密码框
  },
  register(api) { /* ... */ },   // 缺了 register/activate,加载器会报 missing register/activate export
};
export default myPlugin;
// 注意:加载期校验用的是 openclaw.plugin.json 里的 configSchema,
// 同一份 JSON Schema 需要写进 manifest(可以在构建脚本里由 configSchema 生成)

9.14 扩展 API(extensionAPI.ts)

9.14.1 兼容层

src/extensionAPI.ts 提供向后兼容的导入路径:

// src/extensionAPI.ts:4
const shouldWarnExtensionApiImport =
  process.env.VITEST !== "true" &&
  process.env.NODE_ENV !== "test" &&
  process.env.OPENCLAW_SUPPRESS_EXTENSION_API_WARNING !== "1";

if (shouldWarnExtensionApiImport) {
  process.emitWarning(
    "openclaw/extension-api is deprecated. Migrate to api.runtime.agent.* or focused openclaw/plugin-sdk/<subpath> imports. See https://docs.openclaw.ai/plugins/sdk-migration",
    {
      code: "OPENCLAW_EXTENSION_API_DEPRECATED",
      detail: "This compatibility bridge is temporary. ...",   // 迁移说明从略
    },
  );
}

export { resolveAgentDir, resolveAgentWorkspaceDir } from "./agents/agent-scope.js";
export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./agents/defaults.js";
export { resolveAgentIdentity } from "./agents/identity.js";
// ... 更多导出

9.14.2 迁移路径

新插件应使用 openclaw/plugin-sdk 的子路径导入:

// 旧方式(已弃用)
import { resolveAgentDir } from "openclaw/extension-api";

// 新方式
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";

弃用警告本身给出的另一条路是 api.runtime.agent.*——能从注入的运行时拿到的,就不必再 import 宿主侧的辅助函数。

9.15 实战推演:从零开发一个天气预报插件

理论讲够了,让我们动手。本节将完整推演如何从零开发一个 OpenClaw 插件——一个简单的天气预报工具插件。通过这个过程,你将亲身体验"注册而非注入"的哲学。

9.15.1 为什么是"注册而非注入"?

在大多数框架中,添加功能的方式是注入——你获取框架的内部引用,修改它的数据结构,甚至 monkey-patch 它的方法。这种方式灵活但脆弱——框架内部结构一变,你的插件就坏了。

OpenClaw 的插件系统采用注册——你把你的能力推给系统("我能提供这个工具"),系统决定何时、如何使用它。你不需要触碰系统的内部。这种单向门带来的是:

  • 插件不依赖核心的内部数据结构,核心重构不必顾及插件的实现细节
  • 框架升级不会破坏插件(只要注册 API 稳定)
  • 注册冲突以诊断形式记录,一个插件注册失败不会拖垮其他插件

但要记住,"注册"约束的是集成方式,不是权限:原生插件与网关同进程运行、不在沙箱里(见 9.5),一个有 bug 或恶意的插件依然可以拖垮甚至操控整个网关。

9.15.2 第一步:创建项目结构

mkdir openclaw-plugin-weather
cd openclaw-plugin-weather
npm init -y
npm install @sinclair/typebox   # 工具参数 schema 用;类型导入 openclaw/plugin-sdk 由加载器别名解析

先写清单文件 openclaw.plugin.json——没有它,加载器不会认这个目录;id 和 configSchema 是必填项,下一步要用到的配置字段也得在 schema 里声明:

{
  "id": "weather",
  "name": "Weather Forecast",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "defaultLocation": { "type": "string" },
      "units": { "type": "string", "enum": ["metric", "imperial"] }
    }
  }
}

再创建入口文件 index.ts(目录里没有 package.json 的 openclaw.extensions 声明时,发现逻辑会找 index.ts / index.js 等默认入口):

// index.ts — 天气插件入口(注册而非注入)
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginDefinition } from "openclaw/plugin-sdk/core";

const weatherPlugin: OpenClawPluginDefinition = {
  id: "weather", name: "Weather Forecast", version: "1.0.0",
  register(api) {
    // 向系统"注册"工具——系统决定何时展示、何时调用、如何安全过滤
    api.registerTool({
      name: "weather", label: "Weather", description: "Get current weather for a location",
      parameters: Type.Object({
        location: Type.String(),
        format: Type.Optional(Type.Union([Type.Literal("brief"), Type.Literal("detailed")])),
      }),
      async execute(_toolCallId, params) {
        const { location, format } = params as { location: string; format?: "brief" | "detailed" };
        const url = `https://wttr.in/${encodeURIComponent(location)}${format === "detailed" ? "" : "?format=3"}`;
        const res = await fetch(url);
        const text = res.ok ? (await res.text()).trim() : `HTTP ${res.status}`;
        return { content: [{ type: "text", text }], details: { ok: res.ok } };
      },
    });
  },
};
export default weatherPlugin;

工具对象的形状来自 @mariozechner/pi-agent-core 的 AgentTool:参数 schema 字段叫 parameters,execute(toolCallId, params) 返回 { content, details }——和 extensions/memory-lancedb/index.ts 里 memory_recall 的写法一致。

9.15.3 第二步:在配置中启用

配置分成两件事:去哪儿找(plugins.load.paths)和这个插件怎么配(plugins.entries.<id>):

// ~/.openclaw/openclaw.json(按 JSON5 解析——支持注释和尾逗号,但它是 JSON 不是 YAML)
{
  "plugins": {
    "load": {
      "paths": ["~/openclaw-plugin-weather"],  // 发现路径,origin=config,优先级最高;相对路径按网关进程的工作目录解析,建议写绝对路径或 ~ 开头
    },
    "entries": {
      "weather": {                              // 键就是插件 id,不是数组元素
        "enabled": true,
        "config": {                             // 插件自己的配置落在这里
          "defaultLocation": "Beijing",
        },
      },
    },
  },
}

PluginsConfig 的完整形状是 { enabled, allow, deny, load, slots, entries, installs }(src/config/types.plugins.ts),其中 entries 是 Record<pluginId, PluginEntryConfig>——不是数组,也不带 path 字段。这是新手最常配错的地方:路径归 load.paths,配置归 entries.<id>.config。

注意:不需要编译。OpenClaw 的 Jiti 运行时编译器直接加载 TypeScript 源码。这在开发阶段是巨大的效率提升——修改代码,重启网关,立即测试(官方文档说明插件配置变更需要重启网关;以默认方式运行 openclaw gateway 时,写入配置后通常会自动完成进程内重启)。

9.15.4 第三步:理解发生了什么

当网关启动时,发生了以下序列:

  1. 发现:discoverOpenClawPlugins() 扫描 plugins.load.paths(origin=config)以及工作区/内建/全局三个目录,发现 weather 插件;读取 openclaw.plugin.json,确认 id。
  2. 加载:Jiti 加载 index.ts,执行 TypeScript 编译,导出 weatherPlugin 对象。
  3. 注册:先用 manifest 的 configSchema 校验 entries.weather.config,通过后系统调用 weatherPlugin.register(api),插件通过 api.registerTool() 注册能力(如果还用了 api.registerHook() / api.on(),也在这一步)。
  4. 入册:工具进入 PluginRegistry.tools;若注册了钩子,registerHook 的进入 PluginRegistry.hooks,api.on 的进入 PluginRegistry.typedHooks。
  5. 可用:下次调用 Agent 时,工具策略管线(第10章)评估 weather 是否对当前 Agent 可用。如果通过,LLM 在工具列表中看到 weather。

整个过程中,插件从未触碰系统内部。它只做了两件事:声明自己的能力,提供执行逻辑。系统负责其余一切——何时展示、何时调用、如何安全过滤。

9.15.5 进阶:添加配置 Schema

让插件支持自定义配置:

// 进阶:添加可配置项(从 api.pluginConfig 读取,而非直接读文件)
register(api) {
  // api.pluginConfig 就是 plugins.entries.weather.config 那一段
  const { defaultLocation = "Beijing", units = "metric" } = api.pluginConfig ?? {};
  api.registerTool((ctx) => ({
    name: "weather",
    description: `Get weather (default: ${defaultLocation}, ${units})`,
    // ... 完整工具定义
  }));
},
// 导出里的 configSchema 用于文档与 UI 提示;加载期校验看的是 openclaw.plugin.json,
// 所以 units 这类新字段要同步加进 manifest 的 configSchema(第一步的清单里已经声明)
configSchema: {
  jsonSchema: { type: "object", properties: {
    defaultLocation: { type: "string" }, units: { type: "string", enum: ["metric", "imperial"] },
  }},
  uiHints: { defaultLocation: { label: "默认城市", placeholder: "Beijing" } },
},

9.15.6 注册 vs. 注入的直觉总结

维度 注入式(直接改框架内部) 注册式(OpenClaw)
数据流 插件拉取系统内部 插件推给系统接口
耦合度 紧——依赖内部 API 松——只依赖注册 API 与 SDK 子路径
冲突处理 覆盖(最后写入胜出) 诊断(记录冲突,不崩溃)
升级兼容 脆弱——内部变则插件坏 较稳——只要注册 API 不变,插件不受内部重构影响
安全性 插件可访问一切 同样可访问一切(原生插件进程内运行、不在沙箱里),安全靠安装扫描、白名单与信任

9.16 实战推演:开发一个 Notion 同步插件

天气插件展示了工具注册的基本流程,但真实世界的插件往往需要同时注册多种能力——工具、钩子、HTTP 路由、配置 Schema。让我们用一个更完整的场景来感受"注册而非注入"在复杂场景下的威力。

9.16.1 需求场景

你的团队使用 Notion 管理项目。你希望 Agent 能够:

  1. 查询 Notion 数据库中的任务列表
  2. 在 Agent 完成任务时自动更新 Notion 状态
  3. 通过 Webhook 接收 Notion 的变更通知
  4. API Key 等配置项有结构校验,并在配置界面里按敏感字段处理

这四个需求分别对应四种注册能力:工具(查询)、钩子(自动更新)、HTTP 路由(Webhook)、配置 Schema(配置校验与敏感字段提示;和天气插件一样,同一份 JSON Schema 也要写进 openclaw.plugin.json)。

9.16.2 完整插件实现

// notion-sync/index.ts — 一个"四合一"插件,展示插件 SDK 的四种注册能力
// verifyNotionSignature / parseBody 是示意用的辅助函数,需自行实现
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginDefinition } from "openclaw/plugin-sdk/core";

const notionSync: OpenClawPluginDefinition = {
  id: "notion-sync",
  name: "Notion Sync",
  version: "1.0.0",

  // ── 能力 ①:配置 Schema ─────────────────────────────
  // 声明插件需要的配置字段及 UI 渲染提示
  configSchema: {
    jsonSchema: {
      type: "object",
      properties: {
        apiKey: { type: "string", description: "Notion Integration Token" },
        databaseId: { type: "string", description: "Target Database ID" },
      },
      required: ["apiKey", "databaseId"],
    },
    uiHints: {
      apiKey: { label: "Notion API Key", sensitive: true },
      databaseId: { label: "数据库 ID", placeholder: "abc123..." },
    },
  },

  register(api) {
    // 对应 ~/.openclaw/openclaw.json 里的 plugins.entries["notion-sync"].config
    const cfg = (api.pluginConfig ?? {}) as { apiKey?: string; databaseId?: string; targetSessionKey?: string };

    // ── 能力 ②:注册工具 ─────────────────────────────
    // Agent 可通过 notion_tasks 工具查询 Notion 数据库
    api.registerTool((ctx) => {
      if (!cfg.apiKey) return null; // 未配置则不注册——条件性注册
      return {
        name: "notion_tasks",
        label: "Notion Tasks",
        description: "Query tasks from Notion database",
        parameters: Type.Object({
          status: Type.Optional(Type.Union([
            Type.Literal("todo"), Type.Literal("in_progress"), Type.Literal("done"),
          ])),
        }),
        async execute(_toolCallId, params) {
          const { status } = params as { status?: string };
          const res = await fetch(
            `https://api.notion.com/v1/databases/${cfg.databaseId}/query`,
            {
              method: "POST",
              headers: {
                "Authorization": `Bearer ${cfg.apiKey}`,
                "Notion-Version": "2022-06-28",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                filter: status
                  ? { property: "Status", select: { equals: status } }
                  : undefined,
              }),
            },
          );
          const data = await res.json();
          const tasks = (data.results ?? []).map((r: any) => ({
            title: r.properties.Name?.title?.[0]?.plain_text,
            status: r.properties.Status?.select?.name,
          }));
          return { content: [{ type: "text", text: JSON.stringify(tasks) }], details: { count: tasks.length } };
        },
      };
    }, { name: "notion_tasks" });

    // ── 能力 ③:注册钩子 ─────────────────────────────
    // 子 Agent 结束时自动将 Notion 页面标记为 Done
    // 钩子名必须来自 PluginHookName 枚举——这里是 "subagent_ended"。
    // 它的事件只有 targetSessionKey / outcome / reason 等字段,没有业务任务 id——
    // "子 Agent 会话 ↔ Notion 页面"的对应关系要插件自己维护(这里用一个 Map 示意)
    const pageIdBySession = new Map<string, string>();
    api.on("subagent_ended", async (event) => {
      const pageId = pageIdBySession.get(event.targetSessionKey);
      if (!cfg.apiKey || !pageId || event.outcome !== "ok") return;
      await fetch(`https://api.notion.com/v1/pages/${pageId}`, {
        method: "PATCH",
        headers: {
          "Authorization": `Bearer ${cfg.apiKey}`,
          "Notion-Version": "2022-06-28",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          properties: { Status: { select: { name: "Done" } } },
        }),
      });
    });

    // ── 能力 ④:注册 HTTP 路由 ────────────────────────
    // 接收 Notion Webhook 推送,将变更转发给 Agent
    api.registerHttpRoute({
      path: "/notion-sync/webhook",
      // auth 只有 "gateway" | "plugin" 两种取值,且必填。
      // Notion Webhook 带不了网关凭据,所以选 "plugin"——由 handler 自己验签。
      auth: "plugin",
      match: "exact",
      handler: async (req, res) => {
        if (!verifyNotionSignature(req)) {
          res.statusCode = 401;
          res.end();
          return true;
        }
        const body = await parseBody(req);
        // 把变更投递成一条系统事件(签名是 enqueueSystemEvent(text, { sessionKey, ... }))
        api.runtime.system.enqueueSystemEvent(
          `Notion 更新: ${body.pages?.[0]?.id ?? "unknown"}`,
          { sessionKey: cfg.targetSessionKey! },
        );
        res.setHeader("content-type", "application/json");
        res.end(JSON.stringify({ ok: true }));
        return true;   // 告诉网关这条请求已被本路由消费
      },
    });
  },
};

export default notionSync;

9.16.3 一个插件,四种能力,零行核心代码修改

这个插件同时注册了工具、钩子、HTTP 路由和配置 Schema——四种完全不同的系统集成方式。但它从未触碰 OpenClaw 的内部实现。它只做了四件事:向系统声明"我能提供什么"。

插件的力量不在于它做了多少事——而在于它做了多少事而不需要知道系统是怎么工作的。一个好的插件 API,让插件开发者只需要关心自己的领域逻辑,而非框架的内部机制。这就是"注册而非注入"的终极价值:知识边界的清晰划分。

这个场景也展示了条件性注册的实际价值:if (!cfg.apiKey) return null 确保了未配置 API Key 时,工具不会出现在 Agent 的工具列表中——Agent 甚至不知道 Notion 集成的存在。这比"注册了但调用时报错"优雅得多。

9.17 测试策略

"测试策略"对于插件系统而言、是一个比普通系统更复杂的话题。为什么?因为插件系统的测试有两个维度——测试"插件系统本身能否正确工作"(平台侧测试)、以及测试"各个插件能否正确工作"(插件侧测试)。这两个维度的测试视角完全不同——平台侧关注"注册表能否正确管理多个插件、冲突能否被检测、钩子能否按顺序触发";插件侧关注"某个具体插件的业务逻辑是否正确、与 OpenClaw API 的契约是否满足"。好的插件系统、需要同时为两个维度提供测试支持——平台侧自己写单元测试和集成测试、插件侧提供 Mock SDK 让插件开发者能写测试。OpenClaw 在这两方面都有投入、但目前还处于"平台侧覆盖充分、插件侧支持起步"的阶段——未来发展的一个重点、是为插件开发者提供更完整的测试工具链。这个方向的参考可以看 VS Code 的 @vscode/test-electron、它为 VS Code 扩展开发者提供了端到端的测试环境——OpenClaw 可以借鉴类似的设计。

9.17.1 单元测试

插件系统的每个组件都有独立的单元测试:

src/plugins/manifest-registry.test.ts
src/plugins/http-registry.test.ts
src/plugins/loader.test.ts
src/plugins/discovery.test.ts
src/plugins/commands.test.ts
src/plugins/hooks.security.test.ts
src/plugins/hooks.before-agent-start.test.ts
src/plugins/hooks.before-tool-call.test.ts
src/plugins/hooks.phase-hooks.test.ts

值得一提的是 hooks.*.test.ts 被拆成了好几个文件,其中 hooks.security.test.ts 专门覆盖"终局决定能不能被推翻"这一类断言——比如 before_tool_call 返回 block: true、message_sending 返回 cancel: true 之后,低优先级的钩子是否真的不再执行;而 allowPromptInjection: false 时 before_prompt_build 被拦截、遗留钩子的提示词字段被约束,相关断言写在 loader.test.ts 里。

9.17.2 集成测试

集成测试验证插件加载和注册的完整流程:

// 集成测试的形态(注意入口是同步的 loadOpenClawPlugins)
it("loads bundled plugins", () => {
  const registry = loadOpenClawPlugins({ config: { /* ... */ }, mode: "full", cache: false });
  expect(registry.plugins.length).toBeGreaterThan(0);
});

it("records load errors as diagnostics instead of throwing", () => {
  const registry = loadOpenClawPlugins({
    config: { plugins: { load: { paths: ["/nonexistent"] } } },
    cache: false,
    throwOnLoadError: false,   // 默认行为:错误记诊断,不抛异常
  });
  expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true);
});

mode: "validate" 还提供了一条"只校验不真正注册"的路径,配置校验流程走的就是它。

9.17.3 插件合约测试

src/plugins/contracts/ 目录包含插件合约的测试,确保所有插件遵循相同的接口规范。

9.18 设计决策

9.18.1 为什么使用注册表模式?

注册表模式带来了:

  1. 集中管理:所有插件组件在一个地方注册和查询
  2. 冲突检测:可以在注册时检测命名冲突
  3. 诊断收集:可以收集所有插件的问题,而非一个失败导致全部中止

9.18.2 为什么使用工厂函数?

工具工厂函数允许:

  1. 条件注册:根据配置决定是否注册工具
  2. 上下文注入:工厂在为一次 Agent 运行组装工具时拿到当前会话的上下文(agentId、sessionKey、是否沙箱等)
  3. 延迟创建:工具对象只在需要时创建

9.18.3 为什么需要 PluginRuntime?

PluginRuntime 暴露了:

  1. 统一入口:插件通过运行时拿到配置、子 Agent、媒体、通道辅助等能力,不必 import 核心内部模块(这是约定而非强制——原生插件与网关同进程,技术上仍能 import 任何东西)
  2. 收敛接口面:运行时是对插件公开的能力面,内部实现重构时只需维持这一层
  3. 测试隔离:测试时可以注入 mock 运行时
graph LR
    subgraph Plugin["插件"]
        CODE[插件代码]
    end
    subgraph SDK["plugin-sdk"]
        TYPES[类型定义]
        HELPERS[辅助函数]
    end
    subgraph Runtime["Plugin Runtime"]
        CONFIG[配置访问]
        CHANNEL[通道 API]
        STATE[状态存储]
    end
    subgraph Core["OpenClaw Core"]
        INTERNAL[内部实现]
    end
    CODE --> SDK
    CODE --> Runtime
    Runtime --> Core
    SDK -.-> |类型引用| Runtime
    style Plugin fill:#e3f2fd
    style SDK fill:#f3e5f5
    style Runtime fill:#fff3e0
    style Core fill:#e8f5e9

图 9-4:插件与系统的分层访问

9.18.4 源码符号索引与跨版本校对

本章前文引用的类型、函数、常量散落在 src/plugins/ 与 src/plugin-sdk/ 两个目录。为了让你在正文与源码之间来回切换时不再"找不到地方",这里把高频引用的符号整理成一张索引表。基准是 openclaw/openclaw v2026.3.31(commit 213a704);行号只在这个 tag 上成立。

符号 文件 行号 备注
OpenClawPluginDefinition src/plugins/types.ts L1662 仅 8 个字段,id / name 都可选
PluginRegistrationMode src/plugins/types.ts L1677 四态:"full" | "setup-only" | "setup-runtime" | "cli-metadata"
OpenClawPluginApi src/plugins/types.ts L1680–L1777 顶层 20 个 register* 方法,外加 on / resolvePath
PluginOrigin src/plugins/types.ts L1779 "bundled" | "global" | "workspace" | "config"
PluginFormat src/plugins/types.ts L1781 "openclaw" | "bundle"
PluginBundleFormat src/plugins/types.ts L1783 "codex" | "claude" | "cursor"
PluginHookName src/plugins/types.ts L1796–L1822 26 个钩子;紧随其后的 PLUGIN_HOOK_NAMES 与之做编译期对齐断言
PluginHookHandlerMap src/plugins/types.ts L2364 钩子名 → 事件/结果类型的映射表
PluginRegistry src/plugins/registry.ts L220 20 个字段的注册表结构(L221–L239,其中 cliBackends 与 gatewayMethodScopes 可选)
createPluginRegistry src/plugins/registry.ts L272 工厂入口,内部先调 createEmptyPluginRegistry()(src/plugins/registry-empty.ts L3)
registerGatewayMethod src/plugins/registry.ts L393 冲突判定在 L403:核心方法与已注册方法双重检测,走 pushDiagnostic 而非 throw
createPluginJitiLoader src/plugins/loader.ts L150–L177 按 {tryNative, aliasMap} 组成的 cacheKey 缓存 jiti 实例,避免重复 createJiti
loadOpenClawPlugins src/plugins/loader.ts L814 同步函数,返回 PluginRegistry
discoverOpenClawPlugins src/plugins/discovery.ts L832 参数为 workspaceDir / extraPaths / ownershipUid / cache / env
resolvePluginSourceRoots src/plugins/roots.ts L16 解析出 stock / global / workspace 三个根目录
PLUGIN_MANIFEST_FILENAME src/plugins/manifest.ts L8 "openclaw.plugin.json"
PluginsConfig src/config/types.plugins.ts L38 enabled / allow / deny / load / slots / entries / installs

这张表怎么维护

src/plugins/types.ts 在 v2026.3.31 实测 2477 行(口径:wc -l)。这么大的单文件将来有可能按插件、Provider、钩子等主题拆成多个文件(这是推测,v2026.3.31 上并没有 src/plugins/types/ 目录),一旦拆分,上表行号会整体失效。

可行的维护策略是把行号降级为"辅助信息",用锚点字符串作为主键——每个符号对应一条 grep -n '^export type OpenClawPluginDefinition' src/plugins/types.ts 式的检索命令,在 CI 里跑一遍:命中即通过并回填新行号,未命中则提示"符号已改名或移动,请重新对齐"。行号会漂移,export 那一行的文本不会。

这也是读任何"带行号引用"的源码分析材料时该有的警惕:行号是易腐信息,符号名才是耐久信息。本章正文因此尽量以"文件 + 符号名"定位,只在这张表里集中给出行号。

9.19 插件系统竞品深度对比

插件系统是 Agent 框架的核心扩展机制。不同框架对"如何让第三方扩展系统能力"这个问题给出了截然不同的回答。理解这些差异,有助于你判断何时使用哪种方案。

9.19.1 OpenClaw vs. LangChain Tools

LangChain 的方式:工具是 Python 类,继承 BaseTool,通过 @tool 装饰器或类定义实现。

# LangChain 方式
import requests
from langchain.tools import tool

@tool
def weather(city: str) -> str:
    """Get weather for a city."""
    return requests.get(f"https://wttr.in/{city}?format=3").text

OpenClaw 的方式:工具通过插件的工厂函数注册,运行时动态创建。

// OpenClaw 方式 —— 注意没有顶层 `tools` 字段,
// OpenClawPluginDefinition 只有 id/name/description/version/kind/configSchema/register/activate 八个字段,
// 工具一律走 register 里的 api.registerTool()
export default {
  id: "weather-plugin",   // 还需同目录的 openclaw.plugin.json(id + configSchema)
  register(api) {
    api.registerTool(() => ({
      name: "weather",
      label: "Weather",
      description: "Get weather for a city",
      parameters: Type.Object({ city: Type.String() }),   // Type 来自 @sinclair/typebox
      async execute(_toolCallId, params) {
        const { city } = params as { city: string };
        const text = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=3`).then((r) => r.text());
        return { content: [{ type: "text", text }], details: {} };
      },
    }), { name: "weather" });
  },
};

关键差异:

  • 生命周期:@tool 装饰的工具在模块导入时就已成型,要按运行时条件增减工具,得由调用方自己组织工具列表。OpenClaw 把这件事收进了工厂函数:每次组装工具时根据配置、会话、是否沙箱等条件有条件地提供工具。
  • 隔离性:两者在这一点上其实相同——LangChain 工具可以 import 任何 Python 模块,OpenClaw 的原生插件也与网关同进程运行、可以 import 任何 Node.js 模块;PluginRuntime 是能力入口,不是沙箱(见 9.5)。
  • 冲突处理:OpenClaw 的注册表会检测并记录冲突(包括工具同名),但不崩溃——这个"发现问题但继续运行"的哲学贯穿整个系统。

9.19.2 OpenClaw vs. Semantic Kernel Plugins

Semantic Kernel(SK)是微软的 Agent 框架,其插件系统在设计理念上最接近 OpenClaw——都强调类型安全和结构化注册。但两者在关键问题上做出了不同选择。

SK 的方式:插件是 C#/Python 类,通过 [KernelFunction] 属性标注。

// Semantic Kernel 方式
public class WeatherPlugin
{
    [KernelFunction]
    [Description("Get weather for a city")]
    public async Task<string> GetWeather(string city)
    {
        // ...
    }
}

// 注册
kernel.Plugins.AddFromType<WeatherPlugin>();

关键设计差异:

维度 Semantic Kernel OpenClaw
注册方式 类型反射(AddFromType) 工厂函数(显式返回)
配置 UI 无 uiHints 自动生成配置界面
多版本共存 不支持 诊断记录冲突,保留最高优先级
跨语言 C# / Python / Java SDK TypeScript(Plugin SDK)
通道集成 无 插件可同时注册工具+通道+Provider
安装安全 无预扫描 openclaw plugins install 安装前做危险代码扫描(src/plugins/install-security-scan.ts)
配置生效 需重启 配置变更需重启网关,默认运行方式下写入配置后自动完成进程内重启

最深层的区别:SK 的插件系统设计目标是"让 LLM 调用 .NET 函数"——它是一个函数调用桥接层。OpenClaw 的插件系统目标是"让第三方扩展整个运行时"——它是一个微内核扩展框架。SK 的插件只能注册函数,OpenClaw 的插件可以同时注册通道、Provider、工具、命令、钩子——这是运行时与 SDK 的根本定位差异。

9.19.3 OpenClaw vs. AutoGPT Plugins

AutoGPT 的插件系统经历了两代:

  • 第一代(Legacy):Python 包,通过 __init__.py 中的钩子函数集成。耦合度极高,插件需要直接操作 AutoGPT 的内部状态。
  • 第二代(Forge):基于 OpenAPI spec 的标准化工具定义。这是一个显著的进步——工具定义与实现解耦。

OpenClaw 的差异化:

  • AutoGPT 的插件系统只扩展工具。OpenClaw 的插件系统扩展一切——通道、Provider、工具、命令、安全策略。
  • 隔离上 OpenClaw 并不领先:它的原生插件同样与宿主同进程运行、没有沙箱;它多出来的是安装环节的危险代码扫描,以及用 plugins.allow 限定只加载点名插件的白名单。
  • AutoGPT 没有配置 UI 生成。OpenClaw 的 uiHints 让每个插件配置项都有友好的用户界面。

9.19.4 设计选择的深层原因

为什么 OpenClaw 选择了"注册式"而非"注入式"?这不是偶然的——它源于 OpenClaw 作为运行时(而非框架)的定位。

框架的更新策略是"你更新依赖版本"——当 LangChain 从 v0.1 升到 v0.2 时,你的工具代码可能需要修改。运行时的更新策略是"运维方升级运行时,扩展代码不动"——就像 Nginx 升级不需要修改你的 PHP 代码一样。

注册式架构使得 OpenClaw 可以自由重构内部实现,只要注册 API 保持稳定。源码中 src/plugin-sdk/ 目录就是这个稳定接口的体现——它没有独立的版本号,而是经根 package.json 的 exports 以 openclaw/plugin-sdk/* 子路径随主包一起发布;旧的 openclaw/extension-api 则保留为带弃用警告的兼容桥(见 9.14)。

9.20 本章小结

插件系统是长寿软件的共同特征:Emacs 近五十年、Vim 三十多年、WordPress 二十多年、VS Code 在短短几年内超越老牌 IDE,都靠插件延续生命力。它解决了一个核心矛盾——软件作者想控制核心,用户需求千差万别;插件让作者保留核心的稳定、让用户按需扩展外围。

本章归纳的核心要点——注册表、钩子、SDK、隔离、冲突检测——在 Emacs mode、Vim plugin、VS Code Extension API、Chrome Manifest、WordPress hooks、Web Extension 里反复出现,构成了可扩展系统的设计模式语言。

与《Claude Code 源码深度解析》专栏第 15 章(Skill 与插件系统)、《Tokio 源码深度解析》专栏第 18 章(多 runtime 架构)、《微前端源码精讲》专栏第 11 章(Module Federation 2.0 与 Rspack)一起看——Claude Code 的 Skill + Plugin 让用户扩展 AI 能力;Tokio 的多 runtime 架构让用户定制异步执行器;微前端的 MF 插件扩展模块联邦行为;OpenClaw 的插件让用户添加通道、Provider、工具——它们架构上共享同一套模式,但按各自领域特性做了独特适配。

本章深入剖析了 OpenClaw 的插件与扩展系统:

  • 插件定义:通过 OpenClawPluginDefinition 定义插件的身份、配置和注册逻辑
  • 注册表:PluginRegistry 集中管理所有插件组件,提供冲突检测和诊断收集
  • 加载器:loadOpenClawPlugins(同步函数,不是 loadPlugins)发现、加载、注册和激活插件
  • 运行时:PluginRuntime 为插件提供统一的系统能力入口(不是沙箱)
  • SDK:plugin-sdk 提供类型定义和工具函数,简化插件开发
  • 钩子系统:允许插件在系统事件发生时执行自定义逻辑
  • 提供商插件:支持添加新的 LLM 提供商;语音、媒体理解、图像生成、搜索提供商另有专门的注册方法
  • 工具注册:支持条件性、上下文感知的工具注册
  • 扩展 API:提供向后兼容的导入路径

插件系统是 OpenClaw 扩展性的基石,集中体现了全专栏五大设计哲学中的多项原则:通道无关(Channel-agnostic)——通道通过插件接入,核心不依赖任何特定通道;模型无关(Provider-agnostic)——LLM 提供商通过插件注册;渐进式复杂度(Progressive Disclosure)——从零插件的最简配置到完整的 Plugin SDK 开发,每一层复杂度都有对应的入口。

插件系统是与未来的接口契约——一次跨越时间的握手,握手的对象是尚不存在的开发者。 二十个注册方法和二十六个钩子点,是系统对"扩展者会需要什么"的预测。预测对了,生态繁荣;预测错了,每个插件都是 hack,每次升级都是灾难。土壤决定花园。


至此,插件与扩展的主线讲完。下一章进入工具系统——插件用 registerTool 注册进来的工具,要经过工具策略管线的层层过滤,才会出现在 LLM 的工具列表里。

关于"插件系统的演化"、有一个长线观察值得分享——所有成功的平台系统、最终都会经历"从精简到膨胀"的周期。VS Code 的扩展市场如今已是一个庞大的生态。这种膨胀既是好事(生态繁荣)也是挑战(管理复杂度)。OpenClaw 作为一个年轻项目、现在还处在"精简"阶段——但它的插件系统设计为未来的"膨胀"预留了空间,分发渠道的雏形在 v2026.3.31 上已经能看到:openclaw plugins install 支持 npm 包、本地路径与压缩包、clawhub: 包以及 Claude 兼容插件市场里的条目,openclaw plugins marketplace list 可以列出一个市场源发布的插件。生态真正起来之后、现在这个专栏里讲的"插件 SDK 基础"就成了生态参与者的起点。希望这个专栏帮你提前站上这个起点。

思考题

  1. 概念理解:OpenClaw 的插件系统为什么选择"微内核"而非"宏内核"架构?这个决策对插件开发者的体验有什么影响?
  2. 实践应用:设计一个将 OpenClaw 与 Jira 集成的插件——当 Agent 收到特定指令时自动创建 Jira 工单。你会利用插件系统的哪些扩展点?
  3. 开放讨论:插件系统的安全性与灵活性之间存在天然矛盾。你认为应该信任社区插件到什么程度?是否需要类似"应用商店审核"的机制?

📚 推荐阅读


延伸阅读:插件经济学与开发者生态

从"插件系统"到"插件经济"——这两个概念之间隔着一条鸿沟、跨越这条鸿沟需要"开发者生态运营"的全套能力。一个单纯的插件系统只需要解决技术问题——注册、加载、隔离、钩子、版本管理——但一个繁荣的插件经济需要解决远超技术的问题。开发者凭什么写插件?插件怎么分发?用户怎么发现插件?插件怎么收费?平台怎么抽成?版权纠纷怎么处理?恶意插件怎么防范?差评插件怎么下架?这些问题、每一个都足以写成一本书。VS Code 的 Marketplace 能成为业界标杆、背后是微软多年的开发者关系、技术文档、评论机制、认证体系、分发基建的综合投入——不是仅仅"技术好"就够的。

对于 OpenClaw 这样的年轻项目、"插件经济"还是一个远期目标、但"插件友好"是近期必须做到的事情。"插件友好"意味着几件具体的事情——文档清晰到"新手三十分钟能写出第一个插件";SDK 的 TypeScript 类型覆盖完整到"IDE 能给出九成的提示";示例插件丰富到"几乎所有场景都有模板可以抄";错误消息友好到"报错时能直接告诉开发者哪里出问题以及怎么修"。这些看似"小事"的体验细节、决定了一个插件系统能不能真正活起来——技术再好、开发者体验不行、也无人问津。"开发者体验工程"是一个和功能开发同样重要的独立工程领域——类型提示、调试工具、错误信息、示例模板,每一项都直接决定外部开发者会不会留下来。

延伸阅读:插件安全与供应链防护

2026 年这个时间点、"软件供应链攻击"已经是所有开源项目都要直面的问题——一个广泛使用的插件如果被恶意接管、影响范围可能是几百万用户。历史上发生过太多这样的事件——event-stream 事件(2018 年,一个 npm 包被恶意接管用于盗窃加密货币)、colors.js 事件(2022 年,作者故意引入死循环,波及大量下游项目)、xz-utils 事件(2024 年,长达三年的社工渗透差点植入 SSH 后门)——每一次都在提醒整个行业:"依赖别人的代码、就是在信任别人的一切"。OpenClaw 的插件系统、如果走向繁荣、迟早会面对同样的问题——需要签名机制、需要沙箱运行、需要行为审计、需要主动扫描。v2026.3.31 上已经有的是其中一小部分:安装前的危险代码扫描(发现高危模式即阻止安装,除非显式加 --dangerously-force-unsafe-install)、plugins.allow / plugins.deny 名单,以及工作区插件默认不启用;签名、沙箱运行、行为审计还是"未来议题"——了解这些议题的存在、对插件作者和插件用户都是必要的。

真正的安全不是"禁止所有风险"、而是"让风险显性化并可控"。禁止所有风险的方案在现实中行不通——那会让插件系统失去灵活性、没人再写插件。让风险显性化的方案才可持续——让插件声明所需权限、让用户看到这些权限、让运行时强制执行这些权限、让审计日志记录所有敏感操作。这种"信任但验证"的设计哲学、贯穿了 iOS 的 App 权限、Chrome 的 Extension 权限、Android 的 Runtime Permission、Linux 的 Capabilities 体系——OpenClaw 目前还没有走到这一步:官方明确把已安装的原生插件视为受信代码(SECURITY.md 的「Plugin Trust Boundary」),PluginRuntime 是能力入口而不是权限边界。对于关注安全的读者——建议你继续跟进《Claude Code 源码深度解析》专栏第 15 章(Skill 与插件系统)、《MCP 协议设计与实现》专栏第 15 章(OAuth 2.1 认证框架),以及本专栏第 13 章——它们合起来能给你一个比较完整的"AI 时代软件安全"视角。