OpenClaw 插件系统:如何打造全能私人助理 –OpenClaw源码深度系列第2期
早上 9 点,你打开聊天软件,本来只想回一句”收到”。结果下一秒,你又开始在日历、邮箱、Notion、工单系统、群聊线程之间横跳 😮💨

真正让人崩溃的不是事情多,而是**“要去多少地方才能把一件事理清楚”**。
OpenClaw 爆火的核心原因,不是”模型更强”,而是它把你每天待着时间最长的地方——聊天——变成了真正意义上的驾驶舱 🎛️。你不需要换工作流,助理就已经在你常用的渠道里等着了。
而撑起这一切的,是一套插件系统:让 OpenClaw 能不断”长出新器官”(渠道入口、工具、后台服务、网关方法、CLI……),而不是靠散装脚本凑出来的运气。
本文坚持一个写法原则:主线只讲”怎么做成”,源码引用 100% 可对照,避坑细节放 Notes 留给想深挖的人。
目录
-
私人助理最重要的不是聪明,是”出现在你常用的地方” -
插件是 Tool?是 Hook?都不是——它是能力工厂 -
有 MCP 了,还需要 Plugin 干什么 -
装上插件,它怎么被发现?怎么被启用? -
TypeScript 插件为什么能即写即跑——Jiti + alias -
为什么强制要 configSchema? -
为什么 register 必须同步? -
闭环示例:把”日程 + 重要邮件”每天推到你的聊天里
1. 私人助理最重要的不是聪明,是”出现在你常用的地方” 📍
为什么单有一个聪明的模型,助理还是不够得力?
因为”得力”的第一要素不是能力,而是入口摩擦为零。你不会为了问一个问题,专门打开一个新 App。
OpenClaw 的 12 个官方扩展,几乎都在解决同一件事:把 OpenClaw 塞进 Teams、Google Chat、Matrix、Mattermost、iMessage、Twitch、语音电话……这些”你已经在用的地方”。而承载这件事的核心抽象,是 ChannelPlugin。
ChannelPlugin 不是一个简单的 sendMessage 接口,而是一套按需组合的渠道能力模块:
export typeChannelPlugin<ResolvedAccount = any>={id: ChannelId;meta: ChannelMeta;capabilities: ChannelCapabilities;onboarding?: ChannelOnboardingAdapter;// 引导用户配置接入config: ChannelConfigAdapter;// 账号/凭证管理pairing?: ChannelPairingAdapter;// 用户 ID 映射与白名单outbound?: ChannelOutboundAdapter;// 发送消息gateway?: ChannelGatewayAdapter;// 入站监听(startAccount)threading?: ChannelThreadingAdapter;// 线程上下文streaming?: ChannelStreamingAdapter;// 流式合并策略status?: ChannelStatusAdapter;// 健康检查actions?: ChannelMessageActionAdapter;// 编辑/撤回/反应等directory?: ChannelDirectoryAdapter;// 联系人/群组目录// ... 还有十几个可选适配器};
每个适配器都是可选的——一个最小可用渠道插件只需要实现 config + gateway + outbound,其余能力按平台特性按需加。这就是”私人助理能适配不同渠道”而又保持核心逻辑一致的关键。
Notes:
threading(线程上下文)、streaming(流式合并)、status(健康检查)是三个最容易被忽视却最影响”像不像产品”的能力。Mattermost 的流式合并配置(1500 字符 + 1000ms idle 阈值)、BlueBubbles 的 edit/unsend/reaction 等,都是在这一层实现的。
2. 插件是 Tool?是 Hook?都不是——它是能力工厂 🏭
如果你已经能写工具(Tool),为什么还要多学”插件”这个概念?
因为工具是”一个能力”,插件是”一批能力的注册入口”。最直白的例子来自 Voice Call 插件,同一个 register(api) 调用,一次性往宿主注册了四种形态完全不同的东西:
register(api){api.registerService(voiceCallService);// 后台常驻服务api.registerTool(voiceCallTool);// Agent 可调用工具api.registerGatewayMethod("voicecall.initiate", handler);// 外部 RPC 方法api.registerCli(registerVoiceCallCli);// CLI 命令}
插件的”注册面”来自 OpenClawPluginApi,远不止工具:
const createApi =(record, params): OpenClawPluginApi =>({registerTool:(tool, opts)=>registerTool(record, tool, opts),registerHook:(events, handler)=>registerHook(record, events, handler,...),registerHttpRoute:(params)=>registerHttpRoute(record, params),registerChannel:(registration)=>registerChannel(record, registration),registerGatewayMethod:(method, handler)=>registerGatewayMethod(record, method, handler),registerCli:(registrar, opts)=>registerCli(record, registrar, opts),registerService:(service)=>registerService(record, service),registerCommand:(command)=>registerCommand(record, command),registerProvider:(provider)=>registerProvider(record, provider),on:(hookName, handler)=>registerTypedHook(record, hookName, handler, opts),});
这些注册产物最终都落入 PluginRegistry,统一被系统管理:
exporttypePluginRegistry={tools: PluginToolRegistration[];hooks: PluginHookRegistration[];channels: PluginChannelRegistration[];services: PluginServiceRegistration[];httpRoutes: PluginHttpRouteRegistration[];gatewayHandlers: GatewayRequestHandlers;cliRegistrars: PluginCliRegistration[];commands: PluginCommandRegistration[];diagnostics: PluginDiagnostic[];};
选哪种注册方式?一张决策表:
|
|
|
|---|---|
|
|
registerChannel |
|
|
registerService |
|
|
registerTool |
|
|
registerHook
api.on |
|
|
registerHttpRoute
registerGatewayMethod |
3. 有 MCP 了,还需要 Plugin 干什么 🤔
既然 MCP 能标准化工具调用,OpenClaw 的 Plugin 在补什么洞?
这是整篇文章最核心的架构问题,答案藏在两个词里:触发方向。
MCP 是 Pull 世界 ↙️:模型判断”我需要这个工具”,主动发起调用,拿到结果,结束。它擅长把外部能力(数据库、GitHub、内部 API)变成模型可用的接口,协议层也是为此设计的——JSON-RPC,请求-响应,跨进程隔离,跨语言复用。
Plugin 是 Push 世界 ↗️:外部事件不断涌入(新消息、账号 webhook、Relay 订阅),系统必须持续在线、稳定承接。Teams 的 Bot Framework 回调、Matrix 的 room sync、电话的实时音频流——这些都不是”模型决定要不要处理”,而是”必须有人一直在那儿接着”。
更重要的是,Plugin 解决的不只是”能不能接住”,还有入站之后的一系列入口工程问题:
-
账号生命周期( startAccount→ 连接 → 重连 → 优雅退出) -
线程上下文( threadTs/channelId在多轮对话里的一致性) -
限流与合并(避免把群聊刷爆) -
健康检查(知道它活着没)
这些是 MCP 工具协议天然覆盖不到的。OpenClaw 本身也选择了这条路:MCP client 被做成插件形态(extensions/mcp-client)接进来,而不是把整个系统建立在 MCP 之上。
一句话判断规则:
-
🟠 事件主动推进来,必须稳定接住 → Plugin(Channel / Service) -
🔵 模型自主决策要不要用 → MCP 工具(或 registerTool) -
🟢 告诉模型怎么用这些工具 → Skills
这不是二选一,成熟的 Agent 系统通常是:Plugin 做入口,MCP 做工具供应,Skills 做流程固化。

4. 装上插件,它怎么被发现?怎么被启用? 🔍
为什么有时候”装了插件”,系统就像没看见?
因为从”文件在磁盘上”到”插件真正运行”,中间有两道门:发现(Discovery) 和 启用(Enable)。
4.1 Discovery:四层来源,固定优先级
discoverOpenClawPlugins() 扫描四个位置,按优先级从高到低:
// 1. config 指定路径(最高优先级,适合临时覆盖/本地开发)for(const extraPath of params.extraPaths ??[]){discoverFromPath({ rawPath: extraPath, origin:"config",...});}// 2. 工作区插件(适合团队协作/版本控制)discoverInDirectory({ dir: path.join(workspaceRoot,".openclaw","extensions"), origin:"workspace",...});// 3. 全局插件(用户个人安装)discoverInDirectory({ dir: path.join(resolveConfigDir(),"extensions"), origin:"global",...});// 4. 内置插件(最低优先级)discoverInDirectory({ dir: bundledDir, origin:"bundled",...});
同一个插件 ID 在多处出现时,先被发现的胜出,后来的记录为 overridden by ${existingOrigin}。这是可预测的,也是可排查的。

📌 开发期推荐放置路径:<workspace>/.openclaw/extensions/<your-plugin>/。优先级高、可版本控制、团队一致。
4.2 Enable:确定性判定链
找到了不等于会跑。resolveEnableState() 按顺序执行一条判定链:
// 1. 全局总开关if(!config.enabled)return{ enabled:false, reason:"plugins disabled"};// 2. 黑名单拦截if(config.deny.includes(id))return{ enabled:false, reason:"blocked by denylist"};// 3. 白名单过滤(设置了白名单则只加载名单内的)if(config.allow.length >0&&!config.allow.includes(id))return{ enabled:false, reason:"not in allowlist"};// 4. Memory slot 优先匹配if(config.slots.memory === id)return{ enabled:true};// 5. 单插件级别开关const entry = config.entries[id];if(entry?.enabled ===true)return{ enabled:true};if(entry?.enabled ===false)return{ enabled:false, reason:"disabled in config"};// 6. bundled 插件默认禁用(除非在默认启用白名单里)if(origin ==="bundled"&&!BUNDLED_ENABLED_BY_DEFAULT.has(id))return{ enabled:false, reason:"bundled (disabled by default)"};// 7. 其他情况:默认启用return{ enabled:true};
这条链里有一个 Agent 特有的设计值得单独说——memory slot 单选机制。
记忆类插件(kind: "memory")只能同时激活一个:
export functionresolveMemorySlotDecision({ id, kind, slot, selectedId }){if(kind !=="memory")return{ enabled:true};if(slot ===null)return{ enabled:false, reason:"memory slot disabled"};if(typeof slot ==="string")return slot === id?{ enabled:true, selected:true}:{ enabled:false, reason:`memory slot set to "${slot}"`};// 无显式 slot:先到先得if(selectedId && selectedId !== id)return{ enabled:false, reason:`memory slot already filled by "${selectedId}"`};return{ enabled:true, selected:true};}
多个记忆后端同时写入,会造成语义混乱。这里直接在架构层把冲突掐掉。
Notes:
plugins.allow一旦设置,就变成了白名单——没在里面的全部不加载,包括你以为”默认启用”的插件。开发阶段如果不确定,不设置allow,只用entries[id].enabled = true更安全。
5. TypeScript 插件为什么能即写即跑——Jiti + alias ⚡
Node 不跑 .ts,插件却是 TypeScript 写的。OpenClaw 怎么做到”装上就能运行”?
loader 用 Jiti 做即时转译,核心配置如下:
const jiti =createJiti(import.meta.url,{interopDefault:true,// 自动展开 { default: ... } 包装,兼容 CJS/ESM 混用extensions:[".ts",".tsx",".mts",".cts",".js",".mjs",".cjs",".json"],...(pluginSdkAlias?{ alias:{"openclaw/plugin-sdk": pluginSdkAlias }}:{}),});
重点在 alias。插件里写 import { ... } from "openclaw/plugin-sdk",但插件安装在 ~/.openclaw/extensions/ 里,并不存在 openclaw 这个 npm 包。alias 把这个路径映射到核心 SDK 的真实文件。

映射目标由 resolvePluginSdkAlias() 动态决定——它从 loader 文件所在位置向上最多遍历 6 层目录,优先寻找 dist/plugin-sdk/index.js(生产/测试环境)或 src/plugin-sdk/index.ts(开发环境):
const preferDist = process.env.VITEST|| process.env.NODE_ENV==="test"|| isDistRuntime;for(let i =0; i <6; i++){const srcCandidate = path.join(cursor,"src","plugin-sdk","index.ts");const distCandidate = path.join(cursor,"dist","plugin-sdk","index.js");const ordered = preferDist ?[distCandidate, srcCandidate]:[srcCandidate, distCandidate];for(const candidate of ordered){if(fs.existsSync(candidate))return candidate;}cursor = path.dirname(cursor);}
这套设计的收益:插件作者 TS 即写即用,不需要构建步骤;宿主进程不被全局 module loader 污染(每次 jiti(path) 是独立调用);生产环境优先走已编译的 dist,减少转译开销。
loader 还兼容多种导出格式,无论你怎么写都能被识别:
function resolvePluginModuleExport(moduleExport){const resolved = moduleExport?.default ?? moduleExport;// 裸函数 → 直接视为 registerif(typeof resolved ==="function")return{ register: resolved };// 对象 → 取 register,兼容旧字段名 activateif(resolved &&typeof resolved ==="object"){const register = resolved.register ?? resolved.activate;return{ definition: resolved, register };}return{};}
6. 为什么强制要 configSchema? 🛡️
就写个简单插件,为什么还要搞 openclaw.plugin.json + schema?
因为 Agent 系统最常见的线上故障,不是模型答错了,而是配置错了——密钥写错、URL 多了个斜杠、回调路径改了没同步。强制 schema 的意义是把这类事故从”运行期发现”提前到”启动期就拒绝”。

插件根目录必须提供 openclaw.plugin.json,且两个字段不可缺:
// loader 里的检查逻辑const id =typeof raw.id ==="string"? raw.id.trim():"";if(!id)return{ ok:false, error:"plugin manifest requires id"};const configSchema =isRecord(raw.configSchema) ? raw.configSchema :null;if(!configSchema)return{ ok:false, error:"plugin manifest requires configSchema"};
configSchema 即使插件不需要任何配置,也要声明为空对象 {}。这不是形式主义,而是为了让 AJV 校验链路走完整——loader 会在调用 register() 之前,用 schema 验证 pluginConfig,校验失败直接报错,不进入注册阶段。
schema 编译结果会被缓存(schemaCache),缓存 key 由 manifestPath + mtime 组成,manifest 文件一旦变动,缓存自动失效。
manifest 里还可以声明 uiHints、channels、skills 目录等,这些信息供宿主做可视化 onboarding 和配置向导使用——从代码里把”配置”这件事变成可声明、可渲染的系统能力,而不是让用户去读 README。
7. 为什么 register 必须同步? ⏱️
我在 register() 里 await 一下初始化不行吗?
不行。loader 里写得很直接:
const result =register(api);if(result &&typeof result.then ==="function"){registry.diagnostics.push({level:"warn",pluginId: record.id,message:"plugin register returned a promise; async registration is ignored",});}
async register 返回 Promise,系统不会 await,只发一条 warn,然后继续。
为什么要这样设计?
register() 本质上是声明式注册——告诉系统”我有什么”,不应该在这里”做事”。同步遍历所有 candidate 的好处是:加载顺序确定、错误可精确定位到哪个插件、启动不被任何一个插件的 I/O 卡住。
真正需要 I/O 的初始化,有两个正确的归宿:
-
渠道类 📡:放到
gateway.startAccount()里——Gateway 启动后,createChannelManager()会遍历registry.channels,对每个已启用渠道调用startAccount({ accountId, cfg, runtime, abortSignal }),这才是渠道真正”活”起来的时刻(长连接建立、webhook 监听开始、入站消息开始进路由)。 -
后台服务类 ⚙️:放到
registerService()注册的 service 里——service 的启动逻辑在 Gateway 起来之后才会执行,并且受abortSignal管控。
这个分工让”启动期”和”运行期”边界清晰:register 是地图,startAccount/service 是真正出发。

8. 闭环示例:把”日程 + 重要邮件”每天推到你的聊天里 ✅
说了这么多架构,能不能给一个我明天就能用上的东西?
目标很具体:每天早上 9:00,往你最常用的聊天渠道推一条 8 行摘要:
📅 2026-02-20 今日摘要• 10:30 与 Alex 的设计评审(会议室 B)• 14:00 全组周会(Zoom)• 17:00 1:1 with PM(可线上)✉️ 需要回复: - Sarah:"Q2 预算表有问题,今天能确认吗?" - DevOps:"生产告警,需要你确认一下"▶️ 要我帮你:代你回第一封 / 把 17:00 改成线上 / 推迟 30 分钟?
8.1 为什么选 Service 而不是 Cron 脚本
定时推送是典型的”Push + 常驻”场景:不是模型决定”要不要推”,而是系统按时自动做。这对应的注册类型是 registerService。
8.2 最小文件结构
daily-digest-plugin/ package.json openclaw.plugin.json index.ts
package.json(必须声明 openclaw.extensions,否则安装时直接失败):
{"name":"@you/daily-digest","version":"0.1.0","openclaw":{"extensions":["index.ts"]},"dependencies":{}}
openclaw.plugin.json(id + configSchema 两者缺一不可):
{"id":"daily-digest","configSchema":{"type":"object","required":["channelId","accountId","target"],"properties":{"channelId":{"type":"string","description":"目标渠道 ID(如 slack / telegram)"},"accountId":{"type":"string","description":"用于发送的账号 ID"},"target":{"type":"string","description":"发送目标(频道/用户 ID)"},"cronHour":{"type":"number","default":9,"description":"触发小时(本地时区)"}},"additionalProperties":false}}
index.ts(register 必须同步,I/O 放到 service 里):
import type{ OpenClawPluginApi } from "openclaw/plugin-sdk";export default function register(api: OpenClawPluginApi){const cfg = api.pluginConfig as{channelId:string;accountId:string;target:string;cronHour?:number;};// 后台定时服务(Push 场景的正确归宿)api.registerService({id:"daily-digest.scheduler",async start ({ abortSignal }){while(!abortSignal.aborted){const now =newDate();const triggerHour = cfg.cronHour ??9;const msUntilTrigger =getMsUntilHour(now, triggerHour);await sleep(msUntilTrigger, abortSignal);if(abortSignal.aborted) break;// 拉取数据(可对接 MCP 工具 / 内部 API / Gmail API)const[agenda, emails]=awaitPromise.all([fetchTodayAgenda(),fetchImportantEmails(),]);const summary =formatBrief(agenda, emails);// 用 runtime 的渠道发送能力投递消息await api.runtime.channel.reply.dispatchReplyFromConfig({channelId: cfg.channelId,accountId: cfg.accountId,target: cfg.target,text: summary,});}},});// 可选:让 Agent 也能主动触发(Pull 场景)api.registerTool({name:"daily_digest.send_now",description:"立即生成并发送今日摘要",inputSchema:{ type:"object", properties:{},additionalProperties:false},handler:async()=>{const[agenda, emails]= await Promise.all([fetchTodayAgenda(),fetchImportantEmails()]);return{ summary:formatBrief(agenda, emails)};},});api.logger.info(`daily-digest registered (trigger: ${cfg.cronHour ??9}:00)`);}
8.3 这个例子在架构层面说明了什么
- Service 承接 Push
↗️:定时触发不依赖模型”想不想做”,由 service 直接驱动 - Tool 提供 Pull
↙️:用户说”现在给我推一次”,Agent 调用 daily_digest.send_now - register 纯声明
📋:没有任何网络请求, start()在 Gateway 启动后才运行 - configSchema 完整
🛡️: channelId / accountId / target都有类型约束,配错了启动期就报错
把邮件/日历的数据拉取做成 MCP tools,插件只做”触发 + 格式化 + 投递”——这就是前面说的分层:Plugin 管入口和生命周期,MCP 管可复用的业务工具。
Notes(进阶):
只推”重要邮件”的规则建议写进 configSchema(比如minImportanceScore),这样可以在配置层控制,而不是在代码里写死。如果要对接 Gmail OAuth,refresh token 的维护逻辑放到 service 或 channel onboarding里,不要放在register()里(会被忽略)。发给群组时注意信息边界——把包含隐私内容的邮件摘要发进多人频道,是很常见的踩坑点。
结尾:OpenClaw 爆火的奥秘其实很朴素 🎯
真正”得力”的私人助理,不在于它有多聪明,而在于:它稳定地出现在你已经待着的地方,把正确的信息和下一步动作送到你手上。
OpenClaw 的插件系统用一条可工程化的链路把这件事做实了:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
这不是”方便集成的脚手架”,而是一套把”私人助理”这个产品形态变成可扩展、可运维、可复制的系统能力的工程选择。
关注我,下一期继续整更硬核干货🔥🤖📌 敬请期待~✨
本文引用的所有源码均来自 OpenClaw 代码库,可按对应文件路径(src/plugins/discovery.ts、src/plugins/loader.ts、src/plugins/config-state.ts 等)直接核对。
夜雨聆风
