8.7 KiB
| title | aliases | tags | sources | created | updated | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Intercept and Control Agent Behavior with SDK Hooks |
|
|
|
2026-04-17 | 2026-04-17 |
Intercept and Control Agent Behavior with SDK Hooks
SDK hooks are async callback functions registered in ClaudeAgentOptions that fire at key execution points. Unlike wiki/agent-sdk/hooks-guide (defined in settings files), these run in-process alongside your agent code.
What Hooks Can Do
- Block dangerous operations before they execute (
permissionDecision: "deny") - Modify tool inputs before execution (
updatedInput) - Auto-approve specific tools without user prompts (
permissionDecision: "allow") - Log / audit every tool call for compliance or debugging
- Inject context into the conversation the model sees (
systemMessage) - Track lifecycle — subagent start/stop, session boundaries, compaction
Available Hook Events
| Hook Event | Python | TypeScript | Trigger |
|---|---|---|---|
PreToolUse |
Yes | Yes | Before tool executes — can block or modify |
PostToolUse |
Yes | Yes | After tool succeeds |
PostToolUseFailure |
Yes | Yes | After tool fails |
UserPromptSubmit |
Yes | Yes | When user prompt is submitted |
Stop |
Yes | Yes | Agent execution stops |
SubagentStart |
Yes | Yes | Subagent initializes |
SubagentStop |
Yes | Yes | Subagent completes |
PreCompact |
Yes | Yes | Before conversation compaction |
PermissionRequest |
Yes | Yes | Before permission dialog |
Notification |
Yes | Yes | Agent status messages |
SessionStart |
No | Yes | Session init (Python: use shell hooks instead) |
SessionEnd |
No | Yes | Session ends |
TaskCompleted |
No | Yes | Background task finishes |
ConfigChange |
No | Yes | Config file changes |
WorktreeCreate |
No | Yes | Git worktree created |
WorktreeRemove |
No | Yes | Git worktree removed |
Configuration
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher
options = ClaudeAgentOptions(
hooks={
"PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[my_callback])]
}
)
- Keys = hook event names (case-sensitive:
PreToolUsenotpreToolUse) - Values = list of
HookMatcherobjects
Matchers
HookMatcher(matcher="...", hooks=[...], timeout=60)
matcher— regex against tool name (e.g."Write|Edit","^mcp__","Bash")- Omit
matcherto match all occurrences of the event - Matchers filter by tool name only — filter by file path inside the callback
Built-in tool names: Bash, Read, Write, Edit, Glob, Grep, WebFetch, Agent
MCP tools: mcp__<server>__<action> (e.g. mcp__playwright__browser_click)
Callback Inputs & Outputs
Inputs (3 args)
input_data— typed dict with event details; all sharesession_id,cwd,hook_event_nametool_use_id— correlatesPreToolUse↔PostToolUsefor the same callcontext—AbortSignalin TypeScript; reserved in Python
Output Fields
| Field | Level | Effect |
|---|---|---|
systemMessage |
top-level | Injects text into conversation (model sees it) |
continue (continue_ in Python) |
top-level | Stop agent after this hook if False |
hookSpecificOutput.permissionDecision |
nested | "allow" / "deny" / "ask" |
hookSpecificOutput.permissionDecisionReason |
nested | Reason string shown to model |
hookSpecificOutput.updatedInput |
nested | Replacement tool input (must also set allow) |
hookSpecificOutput.additionalContext |
nested | Appended to tool result (PostToolUse only) |
Return {} to allow the operation unchanged.
Priority: deny > ask > allow — if any hook denies, the operation is blocked.
Async (fire-and-forget) output
For side effects (logging, webhooks) that shouldn't block the agent:
async def async_hook(input_data, tool_use_id, context):
asyncio.create_task(send_to_logging_service(input_data))
return {"async_": True, "asyncTimeout": 30000}
Cannot block, modify, or inject context — agent has already moved on.
Common Patterns
Block dangerous paths
async def protect_env_files(input_data, tool_use_id, context):
file_path = input_data["tool_input"].get("file_path", "")
if file_path.split("/")[-1] == ".env":
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": "Cannot modify .env files",
}
}
return {}
Redirect writes to sandbox
async def redirect_to_sandbox(input_data, tool_use_id, context):
if input_data["tool_name"] == "Write":
original = input_data["tool_input"].get("file_path", "")
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "allow", # required with updatedInput
"updatedInput": {**input_data["tool_input"], "file_path": f"/sandbox{original}"},
}
}
return {}
Auto-approve read-only tools
async def auto_approve_read_only(input_data, tool_use_id, context):
if input_data["tool_name"] in ["Read", "Glob", "Grep"]:
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "allow",
}
}
return {}
Chain hooks (single responsibility per hook)
options = ClaudeAgentOptions(
hooks={
"PreToolUse": [
HookMatcher(hooks=[rate_limiter]),
HookMatcher(hooks=[authorization_check]),
HookMatcher(hooks=[input_sanitizer]),
HookMatcher(hooks=[audit_logger]),
]
}
)
Hooks execute in array order. Chain for complex logic, keep each focused.
Forward notifications to Slack
async def notification_handler(input_data, tool_use_id, context):
try:
await asyncio.to_thread(_send_slack, input_data.get("message", ""))
except Exception as e:
print(f"Slack failed: {e}") # swallow — don't interrupt agent
return {}
options = ClaudeAgentOptions(
hooks={"Notification": [HookMatcher(hooks=[notification_handler])]}
)
Notification types: permission_prompt, idle_prompt, auth_success, elicitation_dialog.
Troubleshooting
| Problem | Fix |
|---|---|
| Hook not firing | Check event name case; verify matcher regex; check max_turns not hit |
| Matcher too broad | Empty matcher fires on ALL tools — always use matcher= when possible |
updatedInput not applied |
Must be inside hookSpecificOutput, must also include permissionDecision: "allow", must include hookEventName |
SessionStart/SessionEnd missing in Python |
Not in Python SDK — use shell hooks via setting_sources=["project"] |
| Subagent permission storms | Use PreToolUse to auto-approve tools for subagents; they don't inherit parent permissions |
| Recursive loops | UserPromptSubmit hooks spawning subagents can loop — check agent_id to guard |
Key Takeaways
- SDK hooks (
HookMatcher+ async callbacks) run in-process; shell command hooks run in.claude/settings.json— they complement each other PreToolUseis the workhorse: block, modify inputs, or auto-approve- Always return
{}to pass through; never mutatetool_inputin place — return a new object denybeatsaskbeatsallow— multiple hooks compose safelySessionStart/SessionEndare TypeScript-only in the SDK; Python gets them via shell hooks +setting_sources- Use async output (
async_: True) for logging/webhook side effects to avoid blocking the agent - Matchers are tool-name regex only — filter by file path inside the callback body
Related Articles
- wiki/agent-sdk/hooks-guide — shell command hooks: all 25 events, exit codes, JSON output format
- wiki/agent-sdk/configure-permissions — permission modes, allow/deny rules, subagent inheritance
- wiki/agent-sdk/python-api-reference — full Python hook input/output type definitions
- wiki/agent-sdk/typescript-api-reference — full TypeScript hook input/output type definitions
- wiki/agent-sdk/custom-tools — build tools to extend what the agent can call
- wiki/agent-sdk/user-input-approvals —
canUseToolcallback for interactive approval flows
Source: raw/Intercept and control agent behavior with hooks.md