claude.dev 工程博客《Getting started with Claude Code mods》精读。mod 即 plugin 内的 hooks:中间件链 · $ 为唯一出口 · 状态归宿主;观察、改写、直接作答三种动作;Token Weather 实战,Blast Radius 与 Replay Theater 两例,附速查。
来源:Getting started with Claude Code mods,Addy Osmani,claude.dev Blog(Tutorials),2026-10-01 原文 Markdown 版:https://claude.dev/blog/getting-started-with-claude-code-mods.md 适用版本:Claude Code 2.1.287 及以上(mods 默认开启)。API 可能随版本变化,以本机生成的类型声明为准。
说明:本笔记是对原文的整理和精读。代码片段做过精简,只用来说明结构,完整代码请看原文。标有「类比」「引申」的部分是我的理解,不是原文内容。
配图:原文的 Fig A 到 Fig G 和一张未编号的示意图已嵌入对应小节。四段演示视频以封面图出现,点图打开视频(原文每段视频有 Terminal / Desktop 两个版本,这里取 Terminal 版)。图文从原文页抓取,托管在自有 CDN(2026-10-26)。
TL;DR:三个心智模型
- 中间件链:每个 hook 的签名都是
($, e, next),对事件只能做三件事:观察、改写、直接作答。UI 绘制也是链上的一个事件,所以改行为和改界面是同一套机制。 $是唯一出口:mod 跑在沙箱里,没有 DOM 也没有 Node,读文件、跑进程、开面板都要通过$。因此claude plugin validate能静态分析出 mod 做了什么。- 状态放在宿主那边:热重载会把模块扔掉重新执行,模块变量会被清空。数据要放进
$.state,它由 Claude Code 保管,而且是响应式的。
1. Mod 是什么
- 一个运行在 Claude Code 会话里的 JS/TS 文件,能观察发生了什么、改变 Claude Code 的行为、绘制自定义 UI(终端和桌面 App 都可以)。
- Claude Code 原有的定制手段有 settings、权限规则、slash 命令、skills、status line。mods 比这些更深一层:可以改写甚至替换原生行为,也可以自己画界面。
- 本质上,mods 就是装在 plugin 里的 hooks,每个 mod 都能实时看到会话中的每一个事件。
- 不懂 API 也能上手:在
claude里描述你想要的 mod,同意热重载,这一轮结束时 mod 就会出现。
原文开头先放了一段三个示例 mod 的连播,后面第 3、5、6 节逐个拆解:
视频里三段之间用标题卡隔开:Token Weather 的天气栏从 18% 的 Clear 一路爬到 81% 的 Storm;Blast Radius 扣住 rm -rf build,列出要删的 9 个文件;Replay Theater 一步步回放把 greet 改名为 welcome 的几处编辑。
2. 工作原理(How a mod works)
2.1 目录形态:就是一个普通 plugin
my-mod/
├── .claude-plugin/
│ ├── plugin.json # 标准 plugin 清单
│ └── types/ # Claude Code 加载时自动写入的类型声明
├── hooks/
│ ├── hooks.json # { "modules": ["./my-mod.mjs"] },一个 mod 只有一个模块
│ └── my-mod.mjs # export function register(on, options) { ... }
├── types/index.d.ts # 类型契约(声明 state)
└── tests/*.test.ts
2.2 Hook 签名
on("tool.call", { tool: "Bash" }, async ($, e, next) => {
return next(e);
});
| 部分 | 含义 |
|---|---|
"tool.call" |
事件名:关心哪一类事(这里是 Claude 要调用工具时) |
{ tool: "Bash" } |
matcher(可选):在这类事件里再筛一道,只有工具是 Bash 时才触发 |
$ |
mods API:ui、session、state、store、fs、process、clock、http、tool、command、model…… |
e |
本次事件的输入,纯数据 |
next |
把事件交给链上的下一个处理者,链的最底部是 Claude Code 的原生行为 |
关于 matcher(讨论中补充):
- 不写 matcher 就会收到这类事件的全部,可以在函数里自己判断:
if (e.tool !== "Bash") return next(e); - 能用 matcher 就用:代码更干净,
validate的报告里也能直接看到,比如ui.render{component=AbovePrompt}。 - 不同事件按各自最自然的维度筛选:
tool.call→{ tool: "Bash" }ui.render→{ component: "AbovePrompt" }command.run→{ command: "replay" }
- 需要匹配多个工具时(比如 Edit 和 Write),自己在函数里判断更方便。
类比:像 Express 的
app.get("/users", handler),get对应事件类型,"/users"对应 matcher。整条链则像 Koa 的洋葱模型。
2.3 三种动作(核心)

读图:左边是一次 tool.call 事件,e = { command }。它先到你的 hook(签名 ($, e, next)),调用 next(e) 交给其他 plugin(形状相同),再 next(e) 交给 Claude Code,由它真正执行工具。结果(result)沿下方虚线原路返回。图里同时标出了三种动作:
- observe:
await next(e),拿到结果后看一眼; - rewrite:
next({ ...e, command }),换掉往下传的内容; - answer:橙色弧线,不调
next,直接return { deny }返回给事件源,后面的环节根本不会被调用。
| 动作 | 写法 | 例子 |
|---|---|---|
| Observe 观察 | const r = await next(e); /* 看一眼 */ return r |
记录每次文件编辑;每轮结束读一次数据 |
| Rewrite 改写 | return next({ ...e, command: safer }) |
修改链上后续环节看到的内容 |
| Answer 直接作答 | 不调 next,直接 return { deny: "…" } |
拒绝一次工具调用;自己实现一个命令或工具 |
判断方法:有没有调 next、什么时候调、传的是什么。
2.4 事件覆盖面
工具调用、提交的 prompt、turn 开始/结束、会话开始/结束、slash 命令,以及 ui.render(界面每一块被绘制的时刻)。
→ UI 也是链上的事件:想在某个位置画东西,就拦截那个组件的 ui.render 并返回元素树;不想画就 next(e) 还回去。
2.5 沙箱
模块在独立沙箱中运行,没有 DOM、没有 Node,所有外部交互都通过 $。
2.6 和 settings hooks 的区别
| settings hook | mod | |
|---|---|---|
| 运行方式 | 每个事件跑一次 shell 命令 | 加载一次,常驻在会话里 |
| 通信 | stdin/stdout 传 JSON | 直接调用 $ |
| 状态 | 无 | 有 |
| UI | 无 | 可以画随事件实时更新的 UI |
| 回调 Claude Code | 无 | 开面板、跑进程、注册 slash 命令、注册模型可调用的工具 |
类比:settings hook 像 CGI(每个请求起一个进程),mod 像常驻的服务端中间件。
2.7 官方自己也在用
Claude Code 的一部分功能本身就是 mod,例如 AGENTS.md 支持和 /diff 面板。源码和测试在 anthropics/claude-code 仓库的 mods/ 目录下,是最接近生产标准的参考。
3. 实战:Token Weather
在输入框上方画一行 context window 的"天气预报",约 80 行代码。
| 已用 | 预报 |
|---|---|
| < 25% | ☀ Clear |
| 25–49% | ☁ Cloudy |
| 50–74% | ☂ Showers |
| 75–89% | ☇ Storm |
| ↯ Compact soon |

这一行依次显示:图标和天气、百分比、已用 / 窗口、最近 12 轮的迷你柱状图(▁▂▃▄▅▆▇█)、上一轮增量。对照动图里的三帧:
| 天气 | 占比 | 已用 / 窗口 | 上一轮增量 |
|---|---|---|---|
| ☀ Clear(黄) | 18% | 36.1k / 200k | ▲ +36.1k |
| ☂ Showers(蓝) | 67% | 134.4k / 200k | ▲ +98.3k |
| ☇ Storm(粉) | 81% | 161.1k / 200k | ▲ +26.7k |
注意第三帧:上一轮只涨了 26.7k,但累计已经 81%,所以天气由累计占比决定,增量只作参考。
3.0 捷径:让 Claude 帮你写
- 在
claude里描述想要的效果,只写"想看到什么",不需要提 API。Claude Code 内置了 mod 编写指南,负责处理 state 怎么存、怎么 validate、挂哪些事件。 - 第一次会询问是否开启本会话的热重载,同意后可以边提修改边看效果。
- ⚠️ 这样生成的 mod 只在当前会话加载,文件夹之后会被清理。想保留,要把文件夹复制出来,按普通 plugin 安装。
3.1 Step 1:建目录
先 claude --version 确认版本 2.1.287。目录结构见 2.1,hooks.json 只指向一个模块。
3.2 Step 2:先画点东西
- 目标组件是
AbovePrompt(输入框正上方那一条)。Claude Code 自己在这里什么都不画,所以适合练手。 - 元素构造函数不是全局变量,要通过
const { Box, Text } = $.ui.resolve(e)获取。原因是每个绘制面(终端、桌面)支持的元素集合略有不同,同一个 mod 也因此能在两边都显示。也支持 JSX(factory 设为h)。 - 启动:
claude --plugin-dir ./token-weather。文件夹会被监听,保存即热重载,不用重启。
3.3 Step 3:读数据,存到 $.state(重点,附通俗版)
一句话概括:每轮结束查一下 context 用了多少,记到"小本子"上;画天气栏时翻本子画出来。
① 数从哪来:$.session.usage(),和 status line 显示的是同一组数。
context.tokens:上一次回复时的输入 token 数context.window:模型窗口大小context.percent:两者之比- 免费调用,只有请求
breakdown时才会真的发一次 token 计数请求。
② 什么时候读:session.start 读一次,之后每次 turn.complete 读一次。两个 hook 都是 Observe 模式:
const result = await next(e); // 先让事情照常发生
if (!e.agentId) await takeReading($); // 只记主循环的轮次,不记 subagent
return result; // 原样返回,什么都没改
③ 存在哪:最关键的坑
- 热重载 = 把文件扔掉,从头重新执行。
register重跑,session.start重新触发。 - 如果写
let readings = [],每次保存都会被重置成空数组,历史全部丢失。 - 正确做法是存进
$.state:由宿主(Claude Code)在整个会话期间保管,重载不会丢。
类比:寄存柜。mod 代码随时可能"重新投胎",但寄存柜属于 Claude Code。
const readings = { plugin: "token-weather", key: "readings" }就是柜号$.state.get(readings):开柜取出本子$.state.set(readings, newValue):把更新后的本子放回去
④ 类型契约:寄存前先登记
在 types/index.d.ts 里扩展 PluginState 接口:
declare module "claude-code" {
interface PluginState {
"token-weather": { readings: TokenWeatherReading[] };
}
}
然后在 plugin.json 里加上 "types": "./types/index.d.ts"。不声明的话 claude plugin validate 会报错,错误信息里会说明怎么修。(类比:建表前先定义表结构。)
⑤ 白送的好处:自动重绘
在 render hook 里调用 $.state.get,就等于让这块界面订阅了这个值,之后任何地方 $.state.set,界面都会自动重画,不需要 $.ui.invalidate。
- 记账的(
turn.complete)只管写 - 画图的(
ui.render)只管读 - 两边互不调用
类比:像跨热重载存活的 React
useState/ signals,或者"关注了一个公众号"。
时间线:
- 会话开始 → 记第 1 条 → 自动画出 ☀ Clear
- 读了几个大文件,这轮结束 → 记第 2 条 → 自动变成 ☂ Showers
- 改了一个颜色并保存 → 模块重跑,柜子还在 → 历史完整,只有颜色变了(如果用的是模块变量,这时只剩一条记录)
真实会话里的样子:
每一轮都让 Claude 多读几个各有 600 个函数的 Python 文件,输入框上方那一行从黄色 Clear 变成蓝色 Showers,再变成粉色 Storm。这正是上面时间线的第 1、2 步:每轮结束记一条读数,界面自动重绘。
3.4 Step 4:画出预报(三个值得照搬的细节)
- props 在
e.props上。e顶层只有component、surface、requestId、viewport。e.props.hasSurvey:有问卷要占用这一条时,用next(e)让出位置e.props.bodyColumns:这一条的实际宽度(打开侧边面板时会比终端窄)。原文在宽度 时才画柱状图和趋势
- 没东西可画就
return next(e),把位置还给 Claude Code 和其他 mod。 - 用单宽字符,不要用 emoji。☀ ☁ ☂ ☇ ↯ 在任何终端字体里都能对齐。
3.5 Step 5:校验与测试
claude plugin validate ./token-weather:静态报告 hooks、$调用、state 读写。(得益于"$是唯一出口"。)claude plugin test ./token-weather:在真实运行时里跑*.test.ts(从claude-code/testing导入describe/test/expect)。- 巧妙之处:mock 用的就是中间件链本身。测试里用
on注册的 hook 排在 mod 之后,正好占住原本 Claude Code 原生行为的位置。所以只要on("session.usage", () => 假数据),就能完全控制$.session.usage()的返回值。 - 测试流程:
$.session.start→$.ui.mount挂载AbovePrompt→ui.find断言出现 "Clear" → 改 tokens,触发$.turn.complete→ 断言 "Showers"、"67% of context"、"▲ +98.3k last turn"。这同时验证了自动重绘。
3.6 Step 6:分享
本地 marketplace = 一个带 .claude-plugin/marketplace.json 的文件夹:
claude plugin marketplace add ./my-mods
claude plugin install token-weather@my-mods --scope user
4. 分享与安全(Sharing your mod)
- 一个带 marketplace 文件的 GitHub 仓库就是一个 marketplace,更新时正常 push 即可。
- 别人在 Claude Code 里安装:
没有出现的话重启 Claude Code。/plugin marketplace add your-org/my-mods /plugin install token-weather@my-mods /reload-plugins - ⚠️ 安全:mod 在本机运行,权限和 Claude Code 相同,作者是发布者而不是 Anthropic。要像装 npm 包一样,先读仓库,只装信得过的人写的。
- 可以提交到 Claude 目录:claude.ai/directory/manage。
5. Blast Radius:危险命令先拦,看清楚再决定
功能:Bash 要执行 rm -rf、git reset --hard、git clean、强制推送、数据库迁移时,先扣住调用,算出影响范围,打开带 Proceed(1) / Cancel(2) 的面板。取消时 Claude 收到附带原因的拒绝,继续则按原样执行。
rm -rf build,列出将删除的 9 个文件(1.1 MB)。第一次选 Cancel 被拒,第二次选 Proceed 才执行(视频 24 秒)面板标题是黄色的"⚠ Blast Radius · rm -rf"。Command 一行是原命令,Would 一行用红字写出后果"delete 9 files (1.1 MB)",下面逐个列出 build/ 里的文件,最底部是 1: Proceed、2: Cancel 和"Claude is waiting on your answer"。视频里先按 2,Claude 收到拒绝;再让它删一次,按 1,命令照原样执行。
类比:小区保安。普通车直接放行,危险品车先拦下来,查清楚装了什么,给业主看清单,业主同意才放。
hooks:tool.call(Bash)+ ui.render(Pane)+ ui.render(AbovePrompt,作为降级位置)
流程(用的是 Answer 动作):
classify(command)
├─ 不危险 → next(e)
└─ 危险 → measure(演习)→ $.ui.open 打开面板 → 等待按键
├─ Proceed → next(e)
└─ Cancel → return { deny: "用户取消了;这条命令本来会……" }
学到的技巧:
$.process.run做演习:调用工具自带的 dry run,比如git status --porcelain、git clean -n、git log HEAD..origin/main、showmigrations。参数用 argv 数组传入,路径里的字符不会被当作 shell 代码执行,避免命令注入。- 扣住调用:每个 hook 每次调用只有 10 秒自己的运行时间,但在
$调用里等待的时间不计入。所以用循环await $.process.run(["sleep", "0.25"])轮询,直到按钮的onPress写入决定。next.signal.aborted(用户按 Esc)时退出循环。 - 带快捷键的按钮:
Button({ label: "Proceed", hotkey: "1", onPress }),支持点击、Tab+Enter、数字键三种操作。 - 降级:
$.ui.open返回isPlaced: false(终端太窄)时,把同一份报告画到AbovePrompt。

git reset --hard 的报告画在输入框上方这就是技巧 4 的降级效果:终端只有 120 列,放不下侧边面板,同一份报告改画在输入框上方的黄框里。Would 一行是"discard uncommitted changes in 2 files",下面列出新增的 NOTES.md(A)和修改过的 src/math.js(M),并提示未提交的改动无法恢复。
⚠️ 局限:它只是安全网,不是权限系统。它读的是命令文本,所以 $(…)、别名、脚本内部调用的 rm 都会漏过去。需要硬性禁止时请用权限规则。
6. Replay Theater:回放上一轮的所有改动
功能:记录一轮中所有 Edit/Write 调用(文件、改前、改后)。这一轮结束后输入框上方出现提示,按 r 或输入 /replay,在面板里逐步查看 diff,有步骤条和 Prev / Next / Close 按钮。
视频前半段是正常的对话记录:Claude 把 greet 改名为 welcome,红绿 diff 依次刷过 greet.js、main.js、README.md。这一轮结束后,输入框上方出现洋红色的 Replay 提示。打开后,面板顶部是 1 到 5 的步骤条,每一步显示一个文件的一处 diff。
纯观察,从不拦截或修改编辑:
| hook | 作用 |
|---|---|
tool.call(不带 matcher) |
如果是 Edit/Write,生成步骤放进 pending,然后 next(e) 原样放行 |
turn.start |
清空 pending(只针对主循环) |
turn.complete |
把 pending 转成 replay(只针对主循环) |
session.start |
$.command.register({ name: "replay", ... }) 注册 slash 命令 |
command.run({ command: "replay" }) |
打开面板,返回 "Replaying" 或 "No edits" |
学到的技巧:
- 事件配对:
turn.start和turn.complete像一对括号,保证每轮对应一份回放。用e.agentId排除 subagent,否则分组会乱。 - 注册 slash 命令分两步:先在
session.start里 register,再在command.run里作答。 - 读取 Write 的旧内容:Write 调用里只有新内容。但
tool.callhook 在工具执行前运行,这时用$.fs.read读到的就是覆盖前的旧文件,所以 diff 是真实的。→ 理解 hook 在链上的位置,就知道每个时刻能拿到什么数据。 - 位置交给界面决定:全屏时停靠在右侧,80 列时在输入框上方展开,mod 画的是同一棵树。(Blast Radius 是自己判断
isPlaced来降级,两种做法都可以。)

和 Fig F 比,内容完全一样,只是位置变了:Fig F 里面板停靠在右侧,这里终端只有 80 列,界面把同一棵元素树放到了输入框上方。mod 自己不做判断,这就是技巧 4 说的"位置交给界面决定"。
7. 三个 mod 对比
| 动作 | 画在哪 | 新能力 | 出发点 | |
|---|---|---|---|---|
| Token Weather | 观察 | 输入框上方 | $.state、自动重绘 |
context 还剩多少? |
| Blast Radius | 直接作答(拒绝) | 面板 / 降级到上方 | 跑进程、扣住调用、按钮 | 这条命令要删什么? |
| Replay Theater | 观察 | 面板 / 上方 | 事件配对、注册命令、读文件 | Claude 刚才改了什么? |
8. 四个好习惯
- 以自动生成的类型为准:
.claude-plugin/types/是当前版本的权威参考,编辑器和tsc -p直接可用。API 会随版本变化,教程可能过时,这份声明不会。 - props 从
e.props读:写成e.bodyColumns不会报错,只会得到undefined。 - 按"随时会热重载"来设计:数据放
$.state,不放模块变量。- 引申(非原文):
session.start会被反复触发,里面的初始化最好能安全地重复执行。
- 引申(非原文):
- 画面不出来就看日志:
claude --debug,找"hook 返回的元素树不合法"那一行。非法的元素树会被悄悄丢弃,表面上没有任何报错。
9. 可以做什么 mod(原文点子 + 拆解)
| 点子 | 事件 | 动作 | 关键 API |
|---|---|---|---|
| 费用 / 限额表 | turn.complete |
观察 | $.session.usage()、$.ui.status |
| 每条 prompt 自动附上团队规范 | prompt.submit |
改写 | next({ ...e, ... }) |
| 列出本会话 Claude 读过的所有文件 | tool.call(Read) |
观察 | $.state + 面板 |
| 长任务完成后弹提醒 | turn.start / turn.complete |
观察 | $.ui.toast |
针对自己技术栈的守卫(生产 kubectl、terraform apply) |
tool.call(Bash) |
直接作答 | 参照 Blast Radius |
三种动作里,原文唯一没有完整示例的是"改写",
prompt.submit这个点子正好可以用来练手。
10. 速查
命令
claude --version # ≥ 2.1.287
claude --plugin-dir ./my-mod # 加载本地 mod,保存即热重载
claude --debug # 排查画面不显示
claude plugin validate ./my-mod # 静态校验
claude plugin test ./my-mod # 运行 *.test.ts
claude plugin marketplace add <path|org/repo>
claude plugin install <name>@<marketplace> --scope user
常用 $ API(原文出现过的)
$.ui.resolve(e)/$.ui.open/$.ui.mount(测试用)/$.ui.status/$.ui.toast$.session.usage()/$.session.cwd()$.state.get/$.state.set$.process.run([...argv])$.fs.read$.command.register
常用事件:session.start、turn.start、turn.complete、tool.call、prompt.submit、command.run、ui.render
组件:AbovePrompt(输入框上方)、Pane(侧边面板)
相关链接
- 原文:https://claude.dev/blog/getting-started-with-claude-code-mods/
- 官方 mods 源码:https://github.com/anthropics/claude-code(
mods/目录) - Claude Code 文档:https://code.claude.com/docs
- Mods 展示页:https://claude.dev/mods/
- Claude 目录提交:https://claude.ai/directory/manage