Initial commit
This commit is contained in:
374
skills/claude-agent-sdk/references/hooks-guide.md
Normal file
374
skills/claude-agent-sdk/references/hooks-guide.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# 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`, or `Notification` hooks 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`:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "allow" | "deny", # Control tool execution
|
||||
"permissionDecisionReason": "Explanation for decision",
|
||||
"modifiedInput": {...} # Optional: Modified tool input
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PostToolUse Hook-Specific Fields
|
||||
|
||||
```python
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PostToolUse",
|
||||
"additionalContext": "Extra context based on tool output"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UserPromptSubmit Hook-Specific Fields
|
||||
|
||||
```python
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"updatedPrompt": "Modified prompt text"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Stop Hook-Specific Fields
|
||||
|
||||
```python
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "Stop"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SubagentStop Hook-Specific Fields
|
||||
|
||||
```python
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SubagentStop"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PreCompact Hook-Specific Fields
|
||||
|
||||
```python
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreCompact",
|
||||
"additionalContext": "Context to preserve during compaction"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. Block Dangerous Commands (PreToolUse)
|
||||
|
||||
```python
|
||||
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)
|
||||
|
||||
```python
|
||||
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)
|
||||
|
||||
```python
|
||||
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)
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
# 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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
1. **Return early** - Return empty dict `{}` when hook doesn't apply
|
||||
2. **Be specific** - Clear `reason` and `systemMessage` fields help debugging
|
||||
3. **Use matchers** - Filter hooks to relevant tools with `matcher` parameter
|
||||
4. **Chain hooks** - Multiple hooks can process same event
|
||||
5. **Handle errors** - Hooks should be defensive and handle missing data
|
||||
6. **Log decisions** - Use `reason` field to explain hook decisions
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
❌ **Blocking without explanation**
|
||||
|
||||
```python
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"permissionDecision": "deny"
|
||||
# Missing permissionDecisionReason
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Clear explanations**
|
||||
|
||||
```python
|
||||
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**
|
||||
|
||||
```python
|
||||
# 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**
|
||||
|
||||
```python
|
||||
async def hook(input_data, tool_use_id, context):
|
||||
if input_data["tool_name"] != "Bash":
|
||||
return {}
|
||||
command = input_data["tool_input"].get("command", "")
|
||||
```
|
||||
Reference in New Issue
Block a user