10 KiB
10 KiB
Hook Patterns and Configuration
This guide covers hook patterns for intercepting and modifying Claude Agent SDK behavior.
Overview
Hooks allow you to intercept SDK events and modify behavior at key points in execution. Common uses:
- Control tool execution (approve/deny/modify)
- Add context to prompts
- Review tool outputs
- Stop execution on errors
- Log activity
⚠️ IMPORTANT: The Python SDK does NOT support
SessionStart,SessionEnd, orNotificationhooks due to setup limitations. Only the 6 hook types listed below are supported. Attempting to use unsupported hooks will result in them never firing.
Hook Types
| Hook | When It Fires | Common Uses |
|---|---|---|
PreToolUse |
Before tool execution | Approve/deny/modify tool calls |
PostToolUse |
After tool execution | Review output, add context, stop on errors |
UserPromptSubmit |
Before processing user prompt | Add context, modify prompt |
Stop |
When execution stops | Cleanup, final logging |
SubagentStop |
When a subagent stops | Capture subagent results, cleanup |
PreCompact |
Before message compaction | Review/modify messages before compaction |
Hook Configuration
Hooks are configured via the hooks parameter in ClaudeAgentOptions:
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher
options = ClaudeAgentOptions(
hooks={
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[check_bash_command]),
],
"PostToolUse": [
HookMatcher(matcher="Bash", hooks=[review_output]),
],
}
)
Hook Function Signature
All hooks have the same signature:
from claude_agent_sdk.types import HookInput, HookContext, HookJSONOutput
async def hook_function(
input_data: HookInput,
tool_use_id: str | None,
context: HookContext
) -> HookJSONOutput:
"""
Args:
input_data: Hook-specific data (tool_name, tool_input, etc.)
tool_use_id: Unique ID for this tool use (if applicable)
context: Additional context about the execution
Returns:
HookJSONOutput: Dict with hook-specific fields
"""
return {} # Empty dict = no action
HookJSONOutput Fields
| Field | Type | Use Case | Description |
|---|---|---|---|
continue_ |
bool |
Stop execution | Set to False to halt execution |
stopReason |
str |
Stop execution | Explanation for why execution stopped |
reason |
str |
Logging/debugging | Explanation of hook decision |
systemMessage |
str |
User feedback | Message shown to user |
hookSpecificOutput |
dict |
Hook-specific data | Additional hook-specific fields |
PreToolUse Hook-Specific Fields
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow" | "deny", # Control tool execution
"permissionDecisionReason": "Explanation for decision",
"modifiedInput": {...} # Optional: Modified tool input
}
}
PostToolUse Hook-Specific Fields
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Extra context based on tool output"
}
}
UserPromptSubmit Hook-Specific Fields
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"updatedPrompt": "Modified prompt text"
}
}
Stop Hook-Specific Fields
{
"hookSpecificOutput": {
"hookEventName": "Stop"
}
}
SubagentStop Hook-Specific Fields
{
"hookSpecificOutput": {
"hookEventName": "SubagentStop"
}
}
PreCompact Hook-Specific Fields
{
"hookSpecificOutput": {
"hookEventName": "PreCompact",
"additionalContext": "Context to preserve during compaction"
}
}
Common Patterns
1. Block Dangerous Commands (PreToolUse)
async def check_bash_command(
input_data: HookInput,
tool_use_id: str | None,
context: HookContext
) -> HookJSONOutput:
"""Prevent dangerous bash commands."""
if input_data["tool_name"] != "Bash":
return {}
command = input_data["tool_input"].get("command", "")
dangerous_patterns = ["rm -rf", "sudo", "chmod 777", "dd if="]
for pattern in dangerous_patterns:
if pattern in command:
return {
"reason": f"Blocked dangerous command pattern: {pattern}",
"systemMessage": f"🚫 Blocked: {pattern}",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Command contains: {pattern}"
}
}
return {} # Allow by default
2. Review Tool Output (PostToolUse)
async def review_tool_output(
input_data: HookInput,
tool_use_id: str | None,
context: HookContext
) -> HookJSONOutput:
"""Add context based on tool output."""
tool_response = input_data.get("tool_response", "")
if "error" in str(tool_response).lower():
return {
"systemMessage": "⚠️ Command produced an error",
"reason": "Tool execution failed",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Consider checking command syntax or permissions."
}
}
return {}
4. Stop on Critical Errors (PostToolUse)
async def stop_on_critical_error(
input_data: HookInput,
tool_use_id: str | None,
context: HookContext
) -> HookJSONOutput:
"""Halt execution on critical errors."""
tool_response = input_data.get("tool_response", "")
if "critical" in str(tool_response).lower():
return {
"continue_": False,
"stopReason": "Critical error detected - halting for safety",
"systemMessage": "🛑 Execution stopped: critical error"
}
return {"continue_": True}
5. Redirect File Writes (PreToolUse)
async def safe_file_writes(
input_data: HookInput,
tool_use_id: str | None,
context: HookContext
) -> HookJSONOutput:
"""Redirect writes to safe directory."""
if input_data["tool_name"] not in ["Write", "Edit"]:
return {}
file_path = input_data["tool_input"].get("file_path", "")
# Block writes to system directories
if file_path.startswith("/etc/") or file_path.startswith("/usr/"):
return {
"reason": f"Blocked write to system directory: {file_path}",
"systemMessage": "🚫 Cannot write to system directories",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "System directory protection"
}
}
# Redirect to safe directory
if not file_path.startswith("./safe/"):
safe_path = f"./safe/{file_path.split('/')[-1]}"
modified_input = input_data["tool_input"].copy()
modified_input["file_path"] = safe_path
return {
"reason": f"Redirected write from {file_path} to {safe_path}",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Redirected to safe directory",
"modifiedInput": modified_input
}
}
return {}
Hook Matcher Patterns
HookMatcher determines when hooks fire:
# Match specific tool
HookMatcher(matcher="Bash", hooks=[check_bash])
# Match all tools
HookMatcher(matcher=None, hooks=[log_all_tools])
# Multiple hooks for same matcher
HookMatcher(
matcher="Write",
hooks=[check_permissions, log_write, backup_file]
)
Complete Example
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, HookMatcher
async def block_dangerous_bash(input_data, tool_use_id, context):
if input_data["tool_name"] != "Bash":
return {}
command = input_data["tool_input"].get("command", "")
if "rm -rf" in command:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Dangerous rm -rf detected"
}
}
return {}
options = ClaudeAgentOptions(
allowed_tools=["Bash", "Read", "Write"],
hooks={
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[block_dangerous_bash])
]
}
)
async with ClaudeSDKClient(options=options) as client:
await client.query("Run a safe bash command")
async for message in client.receive_response():
# Process messages
pass
Best Practices
- Return early - Return empty dict
{}when hook doesn't apply - Be specific - Clear
reasonandsystemMessagefields help debugging - Use matchers - Filter hooks to relevant tools with
matcherparameter - Chain hooks - Multiple hooks can process same event
- Handle errors - Hooks should be defensive and handle missing data
- Log decisions - Use
reasonfield to explain hook decisions
Anti-Patterns
❌ Blocking without explanation
return {
"hookSpecificOutput": {
"permissionDecision": "deny"
# Missing permissionDecisionReason
}
}
✅ Clear explanations
return {
"reason": "Command contains dangerous pattern: rm -rf",
"systemMessage": "🚫 Blocked dangerous command",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Safety: rm -rf detected"
}
}
❌ Ignoring tool_name in PreToolUse
# This will fire for ALL tools
async def hook(input_data, tool_use_id, context):
command = input_data["tool_input"]["command"] # Crashes on non-Bash tools
✅ Filter by tool_name
async def hook(input_data, tool_use_id, context):
if input_data["tool_name"] != "Bash":
return {}
command = input_data["tool_input"].get("command", "")