Clipping 微信公众号

Claude Code Hooks 完整使用手册:机制、接口与示例

by 九皋山人 原文 ↗
Created: 2026-05-26

公众号名称: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

  1. Claude 决定调工具:Claude 的内部循环走到「我要调 Bash 工具」,参数是 command: "rm -rf /tmp/build"

  2. 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" }
}
  1. matcher 命中检查:Claude Code 看你 settings.json 里 PreToolUse 下配的 matcher。你写的是 "Bash",跟当前 tool_name 一致,命中。

  2. handler 执行:Claude Code 把上面那段 JSON 通过 stdin 喂给你配置的命令(比如 /path/to/check.sh),启动子进程。

  3. 脚本判断 + 输出:你的脚本从 stdin 读 JSON,看到 rm -rf,决定拦下来。它做两件事:往 stderr 写一行原因(「BLOCKED: dangerous rm」),然后 exit 2

  4. Claude Code 看反馈:Claude Code 拿到 exit 2,知道这是拦截信号,工具调用被取消。stderr 那一行被当作「反馈给 Claude 的话」,加进 Claude 接下来的上下文。

  5. 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 形式的 decision
  • agent 起一个 Claude 子代理,可以用 Read/Grep/Glob 验证条件再返回 decision
  • mcp_tool 调一个已配好的 MCP server 工具

后四种有一个关键限制:promptagent 只能在工具事件上用(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:当前会话 ID
  • transcript_path:对话完整记录的 jsonl 文件路径
  • cwd:触发时的工作目录
  • hook_event_name:事件名(比如 "PreToolUse"

事件特有字段(举几个常用的)

PreToolUse / PostToolUse 类带 tool_nametool_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 的手段一共四种,按优先级从高到低:

  1. continue: false(终极停止)。在 stdout 打印 {"continue": false, "stopReason": "..."}。这是最高优先级的输出,意思是「请 Claude 完全停下来」。Claude Code 看到这个就不再继续这一轮的处理。

  2. decision: "block" + reason。在 stdout 打印 {"decision": "block", "reason": "..."}。这是事件特定的「拦截 / 反馈」信号,含义因事件而异:

    • PreToolUse 上:拦掉这次工具调用,reason 显示给 Claude
    • PostToolUse 上:工具已经执行了拦不住,但 reason 会让 Claude 自己想办法修复
    • Stop 上:拦住 Claude 停止,reason 告诉它接下来该做什么
    • UserPromptSubmit 上:拦掉用户的 prompt,reason 显示给用户
  3. exit code 2 + stderr。最简单的反馈手段。exit 2 意思是「拦截」,stderr 里的内容会被当作「给 Claude 的话」喂进上下文。能 block 的事件(PreToolUse、UserPromptSubmit、Stop、PermissionRequest)才会真的拦下来。在其他事件上,exit 2 也只是把 stderr 打给用户看,Claude 收不到。

  4. 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。所有结构化输出字段(decisionreasonadditionalContextcontinue 这些)在 SessionEnd 上都不被处理。你的脚本无论 print 什么 JSON,Claude 都看不到。你只能让脚本自己干完所有事,没有跟 Claude 来回交流的口。

第二道墙:promptagent 这两种 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:能够…”就行。那还看这教程干啥?知道有啥、是啥、能干啥,做评估用。


cover_image

原创 九皋山人 AI 方寸山


内容效果不满意?点此反馈

输入关键词开始搜索