OpenClaw 设计与实现
第9章 插件与扩展系统
第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/><工作区>/.openclaw/extensions"]
B --> C3["origin=bundled<br/>随包分发的 extensions/"]
B --> C4["origin=global<br/><配置目录>/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 与模型:
-
协议标准化:Agent 运行时面对的是
@mariozechner/pi-agent-core的统一StreamFn与消息事件,而不是各家 SDK 的原始流式分片。各家协议差异由底层pi-ai按模型的api字段(如"openai-completions")选择的适配器消化;Provider 插件需要时再用createStreamFn自带传输实现,或用wrapStreamFn在通用包装之后补请求头、改请求体。 -
方言声明:不同厂商在工具 schema 形态、tool call id 格式、thinking 签名的保留与清洗上各有方言。Provider 插件通过
capabilities(src/agents/provider-capabilities.ts的anthropicToolSchemaMode、transcriptToolCallIdMode、preserveAnthropicThinkingSignatures等开关)把这些差异声明出来,核心据此转写请求与会话记录。 -
模型级开关:模型定义里的
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 第三步:理解发生了什么
当网关启动时,发生了以下序列:
- 发现:
discoverOpenClawPlugins()扫描plugins.load.paths(origin=config)以及工作区/内建/全局三个目录,发现weather插件;读取openclaw.plugin.json,确认 id。 - 加载:Jiti 加载
index.ts,执行 TypeScript 编译,导出weatherPlugin对象。 - 注册:先用 manifest 的
configSchema校验entries.weather.config,通过后系统调用weatherPlugin.register(api),插件通过api.registerTool()注册能力(如果还用了api.registerHook()/api.on(),也在这一步)。 - 入册:工具进入
PluginRegistry.tools;若注册了钩子,registerHook的进入PluginRegistry.hooks,api.on的进入PluginRegistry.typedHooks。 - 可用:下次调用 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 能够:
- 查询 Notion 数据库中的任务列表
- 在 Agent 完成任务时自动更新 Notion 状态
- 通过 Webhook 接收 Notion 的变更通知
- 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 为什么使用注册表模式?
注册表模式带来了:
- 集中管理:所有插件组件在一个地方注册和查询
- 冲突检测:可以在注册时检测命名冲突
- 诊断收集:可以收集所有插件的问题,而非一个失败导致全部中止
9.18.2 为什么使用工厂函数?
工具工厂函数允许:
- 条件注册:根据配置决定是否注册工具
- 上下文注入:工厂在为一次 Agent 运行组装工具时拿到当前会话的上下文(agentId、sessionKey、是否沙箱等)
- 延迟创建:工具对象只在需要时创建
9.18.3 为什么需要 PluginRuntime?
PluginRuntime 暴露了:
- 统一入口:插件通过运行时拿到配置、子 Agent、媒体、通道辅助等能力,不必 import 核心内部模块(这是约定而非强制——原生插件与网关同进程,技术上仍能 import 任何东西)
- 收敛接口面:运行时是对插件公开的能力面,内部实现重构时只需维持这一层
- 测试隔离:测试时可以注入 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 基础"就成了生态参与者的起点。希望这个专栏帮你提前站上这个起点。
思考题
- 概念理解:OpenClaw 的插件系统为什么选择"微内核"而非"宏内核"架构?这个决策对插件开发者的体验有什么影响?
- 实践应用:设计一个将 OpenClaw 与 Jira 集成的插件——当 Agent 收到特定指令时自动创建 Jira 工单。你会利用插件系统的哪些扩展点?
- 开放讨论:插件系统的安全性与灵活性之间存在天然矛盾。你认为应该信任社区插件到什么程度?是否需要类似"应用商店审核"的机制?
📚 推荐阅读
- VSCode Extension API — 业界最成功的插件系统之一,设计理念值得借鉴
- Micro-kernel Architecture (Wikipedia) — 微内核架构理念,与插件系统的设计哲学一脉相承
- Plugins in Semantic Kernel — 微软 Agent 框架的插件设计,另一种思路
- OSGi 规范 — Java 生态的模块化与插件化标准,插件系统设计的经典参考
- Obsidian 插件开发文档 — 现代桌面应用插件系统的优秀案例
延伸阅读:插件经济学与开发者生态
从"插件系统"到"插件经济"——这两个概念之间隔着一条鸿沟、跨越这条鸿沟需要"开发者生态运营"的全套能力。一个单纯的插件系统只需要解决技术问题——注册、加载、隔离、钩子、版本管理——但一个繁荣的插件经济需要解决远超技术的问题。开发者凭什么写插件?插件怎么分发?用户怎么发现插件?插件怎么收费?平台怎么抽成?版权纠纷怎么处理?恶意插件怎么防范?差评插件怎么下架?这些问题、每一个都足以写成一本书。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 时代软件安全"视角。