hooks
Overview
Claude Code hooks are an event-driven automation system that allows bash commands or LLM prompts to execute at specific lifecycle points during Claude Code sessions. Hooks enable workflow automation, security validation, session tracking, and integration with external systems.
All Available Hook Events
Claude Code supports 10 distinct hook events:
| Hook Event | When It Fires | Can Block? |
|---|---|---|
| SessionStart | Session begins or resumes | No |
| SessionEnd | Session terminates | No |
| UserPromptSubmit | Before Claude processes user input | No |
| PreToolUse | Before tool execution | Yes |
| PostToolUse | After tool completion | No |
| PermissionRequest | When permission dialogs appear | Yes |
| Stop | Main agent finishes responding | No* |
| SubagentStop | Subagent tasks complete | No* |
| PreCompact | Before context compaction | No |
| Notification | System notifications sent | No |
*Can halt execution via exit code 2
Session ID Availability
YES - session_id is available in ALL hooks.
Every hook receives a common JSON payload via stdin that includes:
{ "session_id": "abc123-def456-...", "transcript_path": "~/.claude/projects/.../session.jsonl", "cwd": "/current/working/directory", "permission_mode": "default|plan|acceptEdits|bypassPermissions", "hook_event_name": "HookTypeName"}Stop Hook Payload (Confirmed)
The Stop hook specifically receives:
{ "session_id": "abc123-def456-ghi789", "transcript_path": "~/.claude/projects/.../session-id.jsonl", "cwd": "/path/to/project", "permission_mode": "default", "hook_event_name": "Stop", "stop_hook_active": false}Key fields:
session_id- Unique session identifier (UUID format)stop_hook_active-trueif Claude is continuing due to a previous stop hook (prevents infinite loops)
Hook Payload Reference
SessionStart
{ "session_id": "abc123", "transcript_path": "~/.claude/...", "cwd": "/path/to/project", "permission_mode": "default", "hook_event_name": "SessionStart", "source": "startup|resume|clear|compact"}Source values:
startup- Initial session launchresume- Via--resume,--continue, or/resumeclear- Via/clearcommandcompact- After manual or auto compaction
Important: Compaction Behavior
When source="compact":
- The hook fires AFTER compaction is complete
- The
transcript_pathpoints to the new session file, not the original - The compacted summary is NOT accessible in the hook payload
- The summary is embedded internally in Claude’s context window
- Use this hook to reload external context (git status, project files, etc.)
If you need to preserve data before compaction, use the PreCompact hook instead.
SessionEnd
{ "session_id": "abc123", "transcript_path": "~/.claude/...", "cwd": "/path/to/project", "permission_mode": "default", "hook_event_name": "SessionEnd", "reason": "clear|logout|prompt_input_exit|other"}UserPromptSubmit
{ "session_id": "abc123", "transcript_path": "~/.claude/...", "cwd": "/path/to/project", "permission_mode": "default", "hook_event_name": "UserPromptSubmit", "prompt": "user's input text"}PreToolUse
{ "session_id": "abc123", "transcript_path": "~/.claude/...", "cwd": "/path/to/project", "permission_mode": "default", "hook_event_name": "PreToolUse", "tool_name": "Write", "tool_input": { "file_path": "/path/to/file.ts", "content": "file content..." }, "tool_use_id": "unique-id"}PostToolUse
{ "session_id": "abc123", "transcript_path": "~/.claude/...", "cwd": "/path/to/project", "permission_mode": "default", "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": { "command": "npm test" }, "tool_response": { "stderr": "", "stdout": "test results...", "exit_code": 0 }, "tool_use_id": "unique-id"}PermissionRequest
{ "session_id": "abc123", "transcript_path": "~/.claude/...", "cwd": "/path/to/project", "permission_mode": "default", "hook_event_name": "PermissionRequest", "tool_name": "Bash", "tool_input": { /* tool parameters */ }, "tool_use_id": "unique-id"}PreCompact
{ "session_id": "abc123", "transcript_path": "~/.claude/...", "cwd": "/path/to/project", "permission_mode": "default", "hook_event_name": "PreCompact", "trigger": "manual|auto", "custom_instructions": "system instructions..."}Trigger values:
manual- User ran/compactcommandauto- Automatic compaction due to context limits
Important: Compaction Workflow
- PreCompact fires - Last chance to access full transcript before summarization
- Compaction occurs - Claude Code creates internal summary
- New session starts - SessionStart fires with
source="compact"
Note: There is NO PostCompact hook. If you need to preserve context, back it up in PreCompact. The transcript_path in PreCompact points to the ORIGINAL transcript with full conversation history.
Notification
{ "session_id": "abc123", "transcript_path": "~/.claude/...", "cwd": "/path/to/project", "permission_mode": "default", "hook_event_name": "Notification", "notification_type": "permission_prompt|idle_prompt|auth_success|elicitation_dialog"}Hook Configuration
Configuration Locations (Priority Order)
~/.claude/settings.json- User-global.claude/settings.json- Project-specific.claude/settings.local.json- Local, not committed
Configuration Structure
{ "hooks": { "HookEventName": [ { "matcher": "ToolNamePattern", "hooks": [ { "type": "command", "command": "bash-command-here" } ] } ] }}Hook Types
Command hooks execute bash scripts:
{ "type": "command", "command": "~/bin/my-hook-script.sh"}Prompt hooks use LLM evaluation:
{ "type": "prompt", "prompt": "Evaluate this tool use and respond with allow or deny..."}Matchers
Matchers filter which tools trigger hooks. They apply ONLY to:
- PreToolUse
- PostToolUse
- PermissionRequest
Matcher patterns:
| Pattern | Matches |
|---|---|
"Bash" | Exact match - only Bash tool |
"Edit|Write" | Regex - Edit OR Write tools |
"*" | Wildcard - all tools |
"" | Empty - matches all (same as *) |
Case-sensitive: "bash" won’t match "Bash"
Complete Configuration Example
{ "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "~/bin/init-session.sh" } ] } ], "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "~/bin/inject-context.sh" } ] } ], "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "~/bin/validate-bash-command.sh" } ] }, { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "~/bin/check-file-permissions.rb" } ] } ], "PostToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "~/bin/log-bash-execution.sh" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "~/bin/save-session-state.sh" } ] } ], "SessionEnd": [ { "hooks": [ { "type": "command", "command": "~/bin/cleanup-session.sh" } ] } ] }}Hook Execution
Exit Codes
| Exit Code | Behavior |
|---|---|
0 | Success, continue normally |
2 | Blocking error - halt execution |
| Other | Show stderr to user only |
JSON Output Control
Hooks can return JSON to stdout (when exit code 0):
{ "continue": false, "stopReason": "Blocked due to policy violation", "suppressOutput": false, "systemMessage": "Optional warning to show user"}For PreToolUse/PermissionRequest:
{ "permissionDecision": "deny", "decision": "block", "modified_input": { /* modified tool parameters */ }}For PostToolUse:
{ "decision": "block", "feedback": "Tool output indicates an error"}Input Modification (v2.0.10+)
PreToolUse hooks can modify tool inputs before execution:
{ "modified_input": { "file_path": "/corrected/path/file.ts", "content": "corrected content" }}Modifications are invisible to Claude - the tool runs with modified parameters.
Environment Variables
Available in all hooks:
CLAUDE_PROJECT_DIR- Absolute path to project root
Available in SessionStart:
CLAUDE_ENV_FILE- File path for persisting environment variables
Execution Characteristics
- Timeout: 60 seconds per hook (configurable)
- Parallelization: All matching hooks run in parallel
- Deduplication: Identical hook commands are deduplicated
- Idempotency: Hooks must handle concurrent execution
Output Visibility
| Hook Event | Output Shown |
|---|---|
| PreToolUse, PostToolUse, Stop, SubagentStop | Verbose mode (Ctrl+O) |
| Notification, SessionEnd | Debug only (—debug) |
| UserPromptSubmit, SessionStart | Added as context for Claude |
Practical Examples
Session Memory Integration
Track sessions for memory systems:
#!/bin/bash# save-session.sh - Stop hook for session tracking
# Read JSON from stdininput=$(cat)
# Extract session IDsession_id=$(echo "$input" | jq -r '.session_id')transcript=$(echo "$input" | jq -r '.transcript_path')
# Save session stateecho "$input" | your-memory-system save --session "$session_id"
exit 0Tool Validation
Block dangerous commands:
#!/bin/bash# validate-bash.sh - PreToolUse hook for Bash
input=$(cat)command=$(echo "$input" | jq -r '.tool_input.command')
# Block rm -rf /if [[ "$command" == *"rm -rf /"* ]]; then echo '{"continue": false, "stopReason": "Dangerous command blocked"}' exit 0fi
exit 0Logging and Observability
Log all tool executions:
#!/bin/bash# log-tool.sh - PostToolUse hook
input=$(cat)tool=$(echo "$input" | jq -r '.tool_name')session=$(echo "$input" | jq -r '.session_id')exit_code=$(echo "$input" | jq -r '.tool_response.exit_code // empty')
echo "[$(date -Iseconds)] session=$session tool=$tool exit=$exit_code" >> ~/.claude/tool.log
exit 0Context Injection
Inject context before prompts:
#!/bin/bash# inject-context.sh - UserPromptSubmit hook
input=$(cat)session_id=$(echo "$input" | jq -r '.session_id')
# Fetch relevant context for this sessioncontext=$(your-context-system get --session "$session_id")
# Output is added to Claude's contextecho "$context"exit 0Debugging Hooks
Enable hook debugging
claude -p "test" --debug hooksMultiple debug categories
claude -p "test" --debug "api,hooks"Test hook script manually
echo '{"session_id":"test","hook_event_name":"Stop"}' | ./your-hook.shCommon Patterns
Prevent Infinite Loops (Stop Hook)
#!/bin/bashinput=$(cat)stop_active=$(echo "$input" | jq -r '.stop_hook_active')
if [ "$stop_active" = "true" ]; then # Already continuing from a stop hook - don't trigger again exit 0fi
# Your stop hook logic hereConditional Blocking
#!/bin/bashinput=$(cat)tool=$(echo "$input" | jq -r '.tool_name')
if [ "$tool" = "Write" ]; then file=$(echo "$input" | jq -r '.tool_input.file_path')
if [[ "$file" == *.env* ]]; then echo '{"permissionDecision": "deny", "stopReason": "Cannot modify .env files"}' exit 0 fifi
exit 0