
Claude Code SDK #6:Hooks 系统全解——在 Agent 生命周期的每个节点插入你的代码
Hooks 是 Agent SDK 的「神经系统」——在工具调用前后、会话开始结束、用户提示提交等关键节点插入回调函数,不改动任何 SDK 内部代码,就能拦截危险操作、审计工具调用、动态注入上下文。本篇完整拆解生命周期三层结构(每会话/每轮/每工具)、HookMatcher 配置、五种决策输出、三个典型场景代码示例,以及 Python vs TypeScript 的关键差异。
リサーチノート
Agent 跑起来不难。难的是让它在生产环境里可控、可审计、不出事。
你无法改动 Claude 内部的推理逻辑,但你可以在它「即将做某件事」之前拦住它,检查、修改、放行或拒绝。这就是 Hooks 的价值所在。1
什么是 Hooks
Hooks 是注册在
ClaudeAgentOptions 上的回调函数,在 Agent 执行的特定节点被 SDK 自动调用。1整个机制可以用四步描述:
- 事件触发:Agent 即将调用某工具,或会话开始,或执行结束
- SDK 收集 Hooks:按事件类型找到所有注册的回调,用
matcher过滤 - 回调函数执行:你的代码拿到事件的完整上下文(工具名、参数、session_id 等)
- 返回决策:
allow放行、deny拒绝、ask要求用户确认、或直接修改工具的输入参数
返回
{} 等价于「不干预,继续」。生命周期三层结构
SDK 的 Hook 事件按触发频率分三个层次:
每次会话触发一次
SessionStart/SessionEnd:会话初始化与结束(仅 TypeScript SDK 支持回调形式)SubagentStart/SubagentStop:子 Agent 启动与完成
每轮对话触发一次
UserPromptSubmit:用户提交 Prompt,Claude 处理之前Stop:Agent 本轮执行结束
每次工具调用触发
PreToolUse:工具调用前,可以拦截PostToolUse:工具成功后,可注入上下文PostToolUseFailure:工具失败后,可记录或给 Claude 反馈PostToolBatch:一批并行工具全部完成后,下一次模型调用前(仅 TypeScript)
下图来自官方文档,展示了完整的 Hook 生命周期节点分布:
核心 API:HookMatcher + 回调签名
注册一个 Hook 需要两样东西:用哪个事件、过滤什么工具。
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher
options = ClaudeAgentOptions(
hooks={
"PreToolUse": [
HookMatcher(matcher="Write|Edit", hooks=[my_callback])
]
}
)HookMatcher 有三个字段:| 字段 | 类型 | 说明 |
|---|---|---|
matcher | str(正则) | 过滤工具名,如 "Write|Edit" 或 "^mcp__" |
hooks | list[HookCallback] | 必填,回调函数列表 |
timeout | int | 超时秒数,默认 60 |
注意:
matcher 只匹配工具名,不匹配文件路径。要按路径过滤,需要在回调内部检查 tool_input["file_path"]。每个回调函数接收三个参数:
async def my_callback(input_data: dict, tool_use_id: str | None, context) -> dict:
# input_data 包含 session_id、cwd、hook_event_name、tool_name、tool_input 等
# tool_use_id 用于关联同一工具调用的 PreToolUse 和 PostToolUse
# context 目前在 Python SDK 中预留,暂未使用
return {} # 返回空对象 = 不干预五类决策输出
回调返回的对象决定 Agent 下一步怎么走。所有与「当前工具调用」相关的决策都放在
hookSpecificOutput 下。下图说明了一次 PreToolUse Hook 的完整解析流程——从事件触发、matcher 检查、if 条件匹配,到 hook 处理结果返回给 Claude Code:
# 拒绝工具调用
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": "禁止写入 .env 文件",
}
}
# 自动放行
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "allow",
}
}
# 修改工具的输入参数(同时自动放行)
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "allow",
"updatedInput": {
**input_data["tool_input"],
"file_path": f"/sandbox{original_path}", # 重定向到沙箱目录
},
}
}permissionDecision 四个取值:"allow":跳过权限提示,直接执行"deny":阻止工具调用,把permissionDecisionReason返回给 Claude"ask":弹出用户确认对话框"defer":暂停执行,让调用方通过--resume手动处理(仅在-p非交互模式下有效)
优先级规则:多个 Hook 同时返回决策时,
deny > defer > ask > allow——任意一个 deny 就足以拒绝。PostToolUse 里还有两个额外字段:additionalContext:在工具结果旁边向 Claude 注入补充信息updatedToolOutput:完全替换工具的输出(Claude 会看到替换后的版本,但工具已经跑完)
三个典型场景
场景一:保护敏感文件
async def protect_sensitive_files(input_data, tool_use_id, context):
file_path = input_data.get("tool_input", {}).get("file_path", "")
# 禁止修改 .env 和所有密钥文件
sensitive = [".env", ".env.local", "id_rsa", "credentials.json"]
if any(file_path.endswith(s) for s in sensitive):
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": f"文件 {file_path} 包含敏感信息,已拒绝修改",
}
}
return {}
options = ClaudeAgentOptions(
hooks={
"PreToolUse": [
HookMatcher(matcher="Write|Edit", hooks=[protect_sensitive_files])
]
}
)场景二:审计日志(异步,不阻塞 Agent)
每次文件变更都写入审计日志,但不让记录操作拖慢 Agent:
import asyncio
from datetime import datetime
async def audit_file_changes(input_data, tool_use_id, context):
# 先返回异步信号,Agent 继续执行
asyncio.create_task(_write_audit(input_data))
return {"async_": True, "asyncTimeout": 5000}
async def _write_audit(input_data):
file_path = input_data.get("tool_input", {}).get("file_path", "unknown")
tool = input_data.get("tool_name", "unknown")
ts = datetime.now().isoformat()
with open("./audit.log", "a") as f:
f.write(f"{ts}\t{tool}\t{file_path}\n")
options = ClaudeAgentOptions(
hooks={
"PostToolUse": [
HookMatcher(matcher="Write|Edit", hooks=[audit_file_changes])
]
}
)注意:异步 Hook 无法影响决策,不能
deny 或 allow,只能做日志、指标、通知等副作用。场景三:PostToolUse 向 Claude 注入上下文
async def inject_file_context(input_data, tool_use_id, context):
file_path = input_data.get("tool_input", {}).get("file_path", "")
# 如果修改的是自动生成文件,告知 Claude 不要直接改,要改源文件
if "generated" in file_path or file_path.endswith(".pb.go"):
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"additionalContext": f"{file_path} 是自动生成文件,请修改对应的 .proto 源文件后重新生成",
}
}
return {}这段
additionalContext 会被 Claude 在下一次模型调用时看到,相当于在工具结果旁贴了一张便利贴。Python vs TypeScript 差异备忘
| 特性 | Python SDK | TypeScript SDK |
|---|---|---|
SessionStart / SessionEnd 回调 | ❌ 不支持(只能用 settings.json shell hook) | ✅ 支持 |
PostToolBatch | ❌ 不支持 | ✅ 支持 |
async_ 字段(异步输出) | 用 async_ 避开 Python 保留字 | 用 async |
agent_id / agent_type 字段 | 仅在 PreToolUse / PostToolUse / PostToolUseFailure 可用 | 所有事件可用 |
context 参数 | 预留,暂不可用 | 包含 AbortSignal 用于取消 |
Python 开发者如果需要 SessionStart 逻辑,可以通过监听
receive_response() 的第一条消息来代替,或在 .claude/settings.json 里配置 shell command hook 并设置 setting_sources=["project"]。1完整事件列表、每个 Hook 的 JSON 输入输出 schema 和 matcher 规则,参见官方 Hooks 参考文档:
コンテンツカードを読み込んでいます…
三条实践建议
1. 每个 Hook 独立决策,不依赖其他 Hook 的执行顺序
同事件的多个 Hook 并行执行,完成顺序不确定。写每个回调时假设它是唯一运行的那一个。
2. 生产环境的拦截 Hook 必须捕获异常
未处理的异常会中断 Agent 执行。正确姿势:
async def safe_hook(input_data, tool_use_id, context):
try:
result = await do_validation(input_data)
return result
except Exception as e:
# 记录日志,但不让异常传播
print(f"[hook error] {e}")
return {} # 降级为不干预3.
additionalContext 写事实,不写指令Claude 对「看起来像系统指令」的注入文本有防御机制,可能反而把内容暴露给用户。写法是陈述事实:「当前分支: feat/auth-refactor,有未提交改动」,而不是「你必须先提交再继续」。
下一篇:#7 子 Agent(Subagents) —— 用
AgentDefinition 定义专属 Agent、Agent 工具自动代理调用、父子 Agent 消息流的追踪字段,以及子 Agent 的权限隔离策略。
このコンテンツについて、さらに観点や背景を補足しましょう。