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 EventWhen It FiresCan Block?
SessionStartSession begins or resumesNo
SessionEndSession terminatesNo
UserPromptSubmitBefore Claude processes user inputNo
PreToolUseBefore tool executionYes
PostToolUseAfter tool completionNo
PermissionRequestWhen permission dialogs appearYes
StopMain agent finishes respondingNo*
SubagentStopSubagent tasks completeNo*
PreCompactBefore context compactionNo
NotificationSystem notifications sentNo

*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 - true if 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 launch
  • resume - Via --resume, --continue, or /resume
  • clear - Via /clear command
  • compact - After manual or auto compaction

Important: Compaction Behavior

When source="compact":

  • The hook fires AFTER compaction is complete
  • The transcript_path points 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 /compact command
  • auto - Automatic compaction due to context limits

Important: Compaction Workflow

  1. PreCompact fires - Last chance to access full transcript before summarization
  2. Compaction occurs - Claude Code creates internal summary
  3. 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)

  1. ~/.claude/settings.json - User-global
  2. .claude/settings.json - Project-specific
  3. .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:

PatternMatches
"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 CodeBehavior
0Success, continue normally
2Blocking error - halt execution
OtherShow 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 EventOutput Shown
PreToolUse, PostToolUse, Stop, SubagentStopVerbose mode (Ctrl+O)
Notification, SessionEndDebug only (—debug)
UserPromptSubmit, SessionStartAdded 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 stdin
input=$(cat)
# Extract session ID
session_id=$(echo "$input" | jq -r '.session_id')
transcript=$(echo "$input" | jq -r '.transcript_path')
# Save session state
echo "$input" | your-memory-system save --session "$session_id"
exit 0

Tool 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 0
fi
exit 0

Logging 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 0

Context 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 session
context=$(your-context-system get --session "$session_id")
# Output is added to Claude's context
echo "$context"
exit 0

Debugging Hooks

Enable hook debugging

Terminal window
claude -p "test" --debug hooks

Multiple debug categories

Terminal window
claude -p "test" --debug "api,hooks"

Test hook script manually

Terminal window
echo '{"session_id":"test","hook_event_name":"Stop"}' | ./your-hook.sh

Common Patterns

Prevent Infinite Loops (Stop Hook)

#!/bin/bash
input=$(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 0
fi
# Your stop hook logic here

Conditional Blocking

#!/bin/bash
input=$(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
fi
fi
exit 0

Sources