Claude Code hooks 完全指南:事件时机、退出码语义、JSON 输出协议
CLAUDE.md 是建议层,hooks 是强制层。这句话是理解 hooks 存在意义的全部。
写在 CLAUDE.md 里的「提交前必须跑 lint」是一条上下文里的指令,模型读到了,通常会听,但不保证。写成 hook 的同一条规则是一个在固定时机被 shell 执行的命令,模型的意图不参与其中。要「一定发生」的事情,写 hook;要「希望模型知道」的事情,写 CLAUDE.md。
hooks 配置在 settings.json 里,三层嵌套:事件名 → matcher 组 → hook 数组。
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-bash.sh", "timeout": 10 } ] } ] }}字段含义:
| 字段 | 说明 |
|---|---|
matcher | 匹配工具名。对不涉及工具的事件(如 SessionStart)匹配的是该事件自己的取值 |
type | command 执行 shell 命令。也支持 HTTP 类型的 hook,见下文 |
command | 要执行的命令。用 $CLAUDE_PROJECT_DIR 引用项目根,避免依赖当前工作目录 |
timeout | 超时秒数。超时后该 hook 被中断,不影响同组其他 hook |
同一个 matcher 组里的多个 hook 并行执行。matcher 支持正则,Edit|Write 这样写可以一次匹配两个工具。
$CLAUDE_PROJECT_DIR 这个变量值得单独说:hook 命令的当前工作目录不保证是项目根,用相对路径写脚本位置在子目录里启动会话时会失效。始终用这个变量拼绝对路径。
事件与触发时机
Section titled “事件与触发时机”事件的价值全在时机上。同一个脚本挂在 PreToolUse 和 PostToolUse 上,能做的事情完全不同——前者能拦住操作,后者只能在操作已经发生后反应。
| 事件 | 时机 | 典型用途 |
|---|---|---|
PreToolUse | 工具调用参数已定、尚未执行 | 拦截危险命令、校验参数 |
PostToolUse | 工具执行完成 | 格式化刚写入的文件、跑增量测试 |
UserPromptSubmit | 你的输入提交后、模型看到之前 | 注入当前分支、issue 编号等动态上下文 |
Stop | 模型认为该轮结束、即将交还控制权 | 检查任务是否真的完成,不完成就打回去继续 |
SubagentStop | subagent 结束 | 同上,作用在子任务粒度 |
SessionStart | 会话开始 | 注入启动上下文 |
SessionEnd | 会话结束 | 清理临时资源 |
PreCompact | 压缩发生前 | 抢救即将被压掉的关键信息 |
Notification | Claude Code 发通知时 | 转发到自己的通知渠道 |
PermissionRequest | 权限提示即将弹出 | 按自己的规则自动决策 |
ConfigChange | 检测到设置文件改动 | 配置变更审计 |
InstructionsLoaded | 指令文件加载完成 | 调试 CLAUDE.md / rules 到底加载了哪些 |
SessionStart 的 matcher 有四个取值,对应四种不同的「开始」:
startup:全新启动resume:恢复已有会话clear:执行/clear之后compact:自动压缩之后
compact 这个取值有一个额外用途:它是判断压缩是否真的发生过的可观测信号。挂一个只写时间戳到日志的 hook 在 SessionStart:compact 上,就有了压缩事件的时间线。
InstructionsLoaded 是配置调试专用的。它能记录哪些指令文件被加载、什么时候加载、为什么加载——排查 path-scoped rules 和子目录里延迟加载的 CLAUDE.md 时,这比猜要快得多。
hook 的退出码是最简单的控制方式,三档:
| 退出码 | 行为 |
|---|---|
| 0 | 成功。stdout 在多数事件里不进入模型上下文 |
| 2 | 阻塞。stderr 的内容回灌给模型 |
| 其他非零 | 非阻塞错误。stderr 展示给你,会话继续 |
退出码 2 是整套 hooks 机制里最需要理解的一个数字。它不只是「失败」,而是「失败了,并且把失败原因告诉模型,让模型据此调整」。
举例:PreToolUse 上的 hook 以 2 退出并向 stderr 写 禁止直接 push 到 main,请先建分支,模型收到的不是一个笼统的「操作被拒绝」,而是这句具体的话,因此有机会自己改成建分支再提交。而如果用退出码 1,模型只知道 hook 出错了,不知道该怎么改。
所以写 hook 时 stderr 的措辞值得认真对待——那是给模型看的指令,不是给人看的日志。
JSON 输出协议
Section titled “JSON 输出协议”退出码只能表达「过 / 不过」。要做更细的控制,让 hook 向 stdout 输出 JSON。
通用字段:
| 字段 | 作用 |
|---|---|
continue | false 时停止后续处理 |
stopReason | 配合 continue: false,说明停止原因 |
suppressOutput | 不在 transcript 里显示这个 hook 的输出 |
systemMessage | 向你(不是模型)展示一条消息 |
事件专属字段里,最有用的三个:
PreToolUse 的 permissionDecision,取值 allow / deny / ask。这让 hook 变成一个可编程的权限决策器:把团队的规则写成脚本,比在 permissions.allow 里堆字符串模式灵活得多,因为它能读取当前分支、时间、文件内容等任何运行时状态。
{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "main 分支上禁止直接改 migration 文件" }}UserPromptSubmit 和 SessionStart 的 additionalContext,把字符串注入模型上下文。这是给会话喂动态信息的正规通道——当前 sprint 的目标、正在修的 issue 描述、构建状态,都可以在每次提交输入时自动带上,不必手写进 prompt。
Stop 的 decision: "block",配合 reason 阻止模型结束这一轮。这是让 agent 自己判断是否收工那类玩法的底层机制:Stop Hook 检查验收条件,不满足就带着具体原因把控制权推回给模型。
不想手写 JSON 的话:
/hooks这个命令打开 hooks 的交互配置界面。它适合快速看当前有哪些 hook 在生效,复杂的条件逻辑还是直接写文件更清楚。
hooks 执行任意 shell 命令,权限等同于你自己。所以有几层限制机制。
个人层面,一键全关:
{ "disableAllHooks": true }这个开关同时关掉自定义 status line。
组织层面,managed 设置里有 allowManagedHooksOnly。开启后只加载三类 hook:managed 设置里的、SDK 传入的、以及在 managed 设置 enabledPlugins 里被强制启用的插件带的。用户 hook、项目 hook、其他插件的 hook 全部被拦。
这个设计的用意是让管理员能通过组织内部的插件市场分发经过审核的 hook,同时封掉其他来源。信任是按 plugin@marketplace 完整 ID 授予的——同名插件来自不同市场仍然被拦,避免了名字冒用。
HTTP 类型的 hook 有两个独立的允许列表:
allowedHttpHookUrls:限制 HTTP hook 能请求哪些 URL,支持*通配。未定义时不限制,空数组表示全部禁止。主机名匹配大小写不敏感,并忽略末尾的 FQDN 点,与 DNS 语义一致。httpHookAllowedEnvVars:限制 HTTP hook 能把哪些环境变量插值进请求头。每个 hook 的有效列表是它自己声明的列表与这个设置的交集。
两个列表都跨设置层合并。
- 在
PostToolUse上挂一个匹配Edit|Write的 hook,让它对刚改动的文件跑格式化工具。这是投入产出最高的一个 hook,因为它把「记得跑 formatter」这件事从你和模型双方的记忆里彻底移除了。 - 在
SessionStart上挂一个输出additionalContext的 hook,把git log --oneline -5的结果注进去。观察模型在会话开头对「最近做了什么」的理解有没有变化。 - 故意写一个
PreToolUsehook 以退出码 2 退出,stderr 写一句具体的修正建议,看模型的下一步动作是不是照着建议改了。再换成退出码 1 对比一次。
还没确认的点
Section titled “还没确认的点”- HTTP 类型 hook 的完整配置字段。官方文档主要在讲它的限制机制(
allowedHttpHookUrls、httpHookAllowedEnvVars),配置项本身的完整字段列表没有逐条核实。 - 各事件 stdin 输入 JSON 的完整字段。公共字段(
session_id、transcript_path、cwd、hook_event_name)确认了,各事件特有的字段没有逐个核实。 - 同一 matcher 组内多个 hook 并行执行时,如果多个 hook 同时返回冲突的
permissionDecision,最终采用哪个。文档未见明确说明。 ConfigChange事件的输入字段,以及它在一次批量配置改动中触发几次。