OpenClaw权威指南 第 14 篇|插件与扩展系统:Extension SDK 的架构设计
核心与边缘的分离
OpenClaw 主仓库的 src/ 目录是核心——它包含 Gateway、Session、Agent 运行时、内存系统这些跑起来就必须有的东西。但一个可持续的开源项目不能把所有东西都塞进核心。Matrix 频道集成、LanceDB 内存后端、微软 Teams 适配器、OpenTelemetry 链路追踪——这些东西有人需要,但不是所有人都需要,而且维护它们需要各自领域的专业知识。
OpenClaw 的插件系统解决的就是这个”核心要精简,边缘要可扩展”的工程矛盾。
它的设计原则是一句话:发现和验证不需要执行代码,执行才需要。插件的元数据、配置 Schema、UI 标签、依赖声明,全部来自 manifest 文件,在运行时模块被加载之前就可以被读取、校验、展示。只有确认插件被启用且配置合法,才会执行实际的 TypeScript 代码。这个分离让 OpenClaw 能在不加载任何插件代码的情况下,就告诉你”这个插件缺少哪个配置项”、”那个插件与当前 OpenClaw 版本不兼容”。
四类插件来源
Gateway 启动时,src/plugins/discovery.ts 里的 discoverOpenClawPlugins() 函数扫描四个位置,收集所有候选插件。
Bundled 插件是随 OpenClaw npm 包一同分发的内置插件,放在 extensions/ 目录里,每次安装就已经在本地了。这类插件包括各核心平台的通道实现(Discord、Telegram、WhatsApp 的高级功能扩展)、browser 插件(浏览器工具的实际执行后端)、voice-call 插件(Twilio 语音通话集成)、以及 Provider 插件(Anthropic、OpenAI、Google、OpenRouter 等模型 Provider 的认证流程)。
全局插件安装在 ~/.openclaw/extensions/ 目录里,通过 openclaw plugins install 命令安装,对当前用户的所有 Agent 可见。这是第三方社区插件的标准安装位置。
工作区插件在当前 workspace 根目录下的 .openclaw/extensions/ 里,只对这个特定工作区的 Agent 可见。适合为特定项目安装项目级的专属插件,不影响其他工作区。
额外路径通过 plugins.load.paths 配置数组挂载,允许指向任意目录,主要用于本地开发调试——用 openclaw plugins install -l ./my-plugin 以软链接方式挂载本地插件目录,修改代码后重启 Gateway 即可测试,不需要发布 npm 包。
这四个来源的插件 ID 如果冲突,高优先级的覆盖低优先级:工作区 > 全局 > 额外路径 > bundled。一个工作区本地插件可以同名覆盖全局安装的插件,这让在不修改原始包的情况下做局部 patch 成为可能。
发现流程:从 manifest 到候选列表
发现流程读取的第一个文件不是 TypeScript,而是 openclaw.plugin.json——插件的静态 manifest 文件:
{"id": "nextcloud-talk","name": "Nextcloud Talk","description": "Self-hosted chat via Nextcloud Talk webhook bots.","version": "0.1.0","kind": "channel","channels": ["nextcloud-talk"],"configSchema": {"type": "object","additionalProperties": false,"properties": {"serverUrl": { "type": "string" },"botToken": { "type": "string" }},"required": ["serverUrl", "botToken"]},"uiHints": {"serverUrl": { "label": "Nextcloud Server URL", "placeholder": "https://cloud.example.com" },"botToken": { "label": "Bot Token", "sensitive": true }}}
configSchema 是 JSON Schema,用于在不执行任何插件代码的情况下就对用户的配置做类型验证和 UI 渲染。Web UI 的配置表单、openclaw config validate 命令、Zod schema 的动态合并,全部基于这个 schema 工作。uiHints 为每个字段提供人类可读的标签和占位符,sensitive: true 让 Web UI 对该字段做密码框处理。
如果插件目录里没有 openclaw.plugin.json,发现器会退而求其次读 package.json 里的 openclaw 字段——这是兼容老格式的回退路径,新插件应该优先提供独立的 manifest 文件。
候选插件收集完毕后,每个候选经过三项安全前检:路径解析——用 realpath() 解析候选入口文件路径,确认解析后的路径仍然在插件根目录之内(防止符号链接逃逸);权限检查——非 bundled 插件的目录不能是 world-writable(其他用户可写),否则存在恶意覆盖风险;所有权验证——检查目录的实际所有者,和当前进程的有效 UID 是否一致。任何一项检查不通过,这个候选直接被丢弃,不进入后续的加载流程,openclaw plugins doctor 会报告被丢弃的原因。
启用状态解析
通过安全前检的候选插件,进入启用状态解析,由 resolveEnableState() 函数决定这个插件最终是 enabled、disabled、blocked 还是 slot-selected。
plugins.allow 和 plugins.deny 提供白名单和黑名单控制。deny 优先于 allow——出现在 deny 里的插件永远不加载,不管 allow 里有没有它。如果配置了 allow 白名单但不是空数组,则只有白名单里的插件才被允许加载,其余全部 blocked。这个机制适合生产部署时锁定插件集合,防止意外安装新插件改变系统行为。
plugins.entries.<id>.enabled 控制单个插件的启用开关,false 时插件被 disabled,不执行任何代码,但 manifest 仍然被读取,配置 Schema 仍然合并进来(让你在重新启用时不需要重新填写配置)。
Slots 是一个特殊的独占机制,用于”只能有一个实现”的扩展点。plugins.slots.memory 定义内存后端的独占 slot,默认使用 builtin 后端,设为 "memory-lancedb" 就切换到 LanceDB 后端。同一个 slot 只能有一个插件被激活,后来者自动进入 slot-not-selected 状态,不报错,只是不执行。
运行时加载:jiti 让 TypeScript 直接跑
通过启用状态解析后,插件才进入运行时加载阶段。这里用的是 jiti——一个能直接加载和执行 TypeScript 文件的运行时模块加载器,不需要预先编译:
const jiti = createJiti(import.meta.url, {interopDefault: true,extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"],alias: {"openclaw/plugin-sdk": pluginSdkAlias,},});const mod = jiti(candidate.source) as OpenClawPluginModule;
alias 配置把所有 openclaw/plugin-sdk 的导入重定向到 pluginSdkAlias——一个指向主包 dist 目录里 Plugin SDK 子路径导出的绝对路径。这是 Plugin SDK 的设计约束:插件只能通过 openclaw/plugin-sdk 及其子路径(openclaw/plugin-sdk/core、openclaw/plugin-sdk/telegram 等)导入主包的内容,不能用相对路径绕过这个边界直接访问 src/ 目录的内部实现。
这个边界有双重意义:对插件开发者来说,Plugin SDK 是稳定的公开 API,破坏性变更会走版本号管理;对 OpenClaw 核心来说,内部实现可以自由重构,只要 Plugin SDK 的接口不变,所有插件就不会受影响。
bundled 插件在开发时使用 openclaw/plugin-sdk 自引用路径,package.json 里有 "openclaw/plugin-sdk": "." 的 self-import 声明。外部插件使用同样的路径,jiti 的 alias 把它指向安装好的主包的 dist 目录。两者在写法上完全一致,只是解析目标不同——这让 bundled 插件的开发体验和外部插件完全相同,共用同一套开发规范。
register(api):插件能做的六件事
加载完成的模块必须导出一个 register 函数(或导出一个带 register 方法的对象)。这个函数接收 OpenClawPluginApi 对象作为唯一参数,通过这个 API 向系统注册所有能力:
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";export function register(api: OpenClawPluginApi) {// 注册通道api.registerChannel({ ... });// 注册工具api.registerTool({ ... });// 注册模型 Providerapi.registerProvider({ ... });// 注册 Hookapi.registerHook({ ... });// 注册 HTTP 路由api.registerHttpRoute({ ... });// 注册 CLI 命令api.registerCommand({ ... });}
registerChannel 是最复杂的注册类型,需要提供四个 Adapter 实现:ChannelConfigAdapter(解析和校验通道配置)、ChannelOutboundAdapter(发送消息)、ChannelSecurityAdapter(dmPolicy 访问控制)、ChannelStatusAdapter(健康检查和状态报告)。这四个 Adapter 覆盖了第六篇讲过的通道生命周期的全部职责,让外部通道插件和内置通道在行为上完全对等。
registerProvider 让插件向 OpenClaw 的认证系统注入新的模型 Provider,包括 OAuth 流程、API Key 验证、Device Code 认证三种方式。注册后,用户可以通过 openclaw models auth login --provider <id> 在终端里完成认证,不需要任何外部脚本。这是 Provider 插件(OpenAI、Google、MiniMax 等)能做到”一行命令完成 OAuth 授权”的原因。
registerHook 让插件向工作流的关键节点注入代码,在消息到达前后、Agent 执行前后、通道连接时、Gateway 启动关闭时触发。Hook 目录结构遵循 HOOK.md(给 Agent 看的钩子说明)+ handler.ts(实际执行代码)的格式,这让 Hook 也可以有 Skills 那样的自然语言描述,Agent 可以理解这个钩子在做什么。
registerHttpRoute 把自定义 HTTP 路由挂载到 Gateway 的 HTTP 服务器上,比如 Webhook 类通道(Slack、Google Chat)需要注册一个 POST 路由来接收平台推送的消息。这个路由和 Gateway 内置的 /v1/* 路由共享同一个 HTTP 服务器实例,但路径前缀隔离。
Plugin SDK 的三个模块
Plugin SDK 发布在 openclaw 主包的子路径导出下,分三个模块组织。
openclaw/plugin-sdk(核心模块)导出所有类型定义、definePluginEntry 工具函数(包装 register 函数,提供运行时安全检查)、以及 registerPluginHooksFromDir(批量注册一个目录下的所有 Hook,是最常用的 Hook 注册方式)。
openclaw/plugin-sdk/core 导出 Gateway 内部的核心工具函数,包括 Session 工具、消息规范化、配置工具等。这些是内部 API,破坏性变更概率更高,建议只在必要时使用。
openclaw/plugin-sdk/<channel> 系列(telegram、discord、slack 等)导出各通道适配器的基类和工具函数。如果你在写一个基于现有通道做增强的插件,可以直接复用这些基类,不需要从零实现 Adapter 接口。
有一条需要牢记的边界规则:插件内部不能使用 openclaw/plugin-sdk/<extension> 格式的路径自引用(即在插件代码里引用另一个 bundled 扩展的子路径),因为这些路径只在主包内部有解析规则,在插件的 jiti 运行时环境里不存在对应的解析。scripts/check-extension-plugin-sdk-boundary.mjs 脚本在 CI 流水线里静态检测这类违规导入。
内置插件一览
extensions/ 目录里的 bundled 插件值得单独罗列,它们代表了 OpenClaw 对”哪些能力值得内置”的判断:
browser 是浏览器工具的执行后端,默认启用,如果你要替换浏览器实现,需要先禁用它。voice-call 提供 Twilio 和 log 两种后端的语音通话能力,默认禁用。copilot-proxy 是 VS Code Copilot Proxy 桥接,把 GitHub Copilot 协议流量转发给 OpenClaw Agent,默认禁用。memory-lancedb 是基于 LanceDB 的高性能向量内存后端,作为 memory slot 的可选替代实现。diagnostics-otel 提供 OpenTelemetry 链路追踪和指标导出,适合需要监控 Agent 执行性能的场景。nostr 是 Nostr 去中心化协议的通道适配器。zai 是 z.ai 模型服务的 Provider 插件。
所有 Provider 插件(Anthropic、OpenAI、Google、OpenRouter 等 28 个)也是 bundled 插件,但它们以轻量 manifest 形式存在——只提供认证流程和模型 Provider 注册,不包含大量业务逻辑,保持了体积精简。
供应链安全:三道防线
插件系统和 Skills 系统面临同样的供应链安全挑战,但因为插件是真正的 TypeScript 代码而不是 Markdown 说明书,风险等级更高。OpenClaw 为插件设置了三道防线。
第一道:安装时禁用 lifecycle scripts。openclaw plugins install 内部调用 npm install --ignore-scripts,npm 的 preinstall、postinstall、prepare 钩子全部不执行。这消除了”安装即 RCE(远程代码执行)”这类最常见的 npm 供应链攻击路径。
第二道:pnpm.onlyBuiltDependencies 白名单。monorepo 根目录的 pnpm-workspace.yaml 维护了一个允许执行构建脚本的依赖白名单,只有真正需要编译原生模块的包(better-sqlite3、sharp、canvas 等)才在名单里,其余所有包的构建脚本都被 pnpm 忽略。这防止了依赖链深处的恶意包通过构建脚本悄悄执行代码。
第三道:路径安全检查。发现阶段的 realpath() + 所有权验证,防止符号链接劫持和其他用户的目录污染。
三道防线之后,官方文档仍然明确写着:插件在进程内与 Gateway 共享运行,把它当成受信任的代码对待,只安装你信任的插件。安全机制降低了攻击面,但不能代替信任判断。
开发一个最小插件
理解了整个体系,写一个最小可用插件的步骤非常清晰。
创建目录结构:
my-plugin/├── openclaw.plugin.json ← manifest├── package.json ← 声明 openclaw.extensions└── index.ts ← 入口,导出 register 函数
package.json:
{"name": "my-plugin","openclaw": {"extensions": ["./index.ts"]}}
openclaw.plugin.json:
{"id": "my-plugin","name": "My Plugin","description": "做某件事的插件","version": "1.0.0"}
index.ts:
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";export functionregister(api: OpenClawPluginApi) {api.registerTool({name: "my_tool",description: "这个工具做某件事",inputSchema: {type: "object",properties: {input: { type: "string", description: "输入" }},required: ["input"]},execute: async ({ input }) => {return { result: `处理了: ${input}` };}});}
本地安装测试:
openclaw plugins install -l ./my-pluginopenclaw gateway restartopenclaw plugins list # 确认插件出现
整个过程不需要发布 npm,不需要构建步骤,jiti 在运行时直接加载 TypeScript 源文件。
小结
OpenClaw 的插件系统是一个控制平面(manifest + 配置 Schema)与数据平面(register 函数 + 运行时行为)严格分离的架构。控制平面不执行代码,保证了配置验证、UI 渲染、依赖检查的安全性;数据平面通过 jiti 在运行时动态加载,保证了开发体验的零构建开销。Plugin SDK 的子路径导出边界让核心可以自由重构,插件开发者只需要依赖稳定的公开 API。
这套设计让一个 Matrix 通道插件的作者,能用和 OpenClaw 核心团队完全一致的开发体验构建他的扩展,不需要 fork,不需要修改主仓库,不需要等待合并请求。
下一篇,我们进入多 Agent 系统——当一个任务复杂到单个 Agent 处理不了,父 Agent 是如何生成子 Agent、编排工作流、以及整个系统是如何维持确定性的。
源码参考:src/plugins/discovery.ts · src/plugins/loader.ts · src/plugins/registry.ts · src/plugins/manifest-registry.ts · src/plugin-sdk/ · extensions/voice-call/ · docs/tools/plugin.md基于 commit bf6ec64f 版本
夜雨聆风