
Hook Authoring
- 101 installs
- 325 repo stars
- Updated August 2, 2026
- athola/claude-night-market
Hook Authoring is an agent skill that documents and guides implementation of Claude Code lifecycle, tool, permission, and coordination hooks.
About
Hook Authoring from athola/claude-night-market is an agent-building skill that maps Claude Code’s hook surface so solo developers can inject context, gate tool use, and coordinate subagents at precise lifecycle moments. The overview spans Setup through SessionEnd and Stop, tool hooks before and after execution (including failure paths), PermissionRequest automation, UserPromptSubmit routing, and newer notification and worktree events documented through Claude Code 2.1.69 fixes. Builders use it when shipping a plugin or internal workflow that must auto-approve safe patterns, log metrics on PostToolUse, chain tasks on TaskCompleted, or stop idle teammates with structured continue flags. It assumes familiarity with Claude Code’s hook JSON contracts and version-gated features rather than teaching general JavaScript. Outputs are hook definitions and authoring decisions tied to event types—not end-user product features. Treat it as reference-driven implementation guidance while you iterate in a real repo with plugin settings.
- Quick reference aligned to Claude Code 2.1.50 covering lifecycle, tool, permission, communication, agent, config, contex
- Documents PreToolUse / PostToolUse / PostToolUseFailure interception for validation, metrics, and error fallbacks
- Agent coordination hooks: SubagentStart, SubagentStop, TeammateIdle, TaskCompleted with continue:false teammate control
- ConfigChange and InstructionsLoaded hooks for reacting to settings and augmenting loaded instructions (2.1.33–2.1.49+)
- PreCompact hook for preserving critical context before session compaction
Hook Authoring by the numbers
- 101 all-time installs (skills.sh)
- Ranked #4,345 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/athola/claude-night-market --skill hook-authoringAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 101 |
|---|---|
| repo stars | ★ 325 |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 2, 2026 |
| Repository | athola/claude-night-market ↗ |
What it does
Design and implement Claude Code hooks across session lifecycle, tool calls, permissions, and agent coordination instead of guessing event names and payload shapes.
Who is it for?
Best when you're shipping Claude Code plugins and need a structured map of hook events from 2.1.20 through 2.1.69 behavior notes.
Skip if: Skip if you're only using stock Claude Code without custom hooks, or beginners and have not yet run a single PreToolUse example in settings.
When should I use this skill?
When implementing or debugging Claude Code hooks for plugins, including lifecycle, tool, permission, and agent coordination events.
What you get
You can pick the correct hook type for each interception point and implement handlers with the right payload semantics for your plugin or night-market extension.
- Hook handler implementations mapped to specific Claude Code events
- Documented interception strategy for tools, permissions, and subagent coordination
By the numbers
- Hook quick reference anchored to Claude Code 2.1.50 with 2.1.69+ teammate continue controls
Files
Table of Contents
- Overview
- Key Capabilities
- Quick Start
- Your First Hook (JSON - Claude Code)
- Your First Hook (Python - Claude Agent SDK)
- Hook Event Types
- Claude Code vs SDK
- JSON Hooks (Claude Code)
- Python SDK Hooks
- Security Essentials
- Critical Security Rules
- Example: Secure Logging Hook
- Performance Guidelines
- Performance Best Practices
- Example: Efficient Hook
- Scope Selection
- Decision Framework
- Scope Comparison
- Common Patterns
- Validation Hook
- Logging Hook
- Context Injection Hook
- Testing Hooks
- Unit Testing
- Module References
- Tools
- Related Skills
- Next Steps
- References
Hook Authoring Guide
Overview
Hooks are event interceptors that allow you to extend Claude Code and Claude Agent SDK behavior by executing custom logic at specific points in the agent lifecycle. They enable validation before tool use, logging after actions, context injection, workflow automation, and security enforcement.
This skill teaches you how to write effective, secure, and performant hooks for both declarative JSON (Claude Code) and programmatic Python (Claude Agent SDK) use cases.
Key Capabilities
- PreToolUse: Validate, filter, or transform tool inputs before execution; inject context (2.1.9+)
- PostToolUse: Log, analyze, or modify tool outputs after execution
- UserPromptSubmit: Inject context or filter user messages before processing
- Stop/SubagentStop: Cleanup, final reporting, or result aggregation
- TeammateIdle/TaskCompleted: Multi-agent coordination and orchestration (2.1.33+)
- PreCompact: State preservation before context window compaction
New in 2.1.9: PreToolUse hooks can now return additionalContext to inject information before a tool executes. This enables patterns like cache hints, security warnings, or relevant context injection.Quick Start
Your First Hook (JSON - Claude Code)
Create a simple logging hook in .claude/settings.json:
{
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "echo \"$(date): Executed $CLAUDE_TOOL_NAME\" >> ~/.claude/audit.log"
}]
}
]
}Note: Use string matchers ("Bash") not object matchers ({"toolName": "Bash"}).
Verification: Run the command with --help flag to verify availability.
This logs every Bash command execution with a timestamp.
Your First Hook (Python - Claude Agent SDK)
Create a validation hook using the SDK:
from claude_agent_sdk import AgentHooks
class ValidationHooks(AgentHooks):
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Validate tool inputs before execution."""
if tool_name == "Bash":
command = tool_input.get("command", "")
if "rm -rf /" in command:
raise ValueError("Dangerous command blocked by hook")
# Return None to proceed unchanged, or modified dict to transform
return NoneVerification: Run the command with --help flag to verify availability.
Hook Event Types
Quick reference for all supported hook events:
| Event | Trigger Point | Parameters | Common Use Cases |
|---|---|---|---|
| PreToolUse | Before tool execution | tool_name, tool_input | Validation, filtering, input transformation |
| PostToolUse | After tool execution | tool_name, tool_input, tool_output | Logging, metrics, output transformation |
| UserPromptSubmit | User sends message | message | Context injection, content filtering |
| PermissionRequest | Permission dialog shown | tool_name, tool_input | Auto-approve/deny with custom logic |
| Notification | Claude Code sends notification | message | Custom notification handling |
| Stop | Agent completes | reason, result | Final cleanup, summary reports |
| SubagentStop | Subagent completes | subagent_id, result | Result processing, aggregation |
| TeammateIdle | Teammate agent becomes idle | agent_id, session_id | Work assignment, load balancing (2.1.33+) |
| TaskCompleted | Task finishes execution | task_id, result | Coordination, chaining, reporting (2.1.33+) |
| PreCompact | Before context compact | context_size | State preservation, checkpointing |
| SessionStart | Session starts/resumes | session_id, source, agent_type | Initialization, context loading |
| SessionEnd | Session terminates | session_id | Cleanup, final logging |
| WorktreeCreate | Agent worktree created | worktree_path, session_id | Custom VCS setup, symlink .venv, pre-populate caches (2.1.50+) |
| WorktreeRemove | Agent worktree removed | worktree_path, session_id | Cleanup temp files, teardown worktree-scoped resources (2.1.50+) |
SessionStart Input Schema (Claude Code 2.1.2+)
The SessionStart hook receives JSON input via stdin with these fields:
{
"session_id": "abc123",
"source": "startup",
"agent_type": "my-agent"
}Fields: source is one of "startup", "resume", "clear", or "compact". agent_type is populated when the --agent flag is used.
`agent_type` field: When Claude Code is launched with --agent my-agent, this field contains the agent name, enabling agent-specific initialization:
# Python example: Agent-aware SessionStart hook
input_data = json.loads(sys.stdin.read())
agent_type = input_data.get("agent_type", "")
if agent_type in ["code-reviewer", "quick-query"]:
# Skip heavy context injection for lightweight agents
print(json.dumps({"hookSpecificOutput": {"additionalContext": "Minimal context"}}))
else:
# Full initialization for implementation agents
print(json.dumps({"hookSpecificOutput": {"additionalContext": full_context}}))# Bash example: Agent-aware SessionStart hook
HOOK_INPUT=$(cat)
AGENT_TYPE=$(echo "$HOOK_INPUT" | jq -r '.agent_type // empty')
case "$AGENT_TYPE" in
code-reviewer|quick-query)
echo '{"hookSpecificOutput": {"additionalContext": "Minimal context"}}'
;;
*)
echo '{"hookSpecificOutput": {"additionalContext": "Full context"}}'
;;
esacHooks in Frontmatter (Claude Code 2.1.0+)
New in 2.1.0: Define hooks directly in skill, command, or agent frontmatter. These hooks are scoped to the component's lifecycle.
Skill/Command/Agent Frontmatter Hooks
---
name: validated-skill
description: Skill with lifecycle hooks
hooks:
PreToolUse:
- matcher: "Bash"
command: "./validate-command.sh"
once: true # NEW: Run only once per session
- matcher: "Write|Edit"
command: "./pre-edit-check.sh"
PostToolUse:
- matcher: "Write|Edit"
command: "./format-on-save.sh"
Stop:
- command: "./cleanup-and-report.sh"
---The once: true Configuration
New in 2.1.0: Use once: true to execute a hook only once per session, ideal for:
- One-time setup/initialization
- Resource allocation that shouldn't repeat
- Session-level configuration
hooks:
PreToolUse:
- matcher: "Bash"
command: "./setup-environment.sh"
once: true # Runs only on first Bash call
SessionStart:
- command: "./initialize-session.sh"
once: true # Runs only once at session startFrontmatter vs Settings Hooks
| Aspect | Frontmatter Hooks | Settings Hooks |
|---|---|---|
| Scope | Component lifecycle | Global/project |
| Location | In skill/agent/command | settings.json |
| Persistence | Active only when component runs | Always active |
| Use case | Component-specific validation | Cross-cutting concerns |
PreToolUse updatedInput (2.1.0 Fix)
PreToolUse hooks can now return updatedInput when returning ask permission decision, enabling hooks to act as middleware while still requesting user consent:
{
"decision": "ask",
"updatedInput": {
"command": "modified-command --safe-flag"
}
}Claude Code vs SDK
JSON Hooks (Claude Code)
Declarative configuration in .claude/settings.json, project .claude/settings.json, or plugin hooks/hooks.json:
{
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [{
"type": "command",
"command": "echo 'WARNING: Editing production file' >&2"
}]
}
]
}Important: Use string matchers (regex patterns), not object matchers. The object format {"toolName": "Edit"} is deprecated.
Matcher patterns:
"Edit"- Match single tool"Read|Write|Edit"- Match multiple tools (regex OR)".*"- Match all tools
Verification: Run the command with --help flag to verify availability.
Pros: Simple, no code required, easy to version control Cons: Limited logic capabilities, shell command only
HTTP Hooks (Claude Code 2.1.63+)
New in 2.1.63: Hooks can POST JSON to a URL and receive JSON responses instead of running shell commands. Use "type": "http" with a "url" field:
{
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "http",
"url": "https://my-service.example.com/hooks/validate-bash"
}]
}
]
}The hook POSTs the standard hook input as JSON and expects a standard hook response JSON body.
When to use HTTP hooks over command hooks:
- Enterprise environments where shell execution is restricted
- Centralized hook logic shared across teams via a web service
- Sandboxed or containerized setups without local script access
- Integration with external validation/logging services
Pros: No local scripts needed, centralized logic, works in sandboxed environments Cons: Network latency, requires running HTTP service, external dependency
Python SDK Hooks
Programmatic callbacks using AgentHooks base class:
from claude_agent_sdk import AgentHooks
class MyHooks(AgentHooks):
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
# Complex validation logic
if self._is_dangerous(tool_input):
raise ValueError("Operation blocked")
return None # or return modified inputVerification: Run the command with --help flag to verify availability.
Pros: Full Python capabilities, complex logic, state management Cons: Requires Python, more complex setup
Bash Permission Matching Notes
Environment Variable Wrappers (2.1.38+)
Permission rules now correctly match commands prefixed with environment variable assignments. Before 2.1.38, NODE_ENV=production npm test would not match a rule for Bash(npm *).
# These now all match `Bash(npm *)`:
npm test
NODE_ENV=production npm test
FORCE_COLOR=1 CI=true npm testWhen writing PreToolUse hooks that inspect bash commands, be aware that the permission system strips env var prefixes for matching, but your hook receives the full command string including prefixes.
Heredoc Delimiter Security (2.1.38+)
Claude Code now validates heredoc delimiters to prevent command smuggling. The recommended pattern <<'EOF' (single-quoted) remains the safest approach. Always use single-quoted delimiters in heredoc patterns to prevent variable expansion.
Security Essentials
Critical Security Rules
1. Input Validation: Always validate tool inputs before processing 2. No Secret Logging: Never log API keys, tokens, passwords, or credentials 3. Sandbox Awareness: Respect sandbox boundaries, don't escape. Note: .claude/skills/ is read-only in sandbox mode (2.1.38+) 4. Fail-Safe Defaults: Return None on error instead of blocking the agent 5. Rate Limiting: Prevent hook abuse from malicious or buggy code 6. Injection Prevention: Sanitize all logged content to prevent log injection
Example: Secure Logging Hook
import re
from claude_agent_sdk import AgentHooks
class SecureLoggingHooks(AgentHooks):
# Patterns that might contain secrets
SECRET_PATTERNS = [
r'api[_-]?key',
r'password',
r'token',
r'secret',
r'credential',
r'auth',
]
def _sanitize_output(self, text: str) -> str:
"""Remove potential secrets from log output."""
for pattern in self.SECRET_PATTERNS:
text = re.sub(
rf'({pattern}["\s:=]+)([^\s,}}]+)',
r'\1***REDACTED***',
text,
flags=re.IGNORECASE
)
return text
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Log tool use with sanitization."""
safe_output = self._sanitize_output(tool_output)
# Log safe_output...
return None # Don't modify outputVerification: Run the command with --help flag to verify availability.
See modules/testing-hooks.md for detailed security guidance.
Performance Guidelines
Performance Best Practices
1. Non-Blocking: Use async/await properly, don't block the event loop 2. Timeout Handling: Hook timeout is 10 minutes (increased from 60s in 2.1.3). For most hooks, aim for < 30s; use extended time only for CI/CD integration, complex validation, or external API calls 3. Efficient Logging: Batch writes, use async I/O 4. Memory Management: Don't accumulate unbounded state 5. Fail Fast: Quick validation, early returns, avoid expensive operations
Example: Efficient Hook
import asyncio
from claude_agent_sdk import AgentHooks
class EfficientHooks(AgentHooks):
def __init__(self):
self._log_queue = asyncio.Queue()
self._log_task = None
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
# Quick validation only
if not self._is_valid_input(tool_input):
raise ValueError("Invalid input")
return None
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
# Queue log entry without blocking
await self._log_queue.put({
'tool': tool_name,
'timestamp': time.time()
})
return None
def _is_valid_input(self, tool_input: dict) -> bool:
"""Fast validation check."""
# Simple checks only, < 10ms
return len(str(tool_input)) < 1_000_000Verification: Run the command with --help flag to verify availability.
See modules/performance-guidelines.md for detailed optimization techniques.
Scope Selection
Choose the right location for your hooks based on audience and purpose.
Important: Auto-Loading Behavior
`hooks/hooks.json` is automatically loaded when a plugin is enabled.
Do NOT add"hooks": "./hooks/hooks.json"toplugin.json- this causes duplicate load errors.
Only use the hooks field for additional hook files beyond the standard location.Decision Framework
**Verification:** Run the command with `--help` flag to verify availability.
Is this hook part of a plugin's core functionality?
├─ YES → Plugin hooks (hooks/hooks.json in plugin)
└─ NO ↓
Should all team members on this project have this hook?
├─ YES → Project hooks (.claude/settings.json)
└─ NO ↓
Should this hook apply to all my Claude sessions?
├─ YES → Global hooks (~/.claude/settings.json)
└─ NO → Reconsider if you need a hook at allVerification: Run the command with --help flag to verify availability.
Scope Comparison
| Scope | Location | Audience | Committed? | Example Use Case |
|---|---|---|---|---|
| Plugin | hooks/hooks.json | Plugin users | Yes (with plugin) | YAML validation in YAML plugin |
| Project | .claude/settings.json | Team members | Yes (in repo) | Block production config edits |
| Global | ~/.claude/settings.json | Only you | Never | Personal audit logging |
See modules/scope-selection.md for detailed scope decision guidance.
Common Patterns
Validation Hook
Block dangerous operations before execution:
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
if tool_name == "Bash":
command = tool_input.get("command", "")
# Block dangerous patterns
if any(pattern in command for pattern in ["rm -rf /", ":(){ :|:& };:"]):
raise ValueError(f"Dangerous command blocked: {command}")
# Block production access
if "production" in command and not self._has_approval():
raise ValueError("Production access requires approval")
return NoneVerification: Run the command with --help flag to verify availability.
Logging Hook
Audit all tool operations:
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
await self._log_entry({
'timestamp': datetime.now().isoformat(),
'tool': tool_name,
'input_size': len(str(tool_input)),
'output_size': len(tool_output),
'success': True
})
return NoneVerification: Run the command with --help flag to verify availability.
Context Injection Hook
Add relevant context before user prompts:
async def on_user_prompt_submit(self, message: str) -> str | None:
# Inject project-specific context
context = await self._load_project_context()
enhanced_message = f"{context}\n\n{message}"
return enhanced_messagePreToolUse Context Injection (Claude Code 2.1.9+)
Inject context before a tool executes using additionalContext:
#!/usr/bin/env python3
"""PreToolUse hook that injects context before WebFetch."""
import json
import sys
def main():
payload = json.load(sys.stdin)
tool_name = payload.get("tool_name", "")
if tool_name == "WebFetch":
url = payload.get("tool_input", {}).get("url", "")
# Check cache or knowledge base
cached = lookup_knowledge_base(url)
if cached:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": f"Relevant cached info: {cached}"
}
}))
sys.exit(0)
if __name__ == "__main__":
main()This pattern is useful for: cache hints before web requests, security warnings before risky operations, and injecting relevant project context before file operations.
Testing Hooks
Unit Testing
import pytest
from my_hooks import ValidationHooks
@pytest.mark.asyncio
async def test_dangerous_command_blocked():
hooks = ValidationHooks()
with pytest.raises(ValueError, match="Dangerous command"):
await hooks.on_pre_tool_use("Bash", {"command": "rm -rf /"})
@pytest.mark.asyncio
async def test_safe_command_allowed():
hooks = ValidationHooks()
result = await hooks.on_pre_tool_use("Bash", {"command": "ls -la"})
assert result is None # Allows executionVerification: Run pytest -v from to verify.
See modules/testing-hooks.md for detailed testing strategies.
Module References
For detailed guidance on specific topics:
- Hook Types:
modules/hook-types.md- Detailed event signatures and parameters - SDK Callbacks:
modules/sdk-callbacks.md- Python SDK implementation patterns - Security Patterns:
modules/testing-hooks.md- detailed security guidance - Performance Guidelines:
modules/performance-guidelines.md- Optimization techniques - Scope Selection:
modules/scope-selection.md- Choosing plugin/project/global - Testing Hooks:
modules/testing-hooks.md- Testing strategies and fixtures - Observability Warnings:
modules/observability-warnings.md- Copy-pasteable resolution pattern for binary-actionable drift hooks
Tools
- hook_validator.py: Validate hook structure and syntax (in
scripts/)
Related Skills
- hook-scope-guide: Decision framework for hook placement (existing)
- modular-skills: Design patterns for skill architecture
- skills-eval: Quality assessment and improvement framework
Next Steps
1. Choose your hook type (JSON vs SDK) based on complexity needs 2. Select the appropriate scope (plugin/project/global) 3. Implement following security and performance best practices 4. Test thoroughly with unit and integration tests 5. Validate using hook_validator.py before deployment
Environment Variables (Claude Code 2.1.2+)
FORCE_AUTOUPDATE_PLUGINS
Forces plugin auto-update even when the main Claude Code auto-updater is disabled.
Use cases:
- CI/CD pipelines that need latest plugin versions
- Development environments testing plugin updates
- Controlled update rollouts in enterprise settings
# Enable forced plugin updates
export FORCE_AUTOUPDATE_PLUGINS=1
claude
# Or inline
FORCE_AUTOUPDATE_PLUGINS=1 claude --agent my-agentNote: This only affects plugin updates, not Claude Code core updates.
References
Hook Exit Codes
Hooks communicate decisions to Claude Code via exit codes:
| Exit Code | Meaning | stdout | stderr |
|---|---|---|---|
| 0 | Success/allow | Shown to Claude as system context | Ignored |
| 2 | Block/deny | Ignored | Shown to user as explanation (2.1.39+ fix) |
| Other | Error | Ignored | Shown to user as error message |
Blocking with Exit Code 2 (2.1.39+)
Use exit code 2 to block an action and display a message to the user:
#!/bin/bash
# Example: Block force pushes with user-facing message
command=$(echo "$1" | jq -r '.tool_input.command // empty')
if echo "$command" | grep -q 'push.*--force'; then
echo "Force push blocked: use --force-with-lease instead" >&2
exit 2
fi
exit 0Important: Before Claude Code 2.1.39, stderr from exit code 2 was silently swallowed (#10964). Users would see a generic "hook error" instead of the custom message. This is now fixed: stderr is properly displayed to the user.
Plugin hooks: Before 2.1.39, plugin-installed hooks had a separate code path that also failed to show stderr for exit code 2 (#10412). Both plugin and project hooks now work correctly.
Troubleshooting
Common Issues
Hook not firing Verify hook pattern matches the event. Check hook logs for errors
Syntax errors Validate JSON/Python syntax before deployment
Permission denied Check hook file permissions and ownership
Hook blocking message not shown (pre-2.1.39) If using exit code 2 to block with a user-facing message and the message isn't appearing, upgrade to Claude Code 2.1.39+. In older versions, use exit 0 with stdout as a workaround.
Hook Types Overview
Claude Code hook lifecycle events and their use cases. Hooks intercept specific moments in the session to inject context, validate actions, or transform outputs.
Quick Reference (Claude Code 2.1.50)
Lifecycle Hooks
- Setup: One-time plugin initialization
- SessionStart: Session initialization, context setup
- SessionEnd: Session cleanup, metrics collection
- Stop: Graceful shutdown, final logging
Tool Execution Hooks
- PreToolUse: Validation, logging, state management
- PostToolUse: Result processing, metrics, cleanup
- PostToolUseFailure: Error handling, fallback (2.1.20+)
Permission Hooks
- PermissionRequest: Auto-approve/deny patterns
Communication Hooks
- UserPromptSubmit: Input validation, routing
- Notification: System notification forwarding (2.1.20+)
Agent Coordination Hooks
- SubagentStart: Track agent spawns (2.1.20+)
- SubagentStop: Collect results, cleanup
- TeammateIdle: Work assignment (2.1.33+).
Supports {"continue": false} to stop teammate (2.1.69+).
- TaskCompleted: Task chaining (2.1.33+).
Supports {"continue": false} to stop teammate (2.1.69+).
Configuration Hooks
- ConfigChange: React to settings changes (2.1.49+)
- InstructionsLoaded: Augment instructions (2.1.33+)
Context Hooks
- PreCompact: Preserve critical context before compaction
Worktree Hooks (2.1.50+, plugin fix 2.1.69+)
- WorktreeCreate: Initialize worktree state.
Command-only (no Python SDK callback, no matchers). Must print absolute worktree path on stdout. Plugin-registered hooks were silently ignored before 2.1.69; now they fire correctly.
- WorktreeRemove: Cleanup worktree state.
Command-only (no Python SDK callback, no matchers). Receives worktree_path in input. Cannot block removal. Plugin-registered hooks were silently ignored before 2.1.69; now they fire correctly.
ExitWorktree Tool (2.1.72+)
New built-in tool to leave an EnterWorktree session mid-conversation. Parameters:
action(required):"keep"(leave worktree on disk)
or "remove" (delete worktree and branch)
discard_changes(optional, default false): required
true when action is "remove" and the worktree has uncommitted files or unmerged commits
Restores the session CWD and clears CWD-dependent caches (system prompt, memory files, plans). Hooks can match on ExitWorktree in PreToolUse/PostToolUse.
Worktree isolation fixes (2.1.72+): Task tool resume now correctly restores CWD in worktree sessions. Background task notifications include worktreePath and worktreeBranch fields.
HTTP Hooks (2.1.63+)
Hooks can POST JSON to a URL instead of running shell commands. Use "type": "http" with a "url" field. The hook POSTs the standard hook input as JSON and expects a standard hook response JSON body. Useful for enterprise/sandboxed environments where shell execution is restricted. See Skill(abstract:hook-authoring) for full configuration details.
Hook Event Fields: agent_id and agent_type (2.1.69+)
All hook events now include an agent_id field for subagent sessions and an agent_type field for both subagent sessions and --agent invocations. Use these fields to distinguish which agent triggered the hook and to implement agent-specific hook logic.
{
"session_id": "sess_abc",
"hook_event_name": "PreToolUse",
"agent_id": "backend@my-team",
"agent_type": "implementer",
"tool_name": "Bash",
"tool_input": { "command": "make test" }
}Status line hooks also gain a worktree field (2.1.69+) containing name, path, branch, and originalRepoDir when running in a --worktree session.
Cron Scheduling Tools (2.1.71+)
Three new built-in tools for scheduled tasks: CronCreate, CronList, and CronDelete. Hooks can match on these tool names in PreToolUse and PostToolUse events. The /loop command uses CronCreate internally.
CronCreate parameters:
cron(string, required): Standard 5-field cron
expression in local timezone (minute hour day-of-month month day-of-week)
prompt(string, required): The prompt to enqueue
at each fire time
recurring(boolean, default true): true = fire on
every cron match until deleted or auto-expired. false = fire once then auto-delete (one-shot reminders)
durable(boolean, default false): true = persist to
.claude/scheduled_tasks.json and survive restarts. false = in-memory only, dies when session ends
CronList: No parameters. Returns all tasks with IDs.
CronDelete: Takes id (string) from CronCreate.
Scheduling behavior:
- Sessions hold up to 50 tasks
- Recurring tasks auto-expire after 7 days
- Tasks fire only while the REPL is idle (not mid-query)
- Jitter: recurring tasks fire up to 10% of their period
late (max 15 min); one-shot tasks at :00/:30 fire up to 90s early. Prefer off-minute scheduling to spread API load.
- Disable entirely with
CLAUDE_CODE_DISABLE_CRON=1.
As of 2.1.72, this also stops scheduled jobs mid-session (previously only prevented new jobs).
Bash Auto-Approval Expansion (2.1.71+)
Added to the default bash auto-approval allowlist: fmt, comm, cmp, numfmt, expr, test, printf, getconf, seq, tsort, and pr. These are standard POSIX text/math utilities that execute without permission prompts. Hooks using PermissionRequest should account for these commands no longer triggering permission events.
Heredoc Permission Fix (2.1.71+)
Compound bash commands containing heredoc commit messages no longer trigger false-positive permission prompts. This fixes the common pattern:
git commit -m "$(cat <<'EOF'
feat: my commit message
EOF
)"Previously this could prompt for permission even when Bash(git commit *) was in the allow list.
Bash Auto-Approval Expansion (2.1.72+)
Added to the auto-approval allowlist: lsof, pgrep, tput, ss, fd, and fdfind. These read-only system inspection and file-finding utilities no longer trigger PermissionRequest events.
Skill Hook Double-Fire Fix (2.1.72+)
Skill hooks no longer fire twice per event when a hooks-enabled skill is invoked by the model. Previously, both the skill's hooks and the plugin's hooks would fire for the same event, producing duplicate log entries and potentially double-counting metrics.
Hooks Fixes (2.1.72+)
transcript_pathnow points to the correct directory
for resumed (--continue) and forked (/fork) sessions. Previously it pointed to the original session's transcript.
- The agent prompt is no longer silently deleted from
settings.json on every settings write.
PostToolUseblock reason no longer displays twice.- Async hooks now receive stdin when using
bash read -r (previously stdin was closed).
CLAUDE.md Comment Hiding (2.1.72+)
HTML comments (<!-- ... -->) in CLAUDE.md files are hidden from Claude when auto-injected into context. Comments remain visible when read with the Read tool. This means CLAUDE.md comments can contain human-only notes without consuming context tokens.
Permission Rule Matching Fixes (2.1.72+)
- Wildcard rules now match commands with heredocs,
embedded newlines, or no arguments
sandbox.excludedCommandsworks with env var
prefixes (e.g., FOO=bar command)
- "Always Allow" no longer suggests overly broad
prefixes for nested CLI tools
- Deny rules apply to all command forms
Parallel Tool Call Cascade Fix (2.1.72+)
Failed Read, WebFetch, or Glob no longer cancels sibling parallel tool calls. Only Bash errors cascade. This improves reliability of parallel agent dispatch and multi-file reading operations.
Security: Workspace Trust (2.1.51+)
Hook commands that emit statusLine or fileSuggestion now require workspace trust acceptance in interactive mode. Untrusted hooks cannot execute these commands until the user has accepted workspace trust. If your hook outputs status line updates or file suggestions, test it in both trusted and untrusted workspace contexts.
Hook Source Display (2.1.75+)
When a hook requires user confirmation, the permission prompt now displays the source of the hook: settings, plugin, or skill. This improves visibility into where permission requests originate. Use this to audit which plugins trigger which permission prompts.
Async Hook Completion Messages Suppressed (2.1.75+)
Async hook completion messages (e.g., "Async hook UserPromptSubmit completed") are now suppressed by default. Previously, every async hook completion generated visible status line noise. Still visible via --verbose flag, transcript mode, or Ctrl+O toggle.
Hooks that relied on visible completion messages for debugging should use --verbose mode. No per-hook silent configuration was added; the default behavior changed globally.
Hook Conditional if Field (2.1.85+)
Hooks now support an if field using permission rule syntax to filter when they run. Only evaluated on tool events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest. On other events, hooks with if never fire.
Reduces process spawning: without if, a hook with matcher: "Bash" spawns a process for every Bash call. With if: "Bash(git *)", the condition is evaluated in-process before any subprocess.
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"if": "Bash(rm -rf *)",
"command": "block-rm.sh",
"timeout": 10
}]
}]
}
}PreToolUse Satisfies AskUserQuestion (2.1.85+)
PreToolUse hooks matching AskUserQuestion can return updatedInput alongside permissionDecision: "allow" to programmatically answer questions for headless integrations:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"question": "Use PostgreSQL for the database"
}
}
}StopFailure Hook (2.1.78+)
Fires when a turn ends due to an API error (rate limit, auth failure, etc.). Non-blockable (output/exit code ignored). Matcher on error type: rate_limit, authentication_failed, billing_error, invalid_request, server_error, max_output_tokens, unknown.
TaskCreated Hook (2.1.84+)
Fires when a task is created via TaskCreate. Blockable (exit code 2 or continue: false). No matcher support (fires on every TaskCreate). Input includes task_id, task_subject, task_description, teammate_name, team_name.
CwdChanged and FileChanged Hooks (2.1.83+)
CwdChanged: Fires on working directory change. Non- blockable, no matcher. Input includes cwd. Has CLAUDE_ENV_FILE access for persisting env vars.
FileChanged: Fires on watched file change. Non- blockable. Matcher on filename (e.g., .envrc). Input includes file_path. Has CLAUDE_ENV_FILE access.
WorktreeCreate HTTP Hook Support (2.1.84+)
HTTP hooks (type: "http") for WorktreeCreate can now return the created worktree path via hookSpecificOutput.worktreePath in the response JSON. Previously only command hooks could return this via stdout.
PreToolUse "allow" Bypass Fix (2.1.77+)
PreToolUse hooks returning {"decision": "allow"} could previously bypass deny permission rules, including enterprise managed settings. A plugin hook could override organization-wide security policies.
Fixed: hook "allow" decisions are now checked after deny rules. The permission precedence order is:
1. Managed deny (highest, unbypassable) 2. Hook deny 3. Permission deny 4. Hook allow 5. Permission allow (lowest)
This is a security-critical fix. Enterprise deny rules in managed settings can no longer be circumvented by third-party plugin hooks.
MCP Elicitation Hooks (2.1.76+)
Two new hook events for MCP elicitation workflows:
Elicitation Hook
Fires when an MCP server sends an elicitation/create request. Blockable: exit code 2 sends decline to the server. Matcher filters on mcp_server_name.
Input includes mcp_server_name, tool_name, and form_schema (the MCP requestedSchema). Supports hookSpecificOutput to auto-approve, auto-decline, or auto-fill responses without showing the dialog:
{
"hookSpecificOutput": {
"hookEventName": "Elicitation",
"action": "accept",
"content": { "env": "staging" }
}
}ElicitationResult Hook
Fires after the user responds to an elicitation, before the response reaches the MCP server. Blockable: exit code 2 converts the action to decline. Matcher filters on mcp_server_name.
Input includes action (accept/decline/cancel) and content (form field values if accept). Supports hookSpecificOutput to override the action and/or content before it reaches the server. Use for validation, transformation, or audit logging of user responses.
PostCompact Hook (2.1.76+)
Fires after context compaction completes (manual /compact or automatic). Non-blockable (compaction already completed). Matcher filters on trigger value ("manual" or "auto").
Input includes trigger and compact_summary (the generated conversation summary). Use for post-compaction recovery: re-injecting framework instructions that were paraphrased during compaction. PreCompact content gets summarized (compliance drops ~95% to ~60-70%); PostCompact content appears fresh and verbatim.
SessionEnd Hooks Timeout Fix (2.1.74+)
SessionEnd hooks were previously killed after 1.5 seconds on exit regardless of the hook.timeout setting. Fixed in 2.1.74: the exit timeout is now configurable via CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS env var. Set this to give SessionEnd hooks enough time to complete (e.g., metrics upload, state persistence, notifications).
Example: allow 10 seconds for SessionEnd hooks:
export CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=10000The default remains 1500ms (1.5 seconds). Per-hook timeout values are capped by this budget: a hook with timeout: 30 is still killed at 1.5s unless the env var is raised. The budget applies to session exit, /clear, and switching sessions via /resume.
SessionEnd matchers: clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other.
New Hook Events (2.1.74+ documentation refresh)
The hooks documentation now includes several additional event types beyond the 19 previously tracked:
- CwdChanged: Fires when the working directory
changes. Observability-only (no decision control).
- FileChanged: Fires when a watched file changes.
Observability-only.
- PostCompact: Fires after context compaction
completes. Complements PreCompact.
- TaskCreated: Fires when a task is created.
Blockable (can prevent task creation).
- StopFailure: Fires on API error (distinct from
Stop, which fires on normal completion). No decision control.
- Elicitation: Fires when an MCP server requests
user input. Blockable.
- ElicitationResult: Fires when the user responds
to an MCP elicitation.
These expand the hook surface for directory tracking, file watch integration, compaction response, and MCP elicitation interception.
SessionStart Resume Double-Fire Fix (2.1.73+)
SessionStart hooks previously fired twice when resuming a session via --resume or --continue. Now they fire exactly once. The source field in the hook input distinguishes session types: "startup", "resume", "clear", or "compact". Hooks that tracked initialization state (counters, one-time setup) no longer need deduplication guards for resumed sessions.
JSON-Output Hooks Fix (2.1.73+)
JSON-output hooks previously injected no-op system-reminder messages into the model's context on every turn, causing token waste. Fixed in 2.1.73: hooks using JSON output format no longer produce spurious context injections. This is particularly relevant for hooks that return additionalContext in their JSON output, as those hooks were most affected by the duplicate injection.
Hook Selection Guide
| Use Case | Recommended Hook | Why |
|---|---|---|
| Log all tool calls | PreToolUse | Captures before execution |
| Track execution time | Pre and PostToolUse | Measure duration |
| Validate inputs | UserPromptSubmit | Before processing |
| Handle tool errors | PostToolUseFailure | Error-specific handling |
| Auto-approve tools | PermissionRequest | Bypass permission dialog |
| Initialize session | SessionStart | One-time setup |
| Cleanup resources | SessionEnd/Stop | Guaranteed cleanup |
| Multi-agent coordination | TeammateIdle/TaskCompleted | Agent team workflows |
| React to config | ConfigChange | Settings-driven behavior |
See Complete Guide
The complete hook types guide includes:
- Detailed lifecycle diagrams
- Complete code examples for each hook type
- Advanced patterns and combinations
- Performance considerations
- Migration guides
See Skill(abstract:hook-authoring) for the full hook development guide and examples.
Observability Warning Patterns
When a hook surfaces actionable drift (state the user can resolve or keep), emit the exact shell command that resolves it. Listing paths or describing the resolution in prose forces the next agent to compose the command, adding latency, error surface, and one more reason to ignore the warning.
Reference implementation
plugins/sanctum/hooks/brainstorm_session_warn.py (locked by test_warning_includes_batch_rm_command in plugins/sanctum/tests/test_brainstorm_session_warn.py) emits:
````
.superpowers/brainstorm/abc.superpowers/brainstorm/def
To remove all listed sessions in one go, run:
rm -rf .superpowers/brainstorm/abc .superpowers/brainstorm/def````
Uses shlex.quote per path so session ids with spaces or shell metacharacters are handled safely.
Three-category classification
When designing an observability hook, classify it into one of three categories. Only one of them takes the copy-pasteable resolution pattern.
Apply pattern: binary-actionable
Hook surfaces drift with a clear resolve-or-keep choice. The user inspects the list, decides "yes, clean this up" or "no, keep it", and a single command resolves the entire batch.
| Hook | Notes |
|---|---|
sanctum/hooks/brainstorm_session_warn.py | Reference implementation |
The pattern is narrow. After the original audit, no other hook in the codebase emitted a multi-item drift warning with a single resolve-or-keep decision. Future binary-actionable hooks should adopt the pattern from authorship.
Skip pattern: observe-only
Hook records a signal for retrospective analysis; no immediate action expected.
| Hook | Reason |
|---|---|
abstract/hooks/skill_execution_logger.py | Logs to JSON for daily aggregation; no action expected per invocation |
abstract/hooks/aggregate_learnings_daily.py | Daily batch run; output is a report, not a prompt |
abstract/hooks/post_learnings_stop.py | Writes session summary; no per-hook action |
Skip pattern: needs-triage
Resolution requires per-item triage (review N items, classify each, choose disposition). A single resolve-all command would hide the per-item judgment the hook surfaced.
| Hook | Reason |
|---|---|
leyline/hooks/fetch-recent-discussions.sh | Lists discussions; users decide which to read or skip |
conserve/hooks/context_warning.py | Suggests one of several actions (clear, compact, summarize) based on context state |
leyline/hooks/supply_chain_check.py | Lists dependency advisories; resolution depends on each one |
Counter-examples (when NOT to apply)
The pattern actively harms when:
- Resolution requires inspection of each item (lists of
discussions, dependencies, lint warnings)
- Resolution is destructive and the user has not yet decided
whether to keep
- The list contains paths the user explicitly chose to keep
(which would imply the hook should not surface them at all)
Authoring checklist for binary-actionable hooks
When adding a new binary-actionable observability hook:
1. Emit a fenced block listing the items 2. Follow with a single resolve-all command 3. Use shlex.quote per path to handle metacharacters 4. Add a contract test mirroring test_warning_includes_batch_rm_command 5. If the hook is in a category that would normally skip the pattern, add a counter-example test confirming the pattern was deliberately not adopted (regression guard)
References
- Issue #460 (origin)
- Discussion #447 (retrospective on PR #417)
- Reference implementation:
plugins/sanctum/hooks/brainstorm_session_warn.py
- Reference test:
plugins/sanctum/tests/test_brainstorm_session_warn.py
Performance Guidelines for Hooks
Optimization techniques for writing fast, efficient hooks that don't degrade agent performance.
Performance Principles
Core Performance Rules
1. Non-Blocking: Use async/await, never block the event loop 2. Fast Validation: < 1s for PreToolUse hooks 3. Async I/O: Use async file/network operations 4. Batch Operations: Queue and batch writes 5. Memory Efficient: Don't accumulate unbounded state 6. Fail Fast: Early returns, quick validation
Performance Budgets
Hook Timing Targets
| Hook Type | Target | Maximum | Rationale |
|---|---|---|---|
| PreToolUse | < 100ms | 1s | Blocks tool execution |
| PostToolUse | < 500ms | 5s | Blocks output processing |
| UserPromptSubmit | < 200ms | 2s | Blocks message processing |
| Stop | < 2s | 10s | Final cleanup, less critical |
| SubagentStop | < 1s | 5s | May have multiple instances |
| TeammateIdle | < 1s | 5s | Agent teams coordination |
| TaskCompleted | < 1s | 5s | Task completion handling |
| PreCompact | < 1s | 3s | Blocks context compaction |
Measuring Hook Performance
import time
from claude_agent_sdk import AgentHooks
class PerformanceMonitoringHooks(AgentHooks):
"""Monitor hook execution time."""
def __init__(self):
self._hook_timings = []
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Measure validation time."""
start = time.perf_counter()
try:
result = await self._validate(tool_input)
return result
finally:
duration_ms = (time.perf_counter() - start) * 1000
self._hook_timings.append({
'hook': 'pre_tool_use',
'tool': tool_name,
'duration_ms': duration_ms
})
if duration_ms > 100: # Warn if over target
print(f"[WARN] Slow hook: {tool_name} validation took {duration_ms:.2f}ms")
async def on_stop(self, reason: str, result: Any) -> None:
"""Report hook performance."""
if self._hook_timings:
avg_time = sum(t['duration_ms'] for t in self._hook_timings) / len(self._hook_timings)
max_time = max(t['duration_ms'] for t in self._hook_timings)
print(f"\nHook Performance:")
print(f" Average: {avg_time:.2f}ms")
print(f" Maximum: {max_time:.2f}ms")
print(f" Total calls: {len(self._hook_timings)}")Non-Blocking Operations
Async I/O
Always use async I/O for file and network operations:
import asyncio
import aiofiles
from claude_agent_sdk import AgentHooks
class AsyncIOHooks(AgentHooks):
"""Use async I/O for performance."""
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Log asynchronously without blocking."""
# BLOCKING (slow)
# with open('log.txt', 'a') as f:
# f.write(f"{tool_name}\n")
# NON-BLOCKING (fast)
async with aiofiles.open('log.txt', 'a') as f:
await f.write(f"{tool_name}\n")
return None
async def _fetch_config(self) -> dict:
"""Async HTTP request."""
import aiohttp
# BLOCKING
# import requests
# return requests.get('http://api.example.com/config').json()
# NON-BLOCKING
async with aiohttp.ClientSession() as session:
async with session.get('http://api.example.com/config') as resp:
return await resp.json()Background Tasks
Use background tasks for non-critical operations:
import asyncio
from claude_agent_sdk import AgentHooks
class BackgroundTaskHooks(AgentHooks):
"""Offload work to background tasks."""
def __init__(self):
self._log_queue = asyncio.Queue()
self._background_task = None
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Queue log entry without blocking."""
# Add to queue (fast, non-blocking)
await self._log_queue.put({
'tool': tool_name,
'timestamp': time.time(),
'output_size': len(tool_output)
})
# Start background writer if not running
if self._background_task is None or self._background_task.done():
self._background_task = asyncio.create_task(self._write_logs())
return None
async def _write_logs(self) -> None:
"""Background task to write logs."""
while not self._log_queue.empty():
try:
entry = await asyncio.wait_for(self._log_queue.get(), timeout=1.0)
# Write to file (in background)
async with aiofiles.open('audit.log', 'a') as f:
await f.write(json.dumps(entry) + '\n')
except asyncio.TimeoutError:
break
async def on_stop(self, reason: str, result: Any) -> None:
"""validate background tasks complete."""
if self._background_task and not self._background_task.done():
await self._background_taskBatch Operations
Batch Writes
Batch multiple writes to reduce I/O overhead:
import asyncio
import aiofiles
from claude_agent_sdk import AgentHooks
class BatchWriteHooks(AgentHooks):
"""Batch writes for efficiency."""
def __init__(self, batch_size: int = 10, flush_interval: float = 5.0):
self._batch: list[dict] = []
self._batch_size = batch_size
self._flush_interval = flush_interval
self._last_flush = time.time()
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Add to batch, flush when full."""
self._batch.append({
'tool': tool_name,
'timestamp': time.time()
})
# Flush if batch is full or time elapsed
should_flush = (
len(self._batch) >= self._batch_size or
time.time() - self._last_flush >= self._flush_interval
)
if should_flush:
await self._flush_batch()
return None
async def _flush_batch(self) -> None:
"""Write entire batch at once."""
if not self._batch:
return
# Write all entries in one operation
async with aiofiles.open('audit.log', 'a') as f:
lines = '\n'.join(json.dumps(entry) for entry in self._batch)
await f.write(lines + '\n')
# Clear batch
self._batch.clear()
self._last_flush = time.time()
async def on_stop(self, reason: str, result: Any) -> None:
"""Flush remaining batch."""
await self._flush_batch()Memory Management
Bounded State
Never accumulate unbounded state:
from collections import deque
from claude_agent_sdk import AgentHooks
class BoundedStateHooks(AgentHooks):
"""Maintain bounded state to prevent memory growth."""
def __init__(self, max_history: int = 1000):
# UNBOUNDED (memory leak)
# self._all_operations = []
# BOUNDED (fixed size)
self._recent_operations = deque(maxlen=max_history)
self._tool_counts = {} # OK - bounded by number of tools
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Track recent operations with bounded memory."""
# Automatically evicts oldest when full
self._recent_operations.append({
'tool': tool_name,
'timestamp': time.time()
})
# Update counts (bounded by tool types)
self._tool_counts[tool_name] = self._tool_counts.get(tool_name, 0) + 1
return NoneCleanup Old State
Periodically clean up old state:
import time
from claude_agent_sdk import AgentHooks
class CleanupHooks(AgentHooks):
"""Periodically clean up old state."""
def __init__(self, max_age_seconds: int = 3600):
self._operations: list[dict] = []
self._max_age = max_age_seconds
self._last_cleanup = time.time()
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Track with periodic cleanup."""
self._operations.append({
'tool': tool_name,
'timestamp': time.time()
})
# Cleanup every 100 operations
if len(self._operations) % 100 == 0:
self._cleanup_old_operations()
return None
def _cleanup_old_operations(self) -> None:
"""Remove operations older than max_age."""
cutoff = time.time() - self._max_age
self._operations = [
op for op in self._operations
if op['timestamp'] > cutoff
]Fast Validation
Early Returns
Return as soon as possible:
from claude_agent_sdk import AgentHooks
class FastValidationHooks(AgentHooks):
"""Optimize validation with early returns."""
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Fast validation with early returns."""
# Quick checks first
if tool_name not in ["Bash", "Edit"]:
return None # No validation needed
# Only validate Bash/Edit
if tool_name == "Bash":
command = tool_input.get("command", "")
# Fast length check
if len(command) > 10_000:
raise ValueError("Command too long")
# Quick pattern check (compiled regex)
if self._dangerous_pattern.search(command):
raise ValueError("Dangerous command")
return None
# Compile regex once at init
def __init__(self):
import re
self._dangerous_pattern = re.compile(
r'rm\s+-rf\s+/|:(){ :|:& };:',
re.IGNORECASE
)Compiled Patterns
Pre-compile expensive operations:
import re
from claude_agent_sdk import AgentHooks
class CompiledPatternHooks(AgentHooks):
"""Use compiled patterns for speed."""
def __init__(self):
# SLOW: Compile every time
# self.pattern_str = r'rm\s+-rf'
# FAST: Compile once
self.dangerous_cmd = re.compile(r'rm\s+-rf\s+/', re.IGNORECASE)
self.secret_api_key = re.compile(r'(api[_-]?key["\s:=]+)([^\s,}]+)', re.IGNORECASE)
self.secret_token = re.compile(r'(token["\s:=]+)([^\s,}]+)', re.IGNORECASE)
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Fast pattern matching with compiled regex."""
if tool_name == "Bash":
command = tool_input.get("command", "")
# Fast: pattern already compiled
if self.dangerous_cmd.search(command):
raise ValueError("Dangerous command")
return NoneCaching
Expensive Computations
Cache expensive operations:
from functools import lru_cache
from claude_agent_sdk import AgentHooks
class CachingHooks(AgentHooks):
"""Cache expensive computations."""
@lru_cache(maxsize=128)
def _is_safe_path(self, path: str) -> bool:
"""Cache path safety checks."""
from pathlib import Path
try:
resolved = Path(path).resolve()
# Expensive: file system operations
return resolved.is_relative_to(Path.home())
except (OSError, ValueError):
return False
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Use cached path validation."""
if tool_name == "Read":
file_path = tool_input.get("file_path", "")
# Fast: cache hit on repeated paths
if not self._is_safe_path(file_path):
raise ValueError("Unsafe path")
return NoneTTL Cache
Cache with time-to-live:
import time
from typing import Any
from claude_agent_sdk import AgentHooks
class TTLCacheHooks(AgentHooks):
"""Cache with expiration."""
def __init__(self, cache_ttl: float = 300.0): # 5 minutes
self._cache: dict[str, tuple[Any, float]] = {}
self._cache_ttl = cache_ttl
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Use TTL cache for config."""
config = await self._get_config() # Cached
# Use config for validation...
return None
async def _get_config(self) -> dict:
"""Get config with TTL caching."""
cache_key = 'validation_config'
now = time.time()
# Check cache
if cache_key in self._cache:
value, expires = self._cache[cache_key]
if now < expires:
return value # Cache hit
# Cache miss: fetch and cache
config = await self._fetch_config() # Expensive
self._cache[cache_key] = (config, now + self._cache_ttl)
return config
async def _fetch_config(self) -> dict:
"""Expensive config fetch."""
# Simulate slow operation
await asyncio.sleep(0.1)
return {'max_command_length': 10000}Profiling Hooks
Identify Bottlenecks
import cProfile
import pstats
from io import StringIO
from claude_agent_sdk import AgentHooks
class ProfilingHooks(AgentHooks):
"""Profile hook performance."""
def __init__(self, enable_profiling: bool = False):
self.enable_profiling = enable_profiling
self._profiler = cProfile.Profile() if enable_profiling else None
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Profile validation."""
if self.enable_profiling:
self._profiler.enable()
try:
result = await self._validate(tool_input)
return result
finally:
if self.enable_profiling:
self._profiler.disable()
async def on_stop(self, reason: str, result: Any) -> None:
"""Print profiling results."""
if self.enable_profiling:
s = StringIO()
ps = pstats.Stats(self._profiler, stream=s)
ps.sort_stats('cumulative')
ps.print_stats(20) # Top 20 functions
print(s.getvalue())Performance Testing
Benchmark Hooks
import pytest
import time
from my_hooks import ValidationHooks
@pytest.mark.asyncio
async def test_validation_performance():
"""validate validation meets performance budget."""
hooks = ValidationHooks()
# Test 100 validations
start = time.perf_counter()
for _ in range(100):
await hooks.on_pre_tool_use("Bash", {"command": "ls -la"})
duration = time.perf_counter() - start
avg_duration_ms = (duration / 100) * 1000
# Assert meets target (< 100ms per validation)
assert avg_duration_ms < 100, f"Validation too slow: {avg_duration_ms:.2f}ms"
@pytest.mark.asyncio
async def test_logging_performance():
"""validate logging doesn't block."""
hooks = LoggingHooks()
start = time.perf_counter()
await hooks.on_post_tool_use("Bash", {"command": "ls"}, "output")
duration_ms = (time.perf_counter() - start) * 1000
# Logging should return quickly (< 500ms)
assert duration_ms < 500, f"Logging too slow: {duration_ms:.2f}ms"Optimization Checklist
Before deploying hooks, verify:
- [ ] Async I/O: All I/O operations use async
- [ ] Background Tasks: Non-critical work runs in background
- [ ] Batch Operations: Multiple writes batched together
- [ ] Bounded State: No unbounded memory growth
- [ ] Early Returns: Fast paths return immediately
- [ ] Compiled Patterns: Regex patterns pre-compiled
- [ ] Caching: Expensive operations cached
- [ ] Profiled: Bottlenecks identified and optimized
- [ ] Tested: Performance tests verify budgets
- [ ] Monitored: Hook timing logged and tracked
Common Performance Issues
Issue 1: Blocking I/O
# SLOW: Blocking I/O
with open('log.txt', 'a') as f:
f.write(f"{tool_name}\n")
# FAST: Async I/O
async with aiofiles.open('log.txt', 'a') as f:
await f.write(f"{tool_name}\n")Issue 2: Unbounded State
# MEMORY LEAK: Unbounded list
self._all_operations.append(operation)
# BOUNDED: Fixed-size deque
self._recent_operations.append(operation) # maxlen=1000Issue 3: Expensive Validation
# SLOW: Recompile every time
if re.search(r'dangerous', command):
...
# FAST: Compiled once
if self._dangerous_pattern.search(command):
...Issue 4: Synchronous Network
# SLOW: Blocking HTTP
import requests
config = requests.get('http://api.example.com/config').json()
# FAST: Async HTTP
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get('http://api.example.com/config') as resp:
config = await resp.json()Related Modules
- hook-types.md: Event types and timing budgets
- sdk-callbacks.md: Implementation patterns
- security-patterns.md: Security best practices
- testing-hooks.md: Performance testing strategies
Hook Scope Selection Guide
Detailed decision framework for choosing where to place hooks: plugin, project, or global scope.
Important: Auto-Loading Behavior
`hooks/hooks.json` is automatically loaded by Claude Code when the plugin is enabled.
Do NOT add"hooks": "./hooks/hooks.json"to yourplugin.json- this causes duplicate load errors.
Thehooksfield inplugin.jsonis only needed for additional hook files beyond the standardhooks/hooks.json.
Correct (hooks/hooks.json auto-loads):
{
"name": "my-plugin",
"version": "1.0.0",
"license": "MIT"
}Incorrect (causes duplicate hook error):
{
"name": "my-plugin",
"version": "1.0.0",
"license": "MIT",
"hooks": "./hooks/hooks.json"
}The Three Scopes
| Scope | Location | Audience | Version Controlled | Persistence |
|---|---|---|---|---|
| Plugin | <plugin-root>/hooks/hooks.json | Plugin users | Yes (with plugin) | When plugin enabled |
| Project | .claude/settings.json | Team members | Yes (in repo) | Per project |
| Global | ~/.claude/settings.json | Only you | Never | All sessions |
Decision Framework
Three Key Questions
Question 1: Who needs this hook?
├─ Only plugin users → Plugin hooks
├─ All team members on this project → Project hooks
└─ Only me, everywhere → Global hooks
Question 2: Should this be version controlled?
├─ Yes, as part of distributable plugin → Plugin hooks
├─ Yes, shared with team in repo → Project hooks
└─ No, keep private → Global hooks
Question 3: What's the persistence requirement?
├─ Only when my plugin is active → Plugin hooks
├─ Always in this specific project → Project hooks
└─ Always, in every project → Global hooksDecision Flowchart
Is this hook part of a plugin's core functionality?
├─ YES → Plugin hooks (hooks/hooks.json in plugin)
└─ NO ↓
Should all team members on this project have this hook?
├─ YES → Project hooks (.claude/settings.json)
└─ NO ↓
Should this hook apply to all my Claude sessions?
├─ YES → Global hooks (~/.claude/settings.json)
└─ NO → Reconsider if you need a hook at allPlugin Hooks
When to Use
The hook is intrinsic to your plugin's functionality and should automatically activate when users enable your plugin.
Perfect for:
- Validation specific to your plugin's domain (e.g., YAML syntax for YAML plugin)
- Auto-formatting that's part of your plugin's features
- Logging operations specific to plugin functionality
- Integration with plugin-specific tools or services
Location
my-plugin/
├── .claude-plugin/
│ └── plugin.json
├── hooks/
│ └── hooks.json ← Plugin hooks here
└── skills/
└── my-skill/
└── SKILL.mdConfiguration
JSON Format (hooks/hooks.json):
{
"PreToolUse": [
{
"matcher": "Read",
"hooks": [{
"type": "command",
"command": "echo 'Plugin reading: $CLAUDE_TOOL_INPUT' >> ${CLAUDE_PLUGIN_ROOT}/log.txt"
}]
}
]
}Key Features:
- Use
${CLAUDE_PLUGIN_ROOT}for plugin-relative paths - Automatically merges when plugin is enabled
- Deactivates when plugin is disabled
- Distributed with plugin code
Examples
YAML Validation Plugin:
{
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/validate-yaml.sh $CLAUDE_TOOL_INPUT"
}]
}
]
}Note: Use string matchers ("Edit") per Claude Code SDK. Filter by file pattern in hook script.Code Formatter Plugin:
{
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/format-code.py"
}]
}
]
}Project Hooks
When to Use
The hook should apply to all team members working on this specific project.
Perfect for:
- Enforcing team-wide coding conventions
- Protecting project-specific resources (e.g., production configs)
- Requiring tests before commits
- Project-specific security policies
- Team workflow requirements
Location
my-project/
├── .claude/
│ └── settings.json ← Project hooks here
├── .git/
├── src/
└── README.mdConfiguration
JSON Format (.claude/settings.json):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "if [[ \"$CLAUDE_TOOL_INPUT\" == *\"production\"* ]]; then echo 'BLOCKED: Production access requires approval'; exit 1; fi"
}]
}
]
}
}Key Features:
- Committed to version control
- Shared across all team members
- Changes visible in PRs (governance trail)
- Project-specific, not personal
Examples
Block Production Edits:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [{
"type": "command",
"command": "if [[ \"$CLAUDE_TOOL_INPUT\" == */production/* ]]; then echo 'ERROR: Cannot edit production configs without approval'; exit 1; fi"
}]
}
]
}
}Require Tests Before Completion:
{
"hooks": {
"Stop": [
{
"hooks": [{
"type": "command",
"command": ".claude/hooks/check-tests-run.sh"
}]
}
]
}
}Project Convention Enforcement:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [{
"type": "command",
"command": ".claude/hooks/validate-naming-convention.py"
}]
}
]
}
}Global Hooks
When to Use
The hook should apply to all your Claude sessions across all projects.
Perfect for:
- Personal workflow preferences
- Cross-project audit logging
- Organization-wide compliance you want everywhere
- Development environment preferences
- Private rules that shouldn't be shared
Location
~/.claude/
├── settings.json ← Global hooks here
├── audit.log
└── projects/Configuration
JSON Format (~/.claude/settings.json):
{
"hooks": {
"PreToolUse": [
{
"hooks": [{
"type": "command",
"command": "echo \"$(date): $CLAUDE_TOOL_NAME\" >> ~/.claude/audit.log"
}]
}
]
}
}Key Features:
- Never committed to any repo
- Applies to ALL Claude Code sessions
- Personal to your user account
- Survives across projects
Examples
Personal Audit Logging:
{
"hooks": {
"PostToolUse": [
{
"hooks": [{
"type": "command",
"command": "echo \"$(date '+%Y-%m-%d %H:%M:%S') - $CLAUDE_TOOL_NAME\" >> ~/.claude/audit.log"
}]
}
]
}
}Cross-Project Safety:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "~/.claude/hooks/global-safety-check.sh"
}]
}
]
}
}Development Environment Setup:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [{
"type": "command",
"command": "~/.claude/hooks/inject-dev-context.sh"
}]
}
]
}
}Loading Order & Precedence
Hook Execution Priority
Claude Code loads settings in this priority (highest first):
1. Enterprise policies (organization-managed) 2. Command-line arguments (claude --flag) 3. Local project settings (.claude/settings.local.json - not committed) 4. Shared project settings (.claude/settings.json - committed) 5. User settings (~/.claude/settings.json)
Multiple Matching Hooks
When multiple hooks from different scopes match the same event, all matching hooks execute in parallel.
Example:
Event: PreToolUse (Bash)
Global hook: Logs to ~/.claude/audit.log
Project hook: Checks production access
Plugin hook: Validates command syntax
All three execute in parallelScope Comparison Matrix
| Criterion | Plugin | Project | Global |
|---|---|---|---|
| Distribution | With plugin | In repo | Personal only |
| Activation | When plugin enabled | Always in project | Always everywhere |
| Audience | Plugin users | Project team | Individual user |
| Version Control | Plugin repo | Project repo | Never |
| Governance | Plugin maintainer | Team consensus | Personal choice |
| Security Review | Plugin installation | PR review | Self-review |
| Scope | Plugin operations | Project files | All sessions |
Common Patterns by Scope
Plugin Hook Patterns
Validation: Check files match plugin's expected format
{
"PreToolUse": [{
"matcher": "Edit",
"hooks": [{"type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/validate.sh"}]
}]
}Note: Use string matchers. Filter by file pattern inside the hook script.
Auto-completion: Suggest plugin-specific completions
{
"PostToolUse": [{
"matcher": "Read",
"hooks": [{"type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/suggest-completions.py"}]
}]
}Project Hook Patterns
Protection: Block dangerous operations on sensitive paths
{
"PreToolUse": [{
"matcher": "Edit",
"hooks": [{
"type": "command",
"command": "if [[ \"$CLAUDE_TOOL_INPUT\" == */production/* ]]; then exit 1; fi"
}]
}]
}Enforcement: Require tests, linting, or builds
{
"Stop": [{
"hooks": [{
"type": "command",
"command": ".claude/hooks/check-all-tests-passed.sh"
}]
}]
}Global Hook Patterns
Auditing: Log all operations for personal review
{
"PostToolUse": [{
"hooks": [{
"type": "command",
"command": "echo \"$CLAUDE_TOOL_NAME: $CLAUDE_TOOL_INPUT\" >> ~/.claude/audit.log"
}]
}]
}Safety: Universal dangerous command detection
{
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "~/.claude/hooks/safety-check.sh"
}]
}]
}Security Considerations
Plugin Hooks
- Security Review: Audited during plugin installation
- User Consent: Users enable plugin knowing hooks will run
- Scope Limitation: Limited to plugin's stated purpose
- Transparency: Hooks visible in plugin source
Project Hooks
- Team Visibility: All team members see hook definitions
- PR Review: Changes reviewed in pull requests
- Consensus: Should reflect team agreement
- Documentation: Document purpose in repo
Global Hooks
- Personal Risk: Execute with your credentials everywhere
- Unexpected Effects: Can affect all projects
- Review Carefully: Test thoroughly before adding
- Keep Private: Never commit to any repo
Migration Patterns
Plugin → Project
When a plugin hook should become project-specific:
# Copy from plugin
cp plugins/my-plugin/hooks/hooks.json .claude/hooks/
# Customize for project
# Edit .claude/hooks/hooks.json to remove plugin-specific logicProject → Global
When a project hook should apply to all your projects:
# Extract to global hooks
cat .claude/settings.json | jq '.hooks' >> ~/.claude/settings.json
# Remove from project
# Remove hook from .claude/settings.jsonGlobal → Plugin
When your personal hook would benefit all plugin users:
# Create plugin hooks directory
mkdir -p plugins/my-plugin/hooks/
# Move hook to plugin
# Copy hook logic to plugins/my-plugin/hooks/hooks.json
# Update paths to use ${CLAUDE_PLUGIN_ROOT}Troubleshooting Scope Issues
Hook Not Executing
Check scope activation:
# Verify plugin is enabled
claude plugins list
# Check project hooks exist
cat .claude/settings.json
# Verify global hooks exist
cat ~/.claude/settings.jsonHook Executing Unexpectedly
Check all scopes:
# Check which hooks are active
claude hooks list
# Disable plugin temporarily
claude plugins disable my-plugin
# Remove project hook
# Edit .claude/settings.jsonConflicting Hooks
Identify source:
# Show all active hooks
claude hooks list --verbose
# Check precedence order
# Global → Project → PluginBest Practices
Plugin Hooks
1. Use plugin-relative paths (${CLAUDE_PLUGIN_ROOT}) 2. Document hooks in plugin README 3. Make hooks optional when possible 4. Test with plugin enabled/disabled
Project Hooks
1. Document purpose in README or HOOKS.md 2. Review in PRs like other code changes 3. Keep focused on project-specific needs 4. Avoid personal preferences
Global Hooks
1. Test thoroughly before deploying 2. Document in personal notes 3. Review periodically for relevance 4. Consider security implications
Related Modules
- hook-types.md: Event types and signatures
- sdk-callbacks.md: Python SDK implementation
- security-patterns.md: Security best practices
- testing-hooks.md: Testing strategies per scope
SDK Callbacks and Implementation Patterns
Complete guide to implementing Claude Agent SDK hooks with Python, including patterns, best practices, and production examples.
AgentHooks Base Class
The AgentHooks class from claude_agent_sdk provides the foundation for all SDK hooks:
from claude_agent_sdk import AgentHooks
class MyHooks(AgentHooks):
"""Custom hooks for agent lifecycle events."""
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Hook before tool execution."""
pass
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Hook after tool execution."""
pass
async def on_post_tool_use_failure(
self, tool_name: str, tool_input: dict, error: str
) -> str | None:
"""Hook when tool execution fails (2.1.20+)."""
pass
async def on_user_prompt_submit(self, message: str) -> str | None:
"""Hook when user submits a message."""
pass
async def on_stop(self, reason: str, result: Any) -> None:
"""Hook when agent stops."""
pass
async def on_subagent_start(self, subagent_id: str, task: Any) -> None:
"""Hook when subagent spawns (2.1.20+)."""
pass
async def on_subagent_stop(self, subagent_id: str, result: Any) -> None:
"""Hook when subagent completes."""
pass
async def on_permission_request(
self, tool_name: str, tool_input: dict
) -> dict | None:
"""Hook when permission dialog would appear."""
pass
async def on_teammate_idle(self, teammate_id: str) -> None:
"""Hook when teammate agent becomes idle (2.1.33+)."""
pass
async def on_task_completed(self, task_id: str, result: Any) -> None:
"""Hook when task finishes execution (2.1.33+)."""
pass
async def on_pre_compact(self, context_size: int) -> dict | None:
"""Hook before context compaction."""
passAll callbacks are optional - implement only the hooks you need.
Implementation Patterns
Pattern 1: Validation Hook
Block operations that violate security policies:
from typing import Any
from claude_agent_sdk import AgentHooks
class ValidationHooks(AgentHooks):
"""Validate tool inputs against security policies."""
def __init__(self, config: dict[str, Any] | None = None):
self.config = config or {}
self.blocked_patterns = self.config.get('blocked_patterns', [
r'rm\s+-rf\s+/',
r':(){ :|:& };:', # Fork bomb
r'dd\s+if=/dev/zero',
])
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Validate tool inputs before execution."""
if tool_name == "Bash":
command = tool_input.get("command", "")
# Check for dangerous patterns
import re
for pattern in self.blocked_patterns:
if re.search(pattern, command):
raise ValueError(
f"Command blocked by security policy: pattern '{pattern}' matched"
)
# Check for production access
if "production" in command.lower() and not self._has_production_approval():
raise ValueError("Production access requires approval")
elif tool_name == "Edit":
file_path = tool_input.get("file_path", "")
# Block edits to sensitive files
sensitive_paths = ["/etc/", "/sys/", "/production/"]
if any(sensitive in file_path for sensitive in sensitive_paths):
raise ValueError(f"Cannot edit protected path: {file_path}")
return None # Allow operation
def _has_production_approval(self) -> bool:
"""Check if production access is approved."""
# Implementation: check environment, file, or API
import os
return os.getenv("PRODUCTION_APPROVED") == "true"Pattern 2: Logging Hook
detailed audit logging with sanitization:
import asyncio
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
from claude_agent_sdk import AgentHooks
class LoggingHooks(AgentHooks):
"""Audit logging for all tool operations."""
# Patterns that might contain secrets
SECRET_PATTERNS = [
r'(api[_-]?key["\s:=]+)([^\s,}]+)',
r'(password["\s:=]+)([^\s,}]+)',
r'(token["\s:=]+)([^\s,}]+)',
r'(secret["\s:=]+)([^\s,}]+)',
r'(auth["\s:=]+)([^\s,}]+)',
]
def __init__(self, log_file: Path | None = None):
self.log_file = log_file or Path.home() / ".claude" / "audit.log"
self._log_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
self._log_task: asyncio.Task[None] | None = None
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Log tool use initiation."""
await self._queue_log({
'event': 'pre_tool_use',
'tool': tool_name,
'input_size': len(str(tool_input)),
'timestamp': datetime.now().isoformat()
})
return None
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Log tool completion with sanitized output."""
safe_output = self._sanitize_secrets(tool_output)
await self._queue_log({
'event': 'post_tool_use',
'tool': tool_name,
'input_size': len(str(tool_input)),
'output_size': len(tool_output),
'output_preview': safe_output[:200],
'timestamp': datetime.now().isoformat()
})
return None
async def on_stop(self, reason: str, result: Any) -> None:
"""Log session completion."""
await self._queue_log({
'event': 'stop',
'reason': reason,
'timestamp': datetime.now().isoformat()
})
# validate all logs are written before exit
await self._flush_logs()
def _sanitize_secrets(self, text: str) -> str:
"""Remove potential secrets from text."""
for pattern in self.SECRET_PATTERNS:
text = re.sub(pattern, r'\1***REDACTED***', text, flags=re.IGNORECASE)
return text
async def _queue_log(self, entry: dict[str, Any]) -> None:
"""Add log entry to queue for async writing."""
await self._log_queue.put(entry)
# Start background writer if not running
if self._log_task is None or self._log_task.done():
self._log_task = asyncio.create_task(self._write_logs())
async def _write_logs(self) -> None:
"""Background task to write logs asynchronously."""
while not self._log_queue.empty():
try:
entry = await asyncio.wait_for(self._log_queue.get(), timeout=1.0)
# Append to log file
async with asyncio.Lock():
with open(self.log_file, 'a') as f:
f.write(json.dumps(entry) + '\n')
except asyncio.TimeoutError:
break
async def _flush_logs(self) -> None:
"""Wait for all queued logs to be written."""
if self._log_task and not self._log_task.done():
await self._log_taskPattern 3: Transformation Hook
Modify tool inputs or outputs:
from claude_agent_sdk import AgentHooks
class TransformationHooks(AgentHooks):
"""Transform tool inputs and outputs."""
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Transform tool inputs before execution."""
if tool_name == "Read":
# Normalize file paths
file_path = tool_input.get("file_path", "")
normalized = self._normalize_path(file_path)
if normalized != file_path:
# Return modified input
return {**tool_input, "file_path": normalized}
return None # No transformation needed
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Transform tool outputs after execution."""
if tool_name == "Bash" and "ls" in tool_input.get("command", ""):
# Add metadata to ls output
enhanced = f"Directory listing:\n{tool_output}\n\nTotal items: {len(tool_output.splitlines())}"
return enhanced
return None # No transformation
def _normalize_path(self, path: str) -> str:
"""Normalize file path to absolute path."""
from pathlib import Path
return str(Path(path).resolve())Pattern 4: Metrics Collection Hook
Track performance and usage metrics:
import time
from collections import defaultdict
from typing import Any
from claude_agent_sdk import AgentHooks
class MetricsHooks(AgentHooks):
"""Collect performance and usage metrics."""
def __init__(self):
self._tool_counts: defaultdict[str, int] = defaultdict(int)
self._tool_durations: dict[str, list[float]] = defaultdict(list)
self._start_times: dict[str, float] = {}
self._session_start = time.time()
self._tool_instance_counter = 0
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Record tool invocation start time."""
self._tool_instance_counter += 1
instance_id = f"{tool_name}_{self._tool_instance_counter}"
self._start_times[instance_id] = time.time()
self._tool_counts[tool_name] += 1
return None
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Record tool execution duration."""
# Find most recent instance of this tool
instance_id = f"{tool_name}_{self._tool_counts[tool_name]}"
if instance_id in self._start_times:
duration = time.time() - self._start_times[instance_id]
self._tool_durations[tool_name].append(duration)
del self._start_times[instance_id]
return None
async def on_stop(self, reason: str, result: Any) -> None:
"""Generate and display metrics summary."""
session_duration = time.time() - self._session_start
print("\n=== Session Metrics ===")
print(f"Total Duration: {session_duration:.2f}s")
print(f"Tools Used: {sum(self._tool_counts.values())}")
print(f"\nTool Breakdown:")
for tool, count in sorted(self._tool_counts.items()):
durations = self._tool_durations[tool]
avg_duration = sum(durations) / len(durations) if durations else 0
print(f" {tool}: {count} calls, avg {avg_duration:.3f}s")
# Save metrics to file
await self._save_metrics({
'session_duration': session_duration,
'tool_counts': dict(self._tool_counts),
'tool_durations': {k: sum(v) for k, v in self._tool_durations.items()},
'stop_reason': reason
})
async def _save_metrics(self, metrics: dict[str, Any]) -> None:
"""Save metrics to JSON file."""
import json
from pathlib import Path
from datetime import datetime
metrics_file = Path.home() / ".claude" / "metrics" / f"{datetime.now().isoformat()}.json"
metrics_file.parent.mkdir(exist_ok=True)
with open(metrics_file, 'w') as f:
json.dump(metrics, f, indent=2)Pattern 5: Context Injection Hook
Enhance user prompts with additional context:
from pathlib import Path
from claude_agent_sdk import AgentHooks
class ContextInjectionHooks(AgentHooks):
"""Inject project context into user prompts."""
def __init__(self, project_root: Path):
self.project_root = project_root
async def on_user_prompt_submit(self, message: str) -> str | None:
"""Inject relevant project context."""
# Detect if user is asking about code/files
code_keywords = ['file', 'function', 'class', 'code', 'implement', 'edit', 'change']
if any(kw in message.lower() for kw in code_keywords):
context = await self._load_project_context()
enhanced = f"{context}\n\nUser Request: {message}"
return enhanced
return None
async def _load_project_context(self) -> str:
"""Load relevant project context."""
context_parts = []
# Add README if exists
readme = self.project_root / "README.md"
if readme.exists():
content = readme.read_text()
context_parts.append(f"Project Overview:\n{content[:500]}...")
# Add coding conventions if exists
conventions = self.project_root / "CONVENTIONS.md"
if conventions.exists():
content = conventions.read_text()
context_parts.append(f"Coding Conventions:\n{content}")
# Add architecture notes if exists
architecture = self.project_root / "ARCHITECTURE.md"
if architecture.exists():
content = architecture.read_text()
context_parts.append(f"Architecture:\n{content[:300]}...")
return "\n\n".join(context_parts) if context_parts else ""State Management
Session State
Maintain state across hook invocations within a session:
from claude_agent_sdk import AgentHooks
class StatefulHooks(AgentHooks):
"""Maintain state across hook invocations."""
def __init__(self):
self._session_state = {
'tools_used': [],
'errors': [],
'warnings': []
}
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Track tool usage."""
self._session_state['tools_used'].append(tool_name)
return None
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Detect errors in output."""
if "error" in tool_output.lower():
self._session_state['errors'].append({
'tool': tool_name,
'output': tool_output[:200]
})
return None
async def on_stop(self, reason: str, result: Any) -> None:
"""Report session state."""
print(f"\nSession Summary:")
print(f"Tools used: {', '.join(set(self._session_state['tools_used']))}")
print(f"Errors encountered: {len(self._session_state['errors'])}")Persistent State
Save state across sessions:
import json
from pathlib import Path
from claude_agent_sdk import AgentHooks
class PersistentHooks(AgentHooks):
"""Maintain state across sessions."""
def __init__(self, state_file: Path | None = None):
self.state_file = state_file or Path.home() / ".claude" / "hook_state.json"
self._state = self._load_state()
def _load_state(self) -> dict:
"""Load state from file."""
if self.state_file.exists():
return json.loads(self.state_file.read_text())
return {'session_count': 0, 'total_tools': 0}
def _save_state(self) -> None:
"""Save state to file."""
self.state_file.parent.mkdir(exist_ok=True)
self.state_file.write_text(json.dumps(self._state, indent=2))
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Increment tool counter."""
self._state['total_tools'] += 1
self._save_state()
return None
async def on_stop(self, reason: str, result: Any) -> None:
"""Increment session counter."""
self._state['session_count'] += 1
self._save_state()
print(f"Session #{self._state['session_count']}, Total tools: {self._state['total_tools']}")Error Handling
Graceful Degradation
import logging
from claude_agent_sdk import AgentHooks
logger = logging.getLogger(__name__)
class ResilientHooks(AgentHooks):
"""Handle errors gracefully without blocking agent."""
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Validate with graceful error handling."""
try:
if not self._is_valid_input(tool_input):
raise ValueError("Invalid input")
except Exception as e:
logger.error(f"Validation error (non-blocking): {e}")
# Don't block operation on validation errors
return None
return None
async def on_post_tool_use(
self, tool_name: str, tool_input: dict, tool_output: str
) -> str | None:
"""Log with error handling."""
try:
await self._log_operation(tool_name, tool_output)
except Exception as e:
logger.error(f"Logging failed: {e}")
# Don't block on logging failures
return None
def _is_valid_input(self, tool_input: dict) -> bool:
"""Validate tool input."""
# Validation logic
return TrueTimeout Handling
import asyncio
from claude_agent_sdk import AgentHooks
class TimeoutHooks(AgentHooks):
"""Apply timeouts to hook operations."""
HOOK_TIMEOUT = 5.0 # seconds
async def on_pre_tool_use(self, tool_name: str, tool_input: dict) -> dict | None:
"""Validation with timeout."""
try:
result = await asyncio.wait_for(
self._validate_input(tool_input),
timeout=self.HOOK_TIMEOUT
)
return result
except asyncio.TimeoutError:
logger.warning(f"Validation timeout for {tool_name}")
return None # Allow on timeout
async def _validate_input(self, tool_input: dict) -> dict | None:
"""Async validation logic."""
# Potentially slow validation
await asyncio.sleep(0.1)
return NoneTesting SDK Hooks
Unit Tests
import pytest
from my_hooks import ValidationHooks, LoggingHooks
@pytest.mark.asyncio
async def test_validation_blocks_dangerous_command():
hooks = ValidationHooks()
with pytest.raises(ValueError, match="blocked by security policy"):
await hooks.on_pre_tool_use("Bash", {"command": "rm -rf /"})
@pytest.mark.asyncio
async def test_validation_allows_safe_command():
hooks = ValidationHooks()
result = await hooks.on_pre_tool_use("Bash", {"command": "ls -la"})
assert result is None
@pytest.mark.asyncio
async def test_logging_sanitizes_secrets():
import tempfile
from pathlib import Path
with tempfile.NamedTemporaryFile(delete=False) as f:
log_file = Path(f.name)
hooks = LoggingHooks(log_file)
await hooks.on_post_tool_use(
"Bash",
{"command": "echo"},
"api_key=secret123"
)
await hooks._flush_logs()
log_content = log_file.read_text()
assert "secret123" not in log_content
assert "REDACTED" in log_content
log_file.unlink()Integration Tests
import pytest
from claude_agent_sdk import Agent
from my_hooks import MyHooks
@pytest.mark.asyncio
async def test_hooks_integration():
hooks = MyHooks()
agent = Agent(hooks=hooks)
# Execute agent with hooks
result = await agent.run("List files in current directory")
# Verify hooks were called
assert len(hooks._log_entries) > 0
assert any(entry['tool'] == 'Bash' for entry in hooks._log_entries)Related Modules
- hook-types.md: Event signatures and parameters
- security-patterns.md: Security best practices
- performance-guidelines.md: Optimization techniques
- testing-hooks.md: detailed testing strategies
Testing Hooks
Testing strategies for hook development. Covers unit testing hook logic, mocking external dependencies, and CI/CD integration.
Testing Philosophy
Core Testing Principles
1. Test Hook Logic, Not Tools: Test your hook's behavior, not Claude's tools 2. Mock External Dependencies: Isolate hook logic from I/O and network 3. Test All Paths: Happy path, error cases, edge cases 4. Verify Performance: Test timing budgets and resource usage 5. Security Testing: Test security controls and sanitization
Test Categories
| Category | Purpose | Budget |
|---|---|---|
| Unit | Hook method logic | < 100ms |
| Integration | Hook chains, mock agent | < 500ms |
| Security | Secret sanitization, injection prevention | < 200ms |
| Performance | Timing budgets, memory bounds | Varies |
Quick Reference
Unit Test Pattern
@pytest.mark.asyncio
async def test_hook_behavior():
hooks = MyHooks()
result = await hooks.on_pre_tool_use("Bash", {"command": "ls"})
assert result is None # or expected modificationReturn Value Testing
| Hook | Return None | Return Modified |
|---|---|---|
| PreToolUse | Allow unchanged | Modified input dict |
| PostToolUse | Allow unchanged | Modified output string |
| Stop | Block with error | N/A |
Error Handling Pattern
@pytest.mark.asyncio
async def test_graceful_failure():
hooks = ResilientHooks()
# Even with invalid input, should return None (allow)
result = await hooks.on_pre_tool_use("Bash", {"invalid": "input"})
assert result is NoneSecurity Test Checklist
- [ ] API keys redacted from logs
- [ ] Passwords sanitized in output
- [ ] Path traversal blocked
- [ ] Command injection prevented
- [ ] Allowed operations permitted
Performance Targets
| Operation | P50 | P95 | Max |
|---|---|---|---|
| PreToolUse validation | < 50ms | < 100ms | < 200ms |
| PostToolUse logging | < 100ms | < 500ms | < 1s |
| Memory per 10K ops | < 10MB | < 20MB | < 50MB |
Test Fixtures
Essential Fixtures
@pytest.fixture
def temp_log_file():
with tempfile.NamedTemporaryFile(suffix='.log') as f:
yield Path(f.name)
@pytest.fixture
def mock_file_system():
mock_fs = MagicMock()
mock_fs.exists.return_value = True
return mock_fsTesting Checklist
Before deploying hooks, verify:
- [ ] Unit Tests: All hook methods tested
- [ ] Happy Path: Normal operations work
- [ ] Error Cases: Errors handled gracefully
- [ ] Edge Cases: Boundary conditions tested
- [ ] Security: Secret sanitization verified
- [ ] Performance: Timing budgets met
- [ ] Memory: No memory leaks
- [ ] Integration: Works with mock agent
- [ ] Coverage: > 90% line coverage
Detailed Examples
For more test examples including:
- Full unit test suites
- Integration test patterns
- Security test cases
- Performance benchmarks
- CI/CD configuration
See Skill(abstract:hook-authoring) for full hook development patterns including test examples.
Related Modules
- hook-types.md: Event types to test
- sdk-callbacks.md: Implementation patterns to test
- performance-guidelines.md: Performance targets to verify
Hook Authoring Scripts
Utilities for validating and testing Claude Code and SDK hooks.
hook_validator.py
Validates hook files for syntax, structure, and compliance with hook specifications.
Features
- JSON Hook Validation: Validates
hooks.jsonfiles for Claude Code - JSON syntax validation
- Required field checking
- Known event type verification
- Hook action validation
- Python SDK Hook Validation: Validates Python files containing
AgentHookssubclasses - Python syntax validation
AgentHooksinheritance checking- Callback method signature verification
- Async definition validation
Usage
# Make executable (first time only)
chmod +x hook_validator.py
# Validate JSON hook file
./hook_validator.py hooks/hooks.json
# Validate Python SDK hook file
./hook_validator.py my_hooks.py
# Specify type explicitly
./hook_validator.py hooks.json --type json
./hook_validator.py my_hooks.py --type python
# Verbose output (show info messages)
./hook_validator.py hooks.json --verboseExit Codes
0: Success, no issues found1: Warnings found (valid but with recommendations)2: Errors found (invalid)
Example Output
Valid JSON hook:
OK Valid
Info:
[INFO] Loaded JSON from hooks/hooks.json
[INFO] Validated 2 event type(s)Invalid Python hook:
FAIL Invalid
Errors:
FAIL MyHooks.on_pre_tool_use: should be async (async def)
FAIL MyHooks.on_post_tool_use: incorrect arguments. Expected ['self', 'tool_name', 'tool_input', 'tool_output'], got ['self', 'tool', 'output']JSON Hook Validation
Checks for:
- Valid JSON syntax
- Known event types (
PreToolUse,PostToolUse, etc.) - Required fields (
hooksarray) - Hook action structure (
type,command) - Matcher format: String regex patterns (e.g.,
"Bash","Read|Write") - Object format
{"toolName": "Bash"}is deprecated and will generate warnings
Python SDK Hook Validation
Checks for:
- Valid Python syntax
AgentHooksbase class inheritance- Async callback methods (
async def) - Correct callback signatures:
on_pre_tool_use(self, tool_name, tool_input) -> dict | Noneon_post_tool_use(self, tool_name, tool_input, tool_output) -> str | Noneon_user_prompt_submit(self, message) -> str | Noneon_stop(self, reason, result) -> Noneon_subagent_stop(self, subagent_id, result) -> Noneon_pre_compact(self, context_size) -> dict | None
Integration with CI/CD
Pre-commit Hook
Add to .git/hooks/pre-commit:
#!/bin/bash
echo "Validating hooks..."
# Find all hook files
json_hooks=$(find . -name "hooks.json" -not -path "*/node_modules/*" -not -path "*/.git/*")
python_hooks=$(find . -name "*_hooks.py" -not -path "*/tests/*" -not -path "*/.git/*")
# Validate JSON hooks
for hook in $json_hooks; do
if ! ./scripts/hook_validator.py "$hook"; then
echo "Hook validation failed: $hook"
exit 1
fi
done
# Validate Python hooks
for hook in $python_hooks; do
if ! ./scripts/hook_validator.py "$hook"; then
echo "Hook validation failed: $hook"
exit 1
fi
done
echo "OK All hooks validated"GitHub Actions
Add to .github/workflows/validate-hooks.yml:
name: Validate Hooks
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Validate JSON hooks
run: |
find . -name "hooks.json" \
-not -path "*/node_modules/*" -not -path "*/.venv/*" \
-not -path "*/__pycache__/*" -not -path "*/.git/*" | while read hook; do
python scripts/hook_validator.py "$hook" --verbose
done
- name: Validate Python hooks
run: |
find . -name "*_hooks.py" -not -path "*/tests/*" \
-not -path "*/.venv/*" -not -path "*/__pycache__/*" \
-not -path "*/node_modules/*" -not -path "*/.git/*" | while read hook; do
python scripts/hook_validator.py "$hook" --verbose
doneTesting
Test the validator itself:
# Test with valid JSON hook (string matcher format)
echo '{
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "echo test"}]
}]
}' > test_hooks.json
./hook_validator.py test_hooks.json
# Should exit with 0
# Test with invalid JSON hook
echo '{"invalid": "structure"}' > test_invalid.json
./hook_validator.py test_invalid.json
# Should exit with 1 or 2
# Clean up
rm test_hooks.json test_invalid.jsonDependencies
- Python 3.11+
- Standard library only (no external dependencies)
Related Files
- SKILL.md: Main hook authoring guide
- modules/hook-types.md: Hook event specifications
- modules/sdk-callbacks.md: Python SDK patterns
- modules/security-patterns.md: Security validation guidelines
- modules/testing-hooks.md: detailed testing strategies
Related skills
How it compares
Authoring reference for Claude Code hook events—not a generic Git pre-commit hook tutorial or an MCP server integration.
FAQ
Who is hook-authoring for?
agent developers extending Claude Code with plugins who need accurate hook names, phases, and version notes while coding handlers.
When should I use hook-authoring?
In Build (agent-tooling) while designing session guards and metrics; in Ship (review) when auditing what your hooks auto-approve; in Operate (iterate) when adjusting TeammateIdle or TaskCompleted chaining after production friction.
Is hook-authoring safe to install?
The skill is documentation for powerful interception points—review the Security Audits panel on this page and treat PermissionRequest auto-approve patterns as high-risk until you test them.