
1. 从一次“插件装了却没反应”说起如果你正在用 OpenClaw 搭自己的私人助理大概率踩过这个坑插件目录明明放好了openclaw.plugin.json也写了重启之后助理却像没看见一样消息照旧石沉大海。问题往往不在模型而在插件系统这条链路上——发现、启用、加载、注册、激活任何一环断了插件都不会真正跑起来。这篇是 OpenClaw 源码系列第 2 期聚焦插件系统里最核心的抽象ChannelPlugin以及它背后那套 TypeScript 类型定义、插件注册、消息路由和 Jiti 动态加载链路。目标很明确让你能自己写一个最小可用的插件骨架本地验证通过再通过统一 Key/API 通道接入 TaoToken 完成端到端联调。适合已经能跑起 OpenClaw、想深入插件机制、准备自建私人助理的开发者。我会按“问题场景 → 前置准备 → 可复制配置 → 验证请求 → 常见错排查 → 下一步”的顺序展开代码都可以直接对照仓库文件核对。2. ChannelPlugin 到底抽象了什么2.1 它不是 sendMessage 接口很多人第一次看ChannelPlugin会以为它就是个发消息的封装。实际上它是一套按需组合的渠道能力模块。核心类型大致长这样export type ChannelPluginResolvedAccount 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其余能力按平台特性按需加。这就是“私人助理能适配不同渠道、又保持核心逻辑一致”的关键。2.2 插件是能力工厂不是单个 Tool工具是“一个能力”插件是“一批能力的注册入口”。最直观的例子来自 Voice Call 插件同一个register(api)调用一次性往宿主注册了四种形态完全不同的东西register(api) { api.registerService(voiceCallService); // 后台常驻服务 api.registerTool(voiceCallTool); // Agent 可调用工具 api.registerGatewayMethod(voicecall.initiate, handler); // 外部 RPC 方法 api.registerCli(registerVoiceCallCli); // CLI 命令 }这些注册产物最终都落入PluginRegistry被系统统一管理export type PluginRegistry { tools: PluginToolRegistration[]; hooks: PluginHookRegistration[]; channels: PluginChannelRegistration[]; services: PluginServiceRegistration[]; httpRoutes: PluginHttpRouteRegistration[]; gatewayHandlers: GatewayRequestHandlers; cliRegistrars: PluginCliRegistration[]; commands: PluginCommandRegistration[]; diagnostics: PluginDiagnostic[]; };选哪种注册方式可以按这张决策表来你想做的事用什么接入聊天平台 / 电话 / 长连接registerChannel后台常驻定时任务 / 队列 / watcherregisterServiceAgent 主动调用的动作查询 / 发送 / 创建registerTool每条消息都必须过的固定逻辑审计 / 路由 / 脱敏registerHook / api.on接收外部系统的 webhook 或 RPC 调用registerHttpRoute / registerGatewayMethod2.3 有了 MCP为什么还需要 Plugin这是整篇最核心的结构性问题答案藏在两个词里触发方向。MCP 是 Pull 世界模型判断“我需要这个工具”主动发起调用拿到结果结束。它擅长把外部能力变成模型可用的接口。Plugin 是 Push 世界外部事件不断涌入新消息、账号 webhook、Relay 订阅系统必须持续在线、稳定承接。Teams 的 Bot Framework 回调、Matrix 的 room sync、电话的实时音频流这些都不是“模型决定要不要处理”而是“必须有人一直在那儿接着”。更关键的是Plugin 解决的不只是“能不能接住”还有入站之后的一系列入口工程问题账号生命周期startAccount → 连接 → 重连 → 优雅退出、线程上下文一致性、限流与合并、健康检查。这些是 MCP 工具协议天然覆盖不到的。一句话判断规则事件主动推进来必须稳定接住 → PluginChannel / Service模型自主决定要不要用 → MCP 工具或 registerTool告诉模型怎么用这些工具 → Skills成熟的 Agent 系统通常是Plugin 做入口MCP 做工具供应Skills 做流程固化。3. 前置准备TaoToken 统一 Key 与本地环境3.1 为什么用 TaoToken 做统一通道自建助理最烦的是模型接入散落各处这个插件用一套 Key那个服务用另一套联调时根本不知道请求打到哪。TaoToken 提供统一的 Key/API 通道把模型对话、编码、Agent 调用收敛到一个入口插件里只需要维护一份配置。官网入口https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 基址不加 UTMhttps://taotoken.net/api3.2 拿到 Key 并配置环境变量进入控制台创建 API Key控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content拿到 Key 后本地写入环境变量插件通过process.env读取避免硬编码export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api如果你要验证模型是否通可以直接用模型对话页做一次快速自测模型对话https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content3.3 插件开发目录约定OpenClaw 的插件发现是四层来源、固定优先级。开发期推荐放在工作区workspace/.openclaw/extensions/your-plugin/优先级从高到低config 指定路径最高适合临时覆盖/本地开发workspace.openclaw/extensionsglobal~/.openclaw/extensionsbundled 内置插件最低同一个插件 ID 在多处出现时先被发现的胜出后来的记录为overridden by ${existingOrigin}。这是可预测的也是可排查的。4. 可复制配置最小插件骨架4.1 文件结构daily-digest-plugin/ package.json openclaw.plugin.json index.ts4.2 package.json必须声明openclaw.extensions否则安装时直接失败{ name: you/daily-digest, version: 0.1.0, openclaw: { extensions: [index.ts] }, dependencies: {} }4.3 openclaw.plugin.jsonid和configSchema两者缺一不可。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 };即使插件不需要任何配置也要声明为空对象{}。这不是形式主义而是为了让 AJV 校验链路走完整——loader 会在调用register()之前用 schema 验证pluginConfig校验失败直接报错不进入注册阶段。{ 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 } }4.4 index.tsregister 必须同步register()本质上是声明式注册——告诉系统“我有什么”不应该在这里“做事”。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然后继续。真正需要 I/O 的初始化有两个正确归宿渠道类放到gateway.startAccount()后台服务类放到registerService()注册的 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 new Date(); const triggerHour cfg.cronHour ?? 9; const msUntilTrigger getMsUntilHour(now, triggerHour); await sleep(msUntilTrigger, abortSignal); if (abortSignal.aborted) break; const [agenda, emails] await Promise.all([ fetchTodayAgenda(), fetchImportantEmails(), ]); const summary formatBrief(agenda, emails); 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)); }4.5 Jiti 动态加载与 aliasNode 不跑.ts插件却是 TypeScript 写的。OpenClaw 用 Jiti 做即时转译const jiti createJiti(import.meta.url, { interopDefault: true, 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开发环境。loader 还兼容多种导出格式无论你怎么写都能被识别function resolvePluginModuleExport(moduleExport) { const resolved moduleExport?.default ?? moduleExport; if (typeof resolved function) return { register: resolved }; if (resolved typeof resolved object) { const register resolved.register ?? resolved.activate; return { definition: resolved, register }; } return {}; }5. 验证请求本地跑通端到端5.1 启用插件找到不等于会跑。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 };注意plugins.allow一旦设置就变成了白名单——没在里面的全部不加载包括你以为“默认可用”的插件。开发阶段如果不确定不设置 allow只用entries[id].enabled true更安全。5.2 用 curl 验证 TaoToken 通道在插件联调前先确认统一通道是通的curl -s https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-5, messages: [{role: user, content: 只回复两个字通了}] }返回里能看到choices[0].message.content为“通了”说明 Key 和基址都对。接入文档里有更完整的参数说明接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content5.3 触发插件并观察日志启动 OpenClaw 后观察插件加载日志。成功时你会看到类似[daily-digest] registered (trigger: 9:00)到设定时间后目标渠道会收到一条摘要消息。如果想让 Agent 主动触发直接在对话里说“现在给我推一次摘要”它会调用daily_digest.send_now。5.4 插件生命周期全景插件加载阶段 插件被发现 → Enable 判定通过 → Jiti 转译执行 声明阶段 register() 同步调用 → tool / channel / service 登记进 Registry 运行期 Gateway 启动 → startAccount() 建立连接 → 入站消息进入路由 → Agent 处理 → outbound 发回6. 本篇常见错排查6.1 插件装了但没被加载先看发现优先级。如果你把插件放在 global 目录但 workspace 里有个同 ID 的旧版本workspace 会胜出global 的被标记为 overridden。排查方法在日志里搜overridden by。6.2 报 “plugin manifest requires configSchema”openclaw.plugin.json里漏了configSchema字段。即使不需要配置也要写configSchema: {}。6.3 register 里的异步初始化没生效如果你在register()里写了await系统不会等它只发一条 warn。把 I/O 移到service.start()或gateway.startAccount()里。6.4 白名单把自己锁死了设置了plugins.allow之后所有不在名单里的插件都不加载。开发期建议先不设 allow用entries[id].enabled true单独开启。6.5 多个记忆插件冲突记忆类插件kind: memory只能同时激活一个。resolveMemorySlotDecision()会在结构层把冲突提前拦掉。如果你装了两个记忆后端先到先得后来的会被标记为memory slot already filled by xxx。6.6 模型请求 401 或超时先确认TAOTOKEN_API_KEY和TAOTOKEN_BASE_URL是否被插件进程读到。环境变量在 shell 里 export 了但如果你用 systemd 或 pm2 启动需要在对应的 service 配置里也声明。用 5.2 的 curl 命令单独验证一次能快速区分是通道问题还是插件问题。7. 下一步从插件骨架到长期编码到这里你已经有了一个能跑通的最小插件发现、启用、Jiti 加载、同步注册、service 激活、消息投递整条链路都验证过了。接下来可以做的方向把邮件/日历的数据拉取做成 MCP tools插件只做“触发 格式化 投递”这就是前面说的分层Plugin 管入口和生命周期MCP 管可复用的业务工具。如果你要长期跑编码类 Agent 任务建议用 Coding Plan 统一管理调用配额和模型路由https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content如果你在用 Claude Code 做插件开发Anthropic 兼容接入可以参考https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content一个实用技巧把“重要邮件”的判定规则写进configSchema比如minImportanceScore这样可以在配置层控制而不是在代码里写死。如果要对接 Gmail OAuthrefresh token 的维护逻辑放到 service 或 channel onboarding 里不要放在register()里——会被忽略。发给群组时注意信息边界把包含隐私内容的邮件摘要发进多人频道是很常见的踩坑点。