Claude Code Hooks 完整使用手册:机制、接口与示例
公众号名称:AI 方寸山
作者名称:九皋山人
发布时间:2026-05-26 08:38
Claude Code 的 hook 机制是确定性执行的,Claude.md 是建议性的。在一般情况下可能感觉不到差别,但是如果在上下文压缩较为频繁的使用场景中时,hook就成了压舱石。
本篇前半部分把机制讲透,hook 是什么、什么时候触发、收什么输入、能输出什么、怎么影响 Claude。后半部分是八个实战配置。
hook 到底是什么
直白说:hook 是 Claude Code 在它自己的运行时上预留的注入点。
注意 Claude Code 看自己的视角。它不把自己当一个聊天工具,它把自己当一个带工具调用、带子代理、带会话生命周期、带上下文压缩的运行时。所有这些环节都暴露出事件,让你在事件之前、之后、失败时,插自己的逻辑进去。
这跟 git hook 长得像,但内部完全是另一套东西。git hook 是单向通知:事件来了脚本跑一下,跑完结束,跟下一步无关。Claude Code 的 hook 多了一层接口:你的脚本能反过来影响 agent 接下来要做什么。拦掉这次工具调用、改掉用户输入、给 Claude 注入一段上下文、强迫它继续干活不准停,这些信号都是从 hook 里发回去的。
但这层「能影响 agent」的能力,并不是每个事件都有。有些事件给你反向接口,有些只让你旁观。这是后面要重点讲的能力分层。
一张图:hook 在运行时里的位置
把整个 Claude Code 的运行流程沿时间轴画出来,hook 就是挂在每个关键节点上的「插入位置」。

从会话开始到结束,每个关键节点上都有对应的 hook 事件。你的脚本接到事件后能做什么,下面会一层层讲。
Claude Code 一共有 28 个左右的 hook 事件。文档按字母表列了一长串,看着头大。按触发频率分组就清楚多了,触发频率本身就是设计意图的一部分。
一次 session 触发一次
SessionStart / SessionEnd / Setup
一次回合触发一次
UserPromptSubmit / Stop / StopFailure / PreCompact / PostCompact
每次工具调用触发
PreToolUse / PostToolUse / PostToolUseFailure / PostToolBatch
PermissionRequest / PermissionDenied
外部状态变化触发
FileChanged / CwdChanged / ConfigChange / InstructionsLoaded
WorktreeCreate / WorktreeRemove
子代理 / 任务触发
SubagentStart / SubagentStop / TeammateIdle
TaskCreated / TaskCompleted
交互 / 用户输入触发
Notification / Elicitation / ElicitationResult / UserPromptExpansion
触发频率不只是分类,它直接决定 hook 的成本预算。一个 session 级 hook 整次会话只跑一次,里面写复杂逻辑都行。一个工具调用级 hook 一秒钟可能跑十几次,里面跑个网络请求或者重 LLM 调用,整个 Claude Code 就拖死了。写 hook 之前先看清楚它在哪一档。
一次 hook 的完整生命周期:从触发到反馈
把一个 hook 从触发到生效拆成 7 步,看明白这套接口是怎么转的。
假设你写了一个 PreToolUse hook 拦危险命令,配置在 .claude/settings.json 里。Claude 现在想跑 rm -rf /tmp/build。
-
Claude 决定调工具:Claude 的内部循环走到「我要调 Bash 工具」,参数是
command: "rm -rf /tmp/build"。 -
PreToolUse 事件触发:Claude Code 在工具真正执行前先广播这个事件,把上下文打包成一段 JSON:
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"cwd": "/Users/you/project",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf /tmp/build" }
}
-
matcher 命中检查:Claude Code 看你 settings.json 里 PreToolUse 下配的 matcher。你写的是
"Bash",跟当前 tool_name 一致,命中。 -
handler 执行:Claude Code 把上面那段 JSON 通过 stdin 喂给你配置的命令(比如
/path/to/check.sh),启动子进程。 -
脚本判断 + 输出:你的脚本从 stdin 读 JSON,看到
rm -rf,决定拦下来。它做两件事:往 stderr 写一行原因(「BLOCKED: dangerous rm」),然后exit 2。 -
Claude Code 看反馈:Claude Code 拿到 exit 2,知道这是拦截信号,工具调用被取消。stderr 那一行被当作「反馈给 Claude 的话」,加进 Claude 接下来的上下文。
-
Claude 接到反馈,换做法:Claude 看到「BLOCKED: dangerous rm」,知道这条路不通,下一轮可能改成
rm -ri或者干脆换个方案。
整套接口的关键是第 5-6 步:脚本通过 exit code 和 stdout/stderr 反过来影响 Claude。这是 hook 跟 git hook 最核心的差别,git hook 没有第 6-7 步,事件通知完就结束了。
后面几节把这套流程里的每一步拆开讲。matcher 怎么写、stdin 收到的 JSON 长什么样、脚本能用什么手段输出。
settings.json 的三层结构、matcher 和五种 handler
hook 配置写在 .claude/settings.json 里,结构是三层嵌套:事件名 → matcher 组 → handler 列表。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/check.sh"
}
]
}
]
}
}
每当 PreToolUse 事件触发,先看 tool 是不是 Bash,是就跑 /path/to/check.sh。
matcher 的四种写法
- 空字符串
""匹配全部事件 - 只含字母数字下划线和竖线的当精确名处理。
Bash只匹配 Bash 工具,Edit|Write匹配 Edit 或 Write - 含其他字符的按 JavaScript 正则处理。
^Notebook匹配所有 Notebook 开头的工具 - MCP 工具特殊,要写
mcp__memory__.*这种带.*后缀的形式,不然不会命中
不是所有事件都支持 matcher。UserPromptSubmit、PostToolBatch、Stop、TeammateIdle、TaskCreated、TaskCompleted、WorktreeCreate、WorktreeRemove、CwdChanged 这些事件没有 matcher,加了也会被忽略,每次必然触发。
handler 的五种 type
command跑 shell 命令,用 stdin 收 JSON、stdout/stderr/exit code 出反馈。最常用、最灵活http把 JSON POST 到一个 URL。适合中心化处理,比如团队共享的审计后端prompt调一次 LLM 让它返回 yes/no 形式的 decisionagent起一个 Claude 子代理,可以用 Read/Grep/Glob 验证条件再返回 decisionmcp_tool调一个已配好的 MCP server 工具
后四种有一个关键限制:prompt 和 agent 只能在工具事件上用(PreToolUse / PostToolUse / PostToolUseFailure / PermissionRequest)。别的事件想拿 LLM 判断只能走 command 自己调 API。
command handler 的两种主流写法
command 最常用,社区里有两种主流范式。一种是 shell + jq 极简风:
{ "type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
一行 jq 解析 stdin,xargs 接出去跑工具。适合简单转发型的 hook。
另一种是独立可执行脚本 + uv 单文件依赖(disler/claude-code-hooks-mastery 仓库用的就是这种):
{ "type": "command",
"command": "uv run $CLAUDE_PROJECT_DIR/.claude/hooks/check.py" }
$CLAUDE_PROJECT_DIR 是 Claude Code 预留的项目根目录环境变量。hook 写成独立 Python 文件,依赖声明跟着脚本走,方便单独测、单独跑。适合判断逻辑复杂、要调外部库的 hook。
简单的用 jq,复杂的写 Python。
hook 的输入:stdin 上的 JSON payload
所有 command 类型的 hook 通过 stdin 收一段 JSON,结构由 Claude Code 决定,事件不同字段不同。
通用字段(所有事件都有)
session_id:当前会话 IDtranscript_path:对话完整记录的 jsonl 文件路径cwd:触发时的工作目录hook_event_name:事件名(比如"PreToolUse")
事件特有字段(举几个常用的)
PreToolUse / PostToolUse 类带 tool_name 和 tool_input。PostToolUse 还多一个 tool_response(工具执行的结果):
{
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": { "file_path": "src/api.py", "content": "..." },
"tool_response": { "success": true }
}
SessionStart 多一个 source 字段(值是 "startup" / "resume" / "clear")和 model 字段。读 source 能区分这是新开会话还是恢复历史会话,不同情况下你可能想注入不同的上下文。
Stop / SubagentStop 多一个 stop_hook_active 布尔字段。这字段是个防死循环阀门:如果 Stop hook 已经拦过一次让 Claude 继续,下次再触发时这字段会是 true,提醒你别再拦了不然就死循环。
UserPromptSubmit 多一个 prompt 字段,就是用户刚提交的原文。
shell 和 Python 端的读法
shell 端用 jq 解析最方便:
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
Python 端直接 json.load(sys.stdin):
input_data = json.load(sys.stdin)
tool_name = input_data.get('tool_name', '')
command = input_data.get('tool_input', {}).get('command', '')
每个事件的完整字段列表在 reference 文档对应事件那一节,写复杂 hook 之前去查一下。
hook 的输出:脚本能影响 Claude 的几种手段
脚本跑完以后,反馈给 Claude Code 的手段一共四种,按优先级从高到低:

-
continue: false(终极停止)。在 stdout 打印{"continue": false, "stopReason": "..."}。这是最高优先级的输出,意思是「请 Claude 完全停下来」。Claude Code 看到这个就不再继续这一轮的处理。 -
decision: "block"+ reason。在 stdout 打印{"decision": "block", "reason": "..."}。这是事件特定的「拦截 / 反馈」信号,含义因事件而异:- PreToolUse 上:拦掉这次工具调用,reason 显示给 Claude
- PostToolUse 上:工具已经执行了拦不住,但 reason 会让 Claude 自己想办法修复
- Stop 上:拦住 Claude 停止,reason 告诉它接下来该做什么
- UserPromptSubmit 上:拦掉用户的 prompt,reason 显示给用户
-
exit code 2 + stderr。最简单的反馈手段。
exit 2意思是「拦截」,stderr 里的内容会被当作「给 Claude 的话」喂进上下文。能 block 的事件(PreToolUse、UserPromptSubmit、Stop、PermissionRequest)才会真的拦下来。在其他事件上,exit 2 也只是把 stderr 打给用户看,Claude 收不到。 -
exit code 0 + stdout(上下文注入)。在 UserPromptSubmit、SessionStart、Setup 这三个特殊事件上,脚本 stdout 打印的内容会自动加进 Claude 的上下文。这是「注入」类 hook 的工作原理,你不用做任何特殊配置,只要 print 出去就行。其他事件上 stdout 默认不进 Claude 上下文。
如果四种手段同时出现,按上面的顺序生效:continue: false 总是赢、然后是 decision: "block"、然后是 exit code 2、最后是其他 exit code(被当作「非阻塞错误」,stderr 给用户看,执行继续)。
实战里大多数 hook 只用第三种(exit 2)或者第四种(stdout 注入)就够了。复杂 hook 才需要打 JSON。
能力分层:能 block / 能给 context / 只能旁观
讲完输出机制再回来看一个关键事实:不是每个事件都接受所有四种输出。事件的「能力档」是分层的。

第一档:能 block 行为。PreToolUse、UserPromptSubmit、Stop、PermissionRequest。这几个事件上你 exit 2 或者打印 {"decision": "block"},Claude 真的会被拦下来。
第二档:能给 Claude 注入上下文。SessionStart、Setup、UserPromptSubmit。这些事件上你 stdout 打印的内容会被自动加进 Claude 的上下文。UserPromptSubmit 同时具备两种能力。
第三档:只能旁观。SessionEnd、PostCompact、InstructionsLoaded、StopFailure、CwdChanged、FileChanged。你的脚本 exit 2 也只是把 stderr 打给用户看,Claude 既不会被拦也不会收到通知。
写 hook 之前最好养成反查这条线的习惯:「我想做拦的事」要落在能 block 的事件上,「我想给 Claude 额外信息」要落在能给 context 的事件上,「我想观察 / 记日志 / 跑外部副作用」其他事件随便选。错位最常见的就是把「想拦」的逻辑写到只能旁观的事件上,脚本写完很努力,但 Claude 该干嘛干嘛。
异步、超时、去重的几条小坑
讲完接口主干,还有几条容易踩的小坑:
async: true真正的用途是兜底,别把它当性能开关。hook 默认同步阻塞下一步。PreToolUse 上同步是好事,你得让脚本同步跑完才能决定拦不拦。但同步阻塞放到会话级事件上就咬人。SessionEnd 写完跑 git commit + LLM 总结要十几二十秒,没加async: true的话 Ctrl+C 退出会一直卡到脚本跑完。asyncRewake: true是 async 的变种,后台跑、但允许 hook 通过 exit code 2 反向唤醒 Claude。适合「我后台跑个测试,跑完结果再告诉 Claude」的场景。- timeout 默认值不是一刀切。command / http / mcp_tool 类型默认 600 秒,但 UserPromptSubmit 上被压到 30 秒。别假设 60 秒一刀切,长任务记得显式设 timeout。
once: true让 hook 跑一次后自动从配置里移除。适合「初始化用的一次性 hook」。- 自动去重:Claude Code 会按 command + args 字符串给 command hooks 去重,按 URL 给 http hooks 去重。重复配置只会触发一次。
- Resume 的坑:UserPromptSubmit 在
--continue或--resume时不会重跑,会用上次保存的输出。如果你的 hook 注入了当前时间戳或 git commit SHA,恢复会话时这些值会变旧。这种动态信息建议放 SessionStart,SessionStart 在 resume 时会重跑,source字段会变成"resume"。
SessionEnd 不能做你想做的「会话总结」
接前一节的 async 话题,假设你想用 SessionEnd 做对话归档 + 自动总结,加上 async 解决了阻塞问题,按理可以了。但还有两道墙堵在前面,得绕过去。
第一道墙:SessionEnd 这个事件没有 decision control。所有结构化输出字段(decision、reason、additionalContext、continue 这些)在 SessionEnd 上都不被处理。你的脚本无论 print 什么 JSON,Claude 都看不到。你只能让脚本自己干完所有事,没有跟 Claude 来回交流的口。
第二道墙:prompt 和 agent 这两种 hook type 只支持工具事件。SessionEnd 上你写不了「起一个子代理让它生成总结再写回来」的 hook,能用的就 command / http / mcp_tool 三种。
两道墙合起来意味着:「SessionEnd 触发一个 Claude subagent 生成总结写回当前会话」这条路从两端都堵死了。想做对话归档 + 总结实际有两条可走的路:
- 路线 A:用
command类型 hook,脚本里自己调 Claude API 写总结,写完落盘归档。脚本跑多久跟 Claude Code 主进程无关(记得加async: true防止卡退出)。 - 路线 B:用
Stop事件而不是 SessionEnd。Stop 在 Claude 每次停止响应时都会触发,你在脚本里检查stop_hook_active字段或者根据会话状态判断是不是「最终退出」那次再写总结。Stop 事件支持 decision control,能反向告诉 Claude「先别停,再做点事」。
八个常用的实战场景
讲完原理给八段最小可用配置,按从拦截到注入到自动化的顺序排。代码混合改编自 disler/claude-code-hooks-mastery 仓库和 Anthropic 官方 hooks-guide 文档。
1. PreToolUse 拦 rm -rf 和 .env 访问
最经典的 hook 用途。settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "",
"hooks": [
{ "type": "command",
"command": "uv run $CLAUDE_PROJECT_DIR/.claude/hooks/pre_tool_use.py" }
]
}
]
}
}
脚本核心:
import json, re, sys
input_data = json.load(sys.stdin)
tool_name = input_data.get('tool_name', '')
tool_input = input_data.get('tool_input', {})
if tool_name == 'Bash':
cmd = tool_input.get('command', '')
if re.search(r'\brm\s+.*-[a-z]*r[a-z]*f', cmd.lower()):
print("BLOCKED: dangerous rm detected", file=sys.stderr)
sys.exit(2)
if tool_name in ['Read', 'Edit', 'Write']:
fp = tool_input.get('file_path', '')
if '.env' in fp and not fp.endswith('.env.sample'):
print("BLOCKED: .env access prohibited", file=sys.stderr)
sys.exit(2)
拦截类 hook 的标准模板:从 stdin 收 JSON、判断、不通过就 exit 2 + stderr 给原因。Claude 收到 stderr 后会知道为什么被拦,下一步换做法。
2. PostToolUse 自动跑 Prettier 格式化
hooks-guide 给的一行流写法,jq 解析路径直接喂给 Prettier:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}
matcher: "Write|Edit" 让 hook 只在写文件时触发,不会每次 Read 或 Bash 时白跑。这个配置写完,Claude 每次写文件后都会自动 format。
3. PostToolUse 跑 ruff 检查 Python 文件
比 Prettier 更严的版本。lint 报错时让 Claude 自己改:
import json, subprocess, sys
hook_input = json.loads(sys.stdin.read() or "{}")
file_path = hook_input.get("tool_input", {}).get("file_path", "")
if not file_path.endswith(".py"):
print(json.dumps({}))
sys.exit(0)
result = subprocess.run(
["uvx", "ruff", "check", file_path],
capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(json.dumps({
"decision": "block",
"reason": f"Lint check failed:\n{result.stdout[:500]}"
}))
else:
print(json.dumps({}))
{"decision": "block", "reason": ...} 在 PostToolUse 上的效果:工具已经执行了拦不住,但 Claude 会看到这个反馈自动尝试修复。把 lint / typecheck / 单测接进 hook,是让 Claude 自动收敛代码质量的最实在做法。
4. SessionStart 注入 git 状态作为上下文
import subprocess
print("## 当前仓库状态")
print()
print("最近 5 个 commit:")
print(subprocess.run(
["git", "log", "--oneline", "-5"],
capture_output=True, text=True
).stdout)
print()
print("未提交改动:")
print(subprocess.run(
["git", "status", "--short"],
capture_output=True, text=True
).stdout or "(无)")
SessionStart 事件上,hook 脚本 stdout 打印的内容会被 Claude Code 自动塞进新会话的上下文。Claude 一打开就知道你这仓库现在长什么样。
5. PostCompact 重新注入项目约定
这是 hooks-guide 强调但很多人没意识到的用法。SessionStart 上的注入 CLAUDE.md 自带能干,但 compact 之后 Claude 会丢掉大段对话历史,这时候才真正需要重新提醒它项目约定:
{
"hooks": {
"PostCompact": [
{
"matcher": "",
"hooks": [
{ "type": "command",
"command": "cat $CLAUDE_PROJECT_DIR/.claude/conventions.md" }
]
}
]
}
}
cat 输出的内容会被加进 compact 之后的 Claude 上下文。把项目里常用的命令、最近的 issue、最近 commit 写进 conventions.md,compact 完 Claude 不会突然变蠢。
6. CwdChanged + direnv 同步环境变量
也是 guide 提到的聪明用法。Claude Code 的 Bash 工具不会自动 pick up shell 里的 direnv,但你可以挂个 CwdChanged hook 让它每次工作目录变化时同步:
{
"hooks": {
"CwdChanged": [
{
"hooks": [
{ "type": "command",
"command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }
]
}
]
}
}
$CLAUDE_ENV_FILE 是 Claude Code 预留的环境变量持久化文件路径,写进去的内容会在后续 Bash 工具里生效。配合 direnv,Claude 进到任何项目目录都能自动拿到 .envrc 里的环境变量。
7. Notification 桌面提醒
guide 给的最小通知配置。macOS 用 osascript,Linux 用 notify-send:
{
"hooks": {
"Notification": [
{
"hooks": [
{ "type": "command",
"command": "osascript -e 'display notification \"Claude 需要你的输入\" with title \"Claude Code\"'" }
]
}
]
}
}
适合长任务跑后台时让 Claude 喊你回来。disler 仓库的扩展版还接了 TTS,让 Claude 用语音喊你。
8. UserPromptSubmit 加 context 或拦关键词
最后一个,UserPromptSubmit 是 power user 才会用的事件。你可以在 Claude 看到 prompt 之前修改它或者拦掉它:
import json, sys, datetime
input_data = json.load(sys.stdin)
prompt = input_data.get('prompt', '')
# 拦危险关键词
if 'curl' in prompt and '| sh' in prompt:
print("BLOCKED: dangerous curl-pipe-sh pattern", file=sys.stderr)
sys.exit(2)
# 给 Claude 加上下文
print(f"Note: current time is {datetime.datetime.now().isoformat()}")
print(f"Project root: /Users/you/project")
stdout 打印的内容会作为额外上下文加在 prompt 前面给 Claude。exit 2 直接拦掉 prompt。这是「prompt validation + 增强」的一站式入口。
写到这里覆盖了 Claude Code hook 八成的常用场景。disler 原仓库里还有十多个事件的完整实现可以直接抄,hooks-guide 文档里 cookbook 风格的例子也值得翻一遍。
一份能直接用的 settings.json 模板
把上面四个最常用的(rm -rf 拦截、自动 format、SessionStart 注入 git、PostCompact 重注入约定)合成一份完整配置,你 clone 下来改路径就能跑:
{
"hooks": {
"PreToolUse": [
{
"matcher": "",
"hooks": [
{ "type": "command",
"command": "uv run $CLAUDE_PROJECT_DIR/.claude/hooks/pre_tool_use.py" }
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -r npx prettier --write 2>/dev/null || true" }
]
}
],
"SessionStart": [
{
"matcher": "",
"hooks": [
{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session_context.sh" }
]
}
],
"PostCompact": [
{
"matcher": "",
"hooks": [
{ "type": "command",
"command": "cat $CLAUDE_PROJECT_DIR/.claude/conventions.md 2>/dev/null || true" }
]
}
]
}
}
把 .claude/hooks/ 下的脚本写好(参考上一节代码),改成 chmod +x,重启 Claude Code 就生效了。
最后
hook 的本质是把 Claude Code 从一个 chat box ,变成一个你能往里面塞 callback 的运行时。想清楚事件、matcher、handler、反馈这套接口,slash command、MCP server、hook 的边界就清楚了,它们对应的场景从来都不一样。
看了这么多,怎么用?直接告诉CC“请创建一个hook:能够…”就行。那还看这教程干啥?知道有啥、是啥、能干啥,做评估用。

原创 九皋山人 AI 方寸山
内容效果不满意?点此反馈