Claude Code Hooks:自定义自动化工作流
这篇文章原本只讲原理。2026-09-19 我补了文末的「本机实测」一节:在 Claude Code 2.1.270 上真搭了一个隔离的 hook 实验室,8 类事件全挂同一个脚本,把 stdin JSON 原样落盘,再用「hook 标记文件 + 工具标记文件」双向确认工具到底跑没跑。先说结论:上面这篇正文里有三处写错了——只有 exit 2 会拦住工具(exit 1 和 exit 3 一律放行)、单数 "hook": {…} 这个配置键已经被静默忽略、$CLAUDE_FILE_PATH / $CLAUDE_TOOL_INPUT 两个环境变量在 2.1.270 上根本不存在。 原文保留在下面不动,实测一节逐条给出真实读数与修正写法。
什么是 Hooks
Hooks 是 Claude Code 提供的自动化扩展机制,允许你在特定事件发生时自动执行自定义脚本。通过 Hooks,你可以在 Claude 调用工具之前或之后插入自己的逻辑,比如自动格式化代码、阻止危险命令或发送通知。
Hooks 在你的本地机器上以确定性方式执行,不消耗 LLM 的 token,是构建可靠自动化工作流的理想方式。
Hook 事件类型
| 事件 | 触发时机 |
|---|---|
PreToolUse |
工具调用执行之前 |
PostToolUse |
工具调用执行之后 |
Notification |
Claude 发送通知时 |
Stop |
Claude 完成响应时 |
配置方法
Hooks 在 .claude/settings.json 中配置:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hook": {
"type": "command",
"command": "echo 'File is about to be modified'"
}
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hook": {
"type": "command",
"command": "npx prettier --write $CLAUDE_FILE_PATH"
}
}
]
}
}
matcher 字段支持正则表达式,用于匹配工具名称(如 Edit、Write、Bash 等)。
实战示例
文件保存后自动 Lint
{
"PostToolUse": [
{
"matcher": "Edit|Write",
"hook": {
"type": "command",
"command": "npx eslint --fix $CLAUDE_FILE_PATH 2>/dev/null || true"
}
}
]
}
阻止危险命令
{
"PreToolUse": [
{
"matcher": "Bash",
"hook": {
"type": "command",
"command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'rm\\s+-rf\\s+/|DROP\\s+DATABASE'; then echo 'BLOCKED: Dangerous command detected' >&2; exit 1; fi"
}
}
]
}
当 hook 脚本以非零状态退出时,对应的工具调用将被阻止。
完成时发送通知
{
"Stop": [
{
"matcher": "",
"hook": {
"type": "command",
"command": "osascript -e 'display notification \"Claude 完成了任务\" with title \"Claude Code\"'"
}
}
]
}
最佳实践
- 保持脚本快速:Hook 脚本会阻塞 Claude 的执行流程,避免长时间运行的任务
- 妥善处理错误:使用
|| true防止非关键脚本失败阻塞工作流 - 善用环境变量:Claude 会注入
$CLAUDE_FILE_PATH、$CLAUDE_TOOL_INPUT等上下文变量 - 团队共享:将 hooks 配置放在项目级
.claude/settings.json中,提交到仓库供团队共用
常见问题
Hooks 和 MCP 有什么区别?
Hooks 是确定性的本地脚本,在特定事件时自动触发,适合自动化检查和格式化。MCP 则是让 Claude 访问外部工具和数据源的协议,Claude 可以主动调用 MCP 提供的工具。两者互补,不冲突。
Hook 脚本执行失败会怎样?
如果 PreToolUse 的 hook 脚本以非零状态退出,对应的工具调用将被阻止。如果 PostToolUse 的脚本失败,Claude 会收到错误信息但会继续执行后续操作。
可以在 Hook 中使用哪些环境变量?
Claude Code 会自动注入上下文相关的环境变量,包括 CLAUDE_FILE_PATH(当前操作的文件路径)、CLAUDE_TOOL_INPUT(工具的输入参数)等。具体可用变量取决于触发的事件和工具类型。
本机实测:8 类事件、exit code、matcher 和延迟,在 2.1.270 上真跑一遍
问题背景
上面这篇原文是按官方文档写的,能跑通的读者不少,跑不通的也不少——而「跑不通」在 hooks 这件事上特别难查:配置写错了不报错,hook 没触发不报错,脚本退出码不对也不报错,你只会看到「好像没生效」。
我自己在这个坑里栽过一次,结论写在另一篇里:Claude Code Hooks 的 stdin 陷阱(那篇讲的是 hook 脚本内部读 stdin 的方式会静默失效,和本篇不重合)。这次我想把外部契约整个量一遍:到底哪些事件会触发、hook 收到的 JSON 长什么样、退出码各自意味着什么、matcher 怎么写才命中、挂 hook 要付多少延迟。
问题分析
要把这些问题变成可验证的读数,有三个障碍:
- 「hook 没触发」和「hook 触发了但没生效」在日志上长得一样。 只看 hook 自己写的日志,无法区分
PreToolUse是「没跑」还是「跑了但没拦住」。 - 本机已经有两个生产用的 hook(全局的
~/.claude/hooks/log_tool_io.py、本仓库的.claude/hooks/lessons-inbox-capture.sh),实验绝对不能污染它们。 - 每次
claude -p冷启动都要烧掉五万个 cache_creation token,在 opus 上一次约 $0.32,几十次实验会很贵。
技术方案与选型
最终方案:独立 CLAUDE_CONFIG_DIR + --settings <绝对路径> + 每次实验独立工作目录 + Haiku 驱动 + 双向标记文件。
- 隔离方式选
--settings而不是写.claude/settings.json:--settings接一个任意路径的 JSON,不落在任何被扫描的目录里,实验配置和生产配置物理隔离;同时CLAUDE_CONFIG_DIR指到tmp/下的一个空目录,用户级 hook 也进不来。(排除项:直接改仓库的.claude/settings.json——会污染生产 hook,且每次改完要记得还原,不可接受。) - 双向标记文件是关键设计:hook 脚本每次触发写一行到
hook-ran.txt,同时让模型执行的 Bash 命令写一个tool-ran.txt。两个文件一交叉,「hook 触发了几次」和「工具到底执行没执行」就是两个独立的事实,不用猜。(排除项:只看模型的自然语言回复——模型会脑补「我已经执行了」,不可信。) - 驱动模型选 Haiku 4.5:38 次会话总共 $1.5248,平均 $0.04/次。(排除项:直接打
/v1/messagesAPI 自己造 hook 场景——本机的 Anthropic 代理只放行 Claude Code 客户端,脚本直连一律 503,这条路根本走不通。) --output-format json取时间与成本:duration_ms/num_turns/total_cost_usd都是 CLI 自己吐的,不用我掐表。端到端wall_s另外用 shell 记录,两个数一起看。
每次实验的形状固定成这样:
claude -p "<最小任务>" \
--settings /abs/path/to/lab/xxx.settings.json \
--output-format json \
< /dev/null # 不加这行会等 3 秒 stdin,把延迟实验全毁了
实测一:8 类事件里,哪几类真的会触发
一个最小任务(Read 一个文件 → Write 一个文件 → echo lab-probe),8 类事件全部挂同一个 capture.sh,把 stdin JSON 原样追加进 events.jsonl:

主会话一共触发 9 次,顺序和时间戳(相对第一次触发):
| 时刻 | 事件 | tool_name |
|---|---|---|
| 0.000s | SessionStart |
— |
| 0.024s | UserPromptSubmit |
— |
| 6.095s | PreToolUse |
Read |
| 6.122s | PostToolUse |
Read |
| 9.374s | PreToolUse |
Write |
| 9.400s | PostToolUse |
Write |
| 9.450s | PreToolUse |
Bash |
| 9.573s | PostToolUse |
Bash |
| 11.933s | Stop |
— |
注意 PreToolUse → PostToolUse 的间隔只有 26~123ms,中间夹的是工具真实执行时间;而两次工具调用之间隔了 3.2 秒,那是模型在思考。hook 的开销要和「模型思考」这个量级比,不是和「工具执行」比——这条后面延迟实验会再印证。
剩下三类事件要另外的会话才能逼出来。让模型起一个后台子代理:
| 时刻 | 事件 | 备注 |
|---|---|---|
| 8.992s | PreToolUse |
tool_name 是 Agent,不是文档里常见的 Task |
| 11.164s | Stop |
主会话先结束了 |
| 13.234s | SubagentStop |
agent_type=general-purpose |
| 13.344s | UserPromptSubmit |
prompt 内容是 <task-notification>…,机器生成的 prompt 也走这个事件 |
| 16.663s | Stop |
第二次 Stop |
这两条是文档里没有的:想用 matcher 匹配子代理调用,要写 Agent;想在 UserPromptSubmit 里做「用户输入审查」,必须准备好收到不是人打的 prompt(否则后台任务一回来就被你自己的规则拦下)。
再 --resume 一个会话手动 /compact:
| 时刻 | 事件 | 备注 |
|---|---|---|
| 0.000s | SessionStart |
source=resume |
| 0.028s | PreCompact |
trigger=manual |
| 14.928s | SubagentStop |
agent_type=""(压缩是一个匿名子代理干的) |
| 14.954s | SessionStart |
source=compact |
压缩会额外放出一次 SubagentStop 和一次 SessionStart。 如果你的 SessionStart hook 会做「注入项目上下文」之类的重活,它在一次长会话里会被跑很多遍,不是一遍。
Notification 本机未触发。 两次尝试都没拿到:一次让模型执行需要授权的命令(5 个事件,无 Notification),一次让模型空转等待(3 个事件,无 Notification)。原因很明确——claude -p 是 headless 的,没有人可以被通知,CLAUDE_CODE_SESSION_ATTENDED 这个环境变量本身就在说明这件事。以下是推测,未验证:Notification 大概率只在交互式 TTY 会话里、等待用户授权或长时间空闲时触发;想测它可能需要真开一个终端会话手动操作。我没有在 headless 下复现出来,就不编数字。
实测二:hook 真正收到的 stdin 字段
下面这张表是从落盘的 events.jsonl 里逐条读出来的,不是抄文档。所有事件共有 session_id / transcript_path / cwd / hook_event_name 四个字段,差异部分:
| 事件 | 独有字段(2.1.270 实测) |
|---|---|
SessionStart |
source(startup / resume / compact) |
UserPromptSubmit |
prompt_id、permission_mode、prompt |
PreToolUse |
prompt_id、permission_mode、tool_name、tool_input、tool_use_id |
PostToolUse |
同上再加 tool_response(Read 工具是 {type, file:{filePath, content, numLines, totalLines}}) |
Stop |
stop_hook_active、last_assistant_message、background_tasks、session_crons |
SubagentStop |
agent_id、agent_type、agent_transcript_path、stop_hook_active、last_assistant_message |
PreCompact |
trigger(manual / auto)、custom_instructions |
Notification |
本机未触发,无实测字段 |
环境变量那一栏,原文写错了。hook 进程里以 CLAUDE 开头的变量实测是这些:
CLAUDECODE, CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR, CLAUDE_CODE_ATTRIBUTION_HEADER,
CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, CLAUDE_CODE_ENTRYPOINT,
CLAUDE_CODE_EXECPATH, CLAUDE_CODE_MAX_OUTPUT_TOKENS, CLAUDE_CODE_MESSAGING_SOCKET,
CLAUDE_CODE_MESSAGING_TOKEN, CLAUDE_CODE_SESSION_ATTENDED, CLAUDE_CODE_SESSION_ID,
CLAUDE_CODE_USE_VERTEX, CLAUDE_CONFIG_DIR, CLAUDE_EFFORT, CLAUDE_PID, CLAUDE_PROJECT_DIR
CLAUDE_FILE_PATH 和 CLAUDE_TOOL_INPUT 都是 UNSET。正确的写法是从 stdin 的 JSON 里取:
# ❌ 原文写法:2.1.270 上这两个变量是空的,prettier 会去格式化一个空路径
npx prettier --write $CLAUDE_FILE_PATH
# ✅ 实测可用:从 stdin JSON 取 tool_input.file_path
payload=$(cat)
f=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty')
[ -n "$f" ] && npx prettier --write "$f"
另外 CLAUDE_PROJECT_DIR 实测等于启动 claude 的那个 cwd,不是 git 仓库根目录。如果你在子目录里起会话,用它拼脚本路径会拼歪。
实测三:exit code 到底怎么解释
这是原文错得最要命的一处。原文说「非零状态退出,对应的工具调用将被阻止」——实测不是。

每个退出码一次独立会话,hook 写 hook-ran.txt、Bash 工具写 tool-ran.txt:
| 配置 | tool-ran.txt |
hook 触发次数 | turns | 花费 | 模型看到什么 |
|---|---|---|---|---|---|
PreToolUse exit 0 |
YES | 1 | 2 | $0.0695 | 正常结果 |
PreToolUse exit 1 |
YES | 1 | 2 | $0.0405 | 正常结果,stderr 没进上下文 |
PreToolUse exit 2 |
NO | 2 | 3 | $0.0496 | tool_result 带 is_error=true |
PreToolUse exit 3 |
YES | 1 | 2 | $0.0409 | 正常结果 |
UserPromptSubmit exit 1 |
YES | 1 | 2 | $0.0409 | 正常结果 |
UserPromptSubmit exit 2 |
NO | 1 | 0 | $0 | 整轮中止,一次模型调用都没发生 |
Stop exit 2 |
— | 3 | 3 | $0.0797 | 被迫继续,多跑了 2 轮 |
只有 exit 2 有拦截语义。 exit 1 和 exit 3 在 PreToolUse 上完全等价于放行,hook 的 stderr 连模型的上下文都进不去——你以为自己拦住了危险命令,实际上它照跑。
exit 2 的证据不止标记文件。翻会话 transcript,能看到两条真实的 tool_result:
is_error=True | PreToolUse:Bash hook error: [.../exitcode.sh PreToolUse-exit2 2 0]:
HOOKSTDERR-PreToolUse-exit2-fire1
is_error=True | PreToolUse:Bash hook error: [.../exitcode.sh PreToolUse-exit2 2 0]:
HOOKSTDERR-PreToolUse-exit2-fire2 ← 模型重试了一次,又被挡回
两个细节:hook 的 stderr 原样进了模型上下文(所以拦截理由要写给模型看,写人话);模型被拦一次会重试,所以 hook 触发了 2 次而不是 1 次——写幂等,别在拦截分支里记账或发通知。
UserPromptSubmit exit 2 那一行的数字最好看:{"wall_s": 0.195, "duration_ms": 31, "num_turns": 0, "total_cost_usd": 0}。在模型被调用之前就中止了,31 毫秒,不花钱。 做输入侧合规检查(禁止提交含密钥的 prompt 之类)应该挂这里,而不是挂 PreToolUse。
Stop exit 2 会把模型拽回来继续干活。我的脚本里带了 max-fires=2 的保险,第 3 次触发改成 exit 0 才停下——不带保险就是死循环。stdin JSON 里的 stop_hook_active 就是给你判断这个用的,第二次进来时它会变成 true,别像我一样用计数文件硬扛。
实测四:matcher 十种写法,谁命中
同一个 PreToolUse 下面并排挂 10 条 matcher,一次会话里 Read / Write / Bash 各调一次,看谁被触发:

| matcher 写法 | 实际命中 |
|---|---|
"*" |
Read, Write, Bash |
省略 matcher 字段 |
Read, Write, Bash |
""(空串) |
Read, Write, Bash |
"Bash" |
Bash |
"Edit|Write" |
Write |
"^Read$" |
Read |
"Read,Write" |
Read, Write |
"Rea"(前缀) |
未触发 |
"bash"(小写) |
未触发 |
"Notebook.*" |
未触发(本次会话没调 Notebook 工具) |
三条结论:
"*"/""/ 省略,三者实测等价,都是全匹配。- 匹配是「整名」的,不是「包含」——
"Rea"匹配不到Read,所以"^Read$"的锚点其实可以省。正则能力是真的(|生效),但别指望子串。 - 大小写敏感,
"bash"匹配不到Bash。
另外在 Stop 事件上写 "matcher": "Bash"——照样触发。非工具类事件的 matcher 被直接忽略,写了等于没写,不会报错也不会过滤。
最后是配置格式。原文(和网上很多教程)用的单数 "hook": {…}:
hook 触发次数: 0 工具照常执行: YES stderr: (空,没有任何报错)
2.1.270 只认复数数组 "hooks": [{…}]。 写成单数既不报错也不生效,这就是文章开头说的「最难查的那种失败」。正确写法:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/abs/path/check.sh", "timeout": 5 }
]
}
]
}
}
实测五:挂 hook 要付多少延迟
同一个任务(Read + Write + Bash,一次会话共 9 次 hook 触发)× 3 种配置 × 3 轮,取中位数:

| 配置 | 3 轮 wall_s |
中位数 | API duration_ms 中位数 |
|---|---|---|---|
| 无 hook | 9.577 / 10.267 / 11.553 | 10.267s | 10.093s |
空 hook(exit 0) |
9.014 / 9.540 / 10.590 | 9.540s | 9.355s |
sleep 0.5 hook |
13.831 / 13.871 / 16.742 | 13.871s | 13.007s |
空 hook 的中位数比无 hook 还低 0.7 秒——这不是说挂 hook 更快,是说 9 次空 hook 的开销完全落在模型响应时间的噪声带里,量不出来。3 轮的极差本身就有 2 秒。真正能量出来的只有 sleep 0.5 那组:9 次 × 0.5s = 4.5s 的理论开销,实际把中位数推高了 3.6s。
结论是别怕挂 hook,要怕 hook 里干慢活。 一个 jq 判断、一句 grep,在 3 秒一次的模型思考面前不值一提;一个 npx eslint 冷启动(实测 1~2 秒)乘以每次 Edit,就很可观了。
同一个事件挂两条 hook 时,它们是并行的:
A start 1789784312.065773000
B start 1789784312.065783000
A end 1789784313.229337000
B end 1789784313.229344000
A、B 启动相差 0.010 毫秒,各自跑了 1.164 秒——两条各 sleep 1 的 hook 总共只花 1.164 秒,不是 2 秒。
超时行为是 fail-open。配置 "timeout": 2、hook 里 sleep 10:
SLOW start 1789784541.… ← 只有 start,没有 end 行,进程被杀了
POST start 1789784543.… ← 2.143s 后 PostToolUse 就触发了
tool-ran.txt 存在 —— 超时的 PreToolUse 不拦工具,直接放行。 如果你把安全检查写成一个可能变慢的脚本(比如要联网查策略),它慢的那天就是它失效的那天。
实践效果
一句话版的实测结论,可以直接拿去改配置:
- 配置键是
"hooks": [...](复数数组),单数"hook": {...}静默失效。 - 想拦截,退出码只能是 2;
1和3都等于放行,stderr 也不会进模型上下文。 - stderr 是写给模型看的,要写成可执行的指令(「用 X 替代 Y」),而不是
BLOCKED。 - 被
exit 2拦住后模型会重试,hook 要幂等。 - 取文件路径用
jq -r '.tool_input.file_path',别用$CLAUDE_FILE_PATH(不存在)。 - 输入侧检查挂
UserPromptSubmit:exit 2 时 31ms 中止、0 turns、$0,比在PreToolUse上拦便宜一个量级。 Stophook 用 exit 2 强制继续时,必须读stop_hook_active兜底,否则死循环。- matcher 大小写敏感、整名匹配、
"*"/""/省略三者等价;非工具事件的 matcher 会被忽略。 - 子代理工具名是
Agent;UserPromptSubmit会收到机器生成的<task-notification>prompt。 - 别怕挂 hook(空 hook 的开销量不出来),要怕 hook 里跑慢活;超时是 fail-open,安全检查别写成可能变慢的脚本。
全部读数来自 38 次 claude -p 会话,总花费 $1.5248,原始 JSONL 与 settings 都留在本地实验目录。
踩坑
坑一:"hook" 与 "hooks" 一字之差,静默失效。
现象:按老教程写的配置一点反应都没有,hook 触发 0 次,工具照常执行,stderr 全空。根因:2.1.270 的 schema 是 {"matcher": ..., "hooks": [ ... ]},单数 "hook" 是未知键,被忽略且不校验。解法:改成复数数组;调试任何「hook 没生效」的问题,第一步先挂一个只写日志的 exit 0 hook 确认触发链路通不通,再调逻辑。
坑二:--settings 只认绝对路径。
现象:--settings lab/matrix.settings.json 报 Error: Settings file not found,但文件明明在。根因:相对路径是按会话的运行 cwd 解析的,我每次实验都切到独立工作目录,路径自然对不上。解法:--settings 和 hook 的 command 一律写绝对路径。
坑三:headless 下不喂 stdin,每次会话白等 3 秒。
现象:claude -p 会打印 Warning: no stdin data received in 3s,然后才开始干活。做延迟实验时这 3 秒直接毁掉全部读数。解法:claude -p "..." < /dev/null。这条对所有脚本化调用都适用,不只是做实验。
坑四:PostToolUse hook 改了文件,模型会收到一条系统提醒。
这是意外发现。让 PostToolUse hook 在模型 Write 完之后往文件里追加内容(模拟 formatter),transcript 里会多出一条 hook_additional_context 附件:
PostToolUse hook modified …/note.txt after your edit (likely a formatter). Your next Edit will not fail with a stale-file error, but if its
old_stringtargets a region the hook reformatted,Readthe file first.
也就是说 Claude Code 知道 formatter 类 hook 的存在并主动给模型打了招呼。好消息是自动格式化不会把模型的后续 Edit 搞崩;代价是每次这样的 hook 都会往上下文里塞一段话。