Skip to content
Code Guide

7. Hooks

What are Hooks: Scripts that run automatically on events (like git hooks)

Event types:

  • PreToolUse → Before Claude runs a tool (e.g., block dangerous commands)
  • PostToolUse → After Claude runs a tool (e.g., auto-format code)
  • UserPromptSubmit → When you send a message (e.g., inject context)

Common use cases:

  • 🛡️ Security: Block file deletions, prevent secrets in commits
  • 🎨 Quality: Auto-format, lint, run tests
  • 📊 Logging: Track commands, audit changes

Quick Start: See 7.3 Hook Templates for copy-paste examples

Read this section if: You want automation or need safety guardrails Skip if: Manual control is sufficient for your workflow


Reading time: 20 minutes Skill level: Week 2-3 Goal: Automate Claude Code with event-driven scripts

Hooks are scripts that run automatically when specific events occur.

EventWhen It FiresCan Block?Use Case
SessionStartSession begins or resumesNoInitialization, load dev context
UserPromptSubmitUser submits prompt, before Claude processes itYesContext enrichment, prompt validation
PreToolUseBefore a tool call executesYesSecurity validation, input modification
PermissionRequestPermission dialog appearsYesCustom approval logic
PostToolUseAfter a tool completes successfullyNoFormatting, logging
PostToolUseFailureAfter a tool call failsNoError logging, recovery actions
NotificationClaude sends notificationNoSound alerts, custom notifications
SubagentStartSub-agent spawnedNoSubagent initialization
SubagentStopSub-agent finishesYesSubagent cleanup
StopClaude finishes respondingYesPost-response actions, continue loops
TeammateIdleAgent team member about to go idleYesTeam coordination, quality gates
TaskCompletedTask being marked as completedYesEnforce completion criteria
ConfigChangeConfig file changes during sessionYes (except policy)Enterprise audit, block unauthorized changes
WorktreeCreateWorktree being createdYes (non-zero exit)Custom VCS setup
WorktreeRemoveWorktree being removedNoClean up VCS state
PreCompactBefore context compactionNoSave state before compaction
SessionEndSession terminatesNoCleanup, logging

Stop and SubagentStoplast_assistant_message field (v2.1.47+): These events now include a last_assistant_message field in their JSON input, giving direct access to Claude’s final response without parsing transcript files. Useful for orchestration pipelines that need to inspect or log the last output.

Terminal window
# In your Stop hook script
LAST_MSG=$(cat | jq -r '.last_assistant_message // ""')
echo "$LAST_MSG" >> ~/.claude/logs/session-outputs.log
┌─────────────────────────────────────────────────────────┐
│ EVENT FLOW │
├─────────────────────────────────────────────────────────┤
│ │
│ User types message │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ UserPromptSubmit │ ← Add context (git status) │
│ └────────────────────┘ │
│ │ │
│ ▼ │
│ Claude decides to run tool (e.g., Edit) │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ PreToolUse │ ← Security check │
│ └────────────────────┘ │
│ │ │
│ ▼ (if allowed) │
│ Tool executes │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ PostToolUse │ ← Auto-format │
│ └────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘

Claude Code supports two execution models for hooks:

  • Claude blocks until the hook completes
  • Exit code and stdout available immediately for feedback
  • Use case: Critical validation (security, type checking, blocking operations)
  • Configuration: Omit async or set async: false
  • Claude continues immediately, hook runs in background
  • Exit code/stdout NOT available to Claude (no feedback loop)
  • Use case: Non-critical operations (logging, notifications, formatting, metrics)
  • Configuration: Add async: true to hook definition
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/auto-format.sh",
"timeout": 10000,
"async": true // ← Non-blocking execution
},
{
"type": "command",
"command": ".claude/hooks/typecheck.sh"
// Sync by default - blocks on completion
}
]
}
]
}
}
Hook PurposeExecution ModeReason
Code formatting (Prettier, Black)AsyncCosmetic change, no feedback needed
Linting with auto-fix (eslint —fix)AsyncNon-critical improvements
Type checking (tsc, mypy)SyncErrors must block for iteration
Security validationSyncMust block dangerous operations
Logging/metricsAsyncPure side-effect, no feedback
Notifications (Slack, email)AsyncUser alerts, non-blocking
Test executionSyncResults influence next action
Git context injectionSyncEnriches prompt before processing

Example session (10 file edits):

  • Sync hooks: auto-format.sh (500ms) × 10 = 5s blocked
  • Async hooks: auto-format.sh runs in background = 0s blocked
  • Gain: ~5-10s per typical development session

⚠️ Async hooks cannot:

  • Block Claude on errors (exit code 2 ignored)
  • Provide real-time feedback via stdout or systemMessage
  • Guarantee execution order with other hooks
  • Return additionalContext that Claude can use

Use async only when the hook’s completion is truly independent of Claude’s workflow.

  • v2.1.0: Initial async hook support (configuration via async: true)
  • v2.1.23: Fixed bug where async hooks weren’t properly cancelled when headless streaming sessions ended

Shell Scripts vs AI Agents: When to Use What

Section titled “Shell Scripts vs AI Agents: When to Use What”

Not everything needs AI. Choose the right tool:

Task TypeBest ToolWhyExample
DeterministicBash scriptFast, predictable, no tokensCreate branch, fetch PR comments
Pattern-basedBash + regexReliable for known patternsCheck for secrets, validate format
Interpretation neededAI AgentJudgment requiredCode review, architecture decisions
Context-dependentAI AgentNeeds understanding”Does this match requirements?”

Rule of thumb: If you can write a regex or a simple conditional for it, use a bash script. If it requires “understanding” or “judgment”, use an agent.

Example — PR workflow:

Terminal window
# Deterministic (bash): create branch, push, open PR
git checkout -b feature/xyz
git push -u origin feature/xyz
gh pr create --title "..." --body "..."
# Interpretation (agent): review code quality
# → Use code-review subagent

Why this matters: Bash scripts are instant, free (no tokens), and 100% predictable. Reserve AI for tasks that genuinely need intelligence.

Inspired by Nick Tune’s Coding Agent Development Workflows

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/security-check.sh",
"timeout": 5000
}
]
}
]
}
}
FieldDescription
matcherRegex pattern filtering when hooks fire (tool name, session start reason, etc.)
typeHook type: "command", "http", "prompt", or "agent"
commandShell command to run (for command type)
promptPrompt text for LLM evaluation (for prompt/agent types). Use $ARGUMENTS as placeholder for hook input JSON
timeoutMax execution time in seconds (default: 600s command, 30s prompt, 60s agent)
modelModel to use for evaluation (for prompt/agent types). Defaults to a fast model
asyncIf true, runs in background without blocking (for command type only)
statusMessageCustom spinner message displayed while hook runs
onceIf true, runs only once per session then is removed (skills only)

Hook types:

  • command: Runs a shell command. Receives JSON on stdin, returns JSON on stdout. Most common type.
  • http (v2.1.63+): POSTs JSON to a URL and reads JSON response. Useful for CI/CD webhooks and stateless backend integrations without shell dependencies. Configure with url and optional allowedEnvVars for header interpolation.
  • prompt: Sends prompt + hook input to a Claude model (Haiku by default) for single-turn evaluation. Returns {ok: true/false, reason: "..."}. Configure model via model field.
  • agent: Spawns a subagent with tool access (Read, Grep, Glob, etc.) for multi-turn verification. Returns same {ok: true/false} format. Up to 50 tool-use turns.

HTTP hook example (v2.1.63+):

{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "http",
"url": "https://ci.example.com/webhook/claude-hook",
"allowedEnvVars": ["CI_TOKEN"]
}
]
}
]
}
}

HTTP hooks receive the same JSON payload as command hooks and must return valid JSON. The allowedEnvVars field lists environment variables that can be referenced in headers (e.g., for Bearer token authentication).

Hooks receive JSON on stdin with common fields (all events) plus event-specific fields:

{
"session_id": "abc123",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "git status"
}
}

Common fields (all events): session_id, transcript_path, cwd, permission_mode, hook_event_name. Event-specific fields (like tool_name and tool_input for PreToolUse) are added on top.

Hooks communicate results through exit codes and optional JSON on stdout. Choose one approach per hook: either exit codes alone, or exit 0 with JSON for structured control (Claude Code only processes JSON on exit 0).

Universal JSON fields (all events):

FieldDefaultDescription
continuetrueIf false, Claude stops processing entirely
stopReasonnoneMessage shown to user when continue is false
suppressOutputfalseIf true, hides stdout from verbose mode
systemMessagenoneWarning message shown to user

Event-specific decision control varies by event type:

  • PreToolUse: Uses hookSpecificOutput with permissionDecision (allow/deny/ask), permissionDecisionReason, updatedInput, additionalContext
  • PostToolUse, Stop, SubagentStop, UserPromptSubmit, ConfigChange: Uses top-level decision: "block" with reason
  • TeammateIdle, TaskCompleted: Exit code 2 only (no JSON decision control)
  • PermissionRequest: Uses hookSpecificOutput with decision.behavior (allow/deny)

PreToolUse blocking example (preferred over exit code 2):

{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}

PreToolUse context injection:

{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": "Current git branch: feature/auth. 3 uncommitted files."
}
}
CodeMeaningResult
0SuccessAllow operation, parse stdout for JSON output
2Blocking errorPrevent operation (for blocking events), stderr fed to Claude
OtherNon-blocking errorStderr shown in verbose mode (Ctrl+O), execution continues
.claude/hooks/security-blocker.sh
#!/bin/bash
# Blocks dangerous commands
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# List of dangerous patterns
DANGEROUS_PATTERNS=(
"rm -rf /"
"rm -rf ~"
"rm -rf *"
"sudo rm"
"git push --force origin main"
"git push -f origin main"
"npm publish"
"> /dev/sda"
)
# Check if command matches any dangerous pattern
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if [[ "$COMMAND" == *"$pattern"* ]]; then
echo "BLOCKED: Dangerous command detected: $pattern" >&2
exit 2
fi
done
exit 0
.claude/hooks/auto-format.sh
#!/bin/bash
# Auto-formats code after edits
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Only run for Edit/Write operations
if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then
exit 0
fi
# Get the file path
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
# Skip if no file path
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
# Run Prettier on supported files
if [[ "$FILE_PATH" =~ \.(ts|tsx|js|jsx|json|md|css|scss)$ ]]; then
npx prettier --write "$FILE_PATH" 2>/dev/null
fi
exit 0

Template 3: UserPromptSubmit (Context Enricher)

Section titled “Template 3: UserPromptSubmit (Context Enricher)”
.claude/hooks/git-context.sh
#!/bin/bash
# Adds git context to every prompt
# Get git information
BRANCH=$(git branch --show-current 2>/dev/null || echo "not a git repo")
LAST_COMMIT=$(git log -1 --format='%h %s' 2>/dev/null || echo "no commits")
STAGED=$(git diff --cached --stat 2>/dev/null | tail -1 || echo "none")
UNSTAGED=$(git diff --stat 2>/dev/null | tail -1 || echo "none")
# Output JSON with context
cat << EOF
{
"hookSpecificOutput": {
"additionalContext": "[Git] Branch: $BRANCH | Last: $LAST_COMMIT | Staged: $STAGED | Unstaged: $UNSTAGED"
}
}
EOF
exit 0
.claude/hooks/notification.sh
#!/bin/bash
# Plays sounds on notifications (macOS)
INPUT=$(cat)
TITLE=$(echo "$INPUT" | jq -r '.title // ""')
MESSAGE=$(echo "$INPUT" | jq -r '.message // ""')
TYPE=$(echo "$INPUT" | jq -r '.notification_type // ""')
# Determine sound based on content
if [[ "$TITLE" == *"error"* ]] || [[ "$MESSAGE" == *"failed"* ]]; then
SOUND="/System/Library/Sounds/Basso.aiff"
elif [[ "$TITLE" == *"complete"* ]] || [[ "$MESSAGE" == *"success"* ]]; then
SOUND="/System/Library/Sounds/Hero.aiff"
else
SOUND="/System/Library/Sounds/Pop.aiff"
fi
# Play sound (macOS)
afplay "$SOUND" 2>/dev/null &
exit 0

Windows users can create hooks using PowerShell (.ps1) or batch files (.cmd).

Note: Windows hooks should use the full PowerShell invocation with -ExecutionPolicy Bypass to avoid execution policy restrictions.

Template W1: PreToolUse Security Check (PowerShell)

Section titled “Template W1: PreToolUse Security Check (PowerShell)”

Create .claude/hooks/security-check.ps1:

security-check.ps1
# Blocks dangerous commands
$inputJson = [Console]::In.ReadToEnd() | ConvertFrom-Json
$command = $inputJson.tool_input.command
# List of dangerous patterns
$dangerousPatterns = @(
"rm -rf /",
"rm -rf ~",
"Remove-Item -Recurse -Force C:\",
"git push --force origin main",
"git push -f origin main",
"npm publish"
)
foreach ($pattern in $dangerousPatterns) {
if ($command -like "*$pattern*") {
Write-Error "BLOCKED: Dangerous command detected: $pattern"
exit 2
}
}
exit 0

Template W2: PostToolUse Auto-Formatter (PowerShell)

Section titled “Template W2: PostToolUse Auto-Formatter (PowerShell)”

Create .claude/hooks/auto-format.ps1:

auto-format.ps1
# Auto-formats code after edits
$inputJson = [Console]::In.ReadToEnd() | ConvertFrom-Json
$toolName = $inputJson.tool_name
if ($toolName -ne "Edit" -and $toolName -ne "Write") {
exit 0
}
$filePath = $inputJson.tool_input.file_path
if (-not $filePath) {
exit 0
}
if ($filePath -match '\.(ts|tsx|js|jsx|json|md|css|scss)$') {
npx prettier --write $filePath 2>$null
}
exit 0

Template W3: Context Enricher (Batch File)

Section titled “Template W3: Context Enricher (Batch File)”

Create .claude/hooks/git-context.cmd:

Terminal window
@echo off
setlocal enabledelayedexpansion
for /f "tokens=*" %%i in ('git branch --show-current 2^>nul') do set BRANCH=%%i
if "%BRANCH%"=="" set BRANCH=not a git repo
for /f "tokens=*" %%i in ('git log -1 --format^="%%h %%s" 2^>nul') do set LAST_COMMIT=%%i
if "%LAST_COMMIT%"=="" set LAST_COMMIT=no commits
echo {"hookSpecificOutput":{"additionalContext":"[Git] Branch: %BRANCH% | Last: %LAST_COMMIT%"}}
exit /b 0

Create .claude/hooks/notification.ps1:

notification.ps1
# Shows Windows toast notifications and plays sounds
$inputJson = [Console]::In.ReadToEnd() | ConvertFrom-Json
$title = $inputJson.title
$message = $inputJson.message
# Determine sound based on content
if ($title -match "error" -or $message -match "failed") {
[System.Media.SystemSounds]::Hand.Play()
} elseif ($title -match "complete" -or $message -match "success") {
[System.Media.SystemSounds]::Asterisk.Play()
} else {
[System.Media.SystemSounds]::Beep.Play()
}
# Optional: Show Windows Toast Notification (requires BurntToast module)
# Install-Module -Name BurntToast
# New-BurntToastNotification -Text $title, $body
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": "powershell -ExecutionPolicy Bypass -File .claude/hooks/security-check.ps1",
"timeout": 5000
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "powershell -ExecutionPolicy Bypass -File .claude/hooks/auto-format.ps1",
"timeout": 10000
}
]
}
]
}
}

Security hooks are critical for protecting your system.

Advanced patterns: For comprehensive security including Unicode injection detection, MCP config integrity verification, and CVE-specific mitigations, see Security Hardening Guide.

Claude Code Security (research preview): Anthropic offers a dedicated codebase vulnerability scanner that traces data flows across files, challenges findings internally before surfacing them (adversarial validation), and generates patch suggestions. Separate from the Security Auditor Agent above — waitlist access only. See Security Hardening Guide → Claude Code as Security Scanner.

Validated at scale: In a March 2026 partnership with Mozilla, Claude Opus 4.6 scanned ~6,000 C++ files in Firefox’s JS engine in two weeks, surfacing 22 confirmed vulnerabilities (14 high severity) — roughly one fifth of all high-severity Firefox CVEs fixed in 2025. Demonstrates the model’s practical depth for production security work, well beyond surface-level linting.

.claude/hooks/comprehensive-security.sh
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# === CRITICAL BLOCKS (Exit 2) ===
# Filesystem destruction
[[ "$COMMAND" =~ rm.*-rf.*[/~] ]] && { echo "BLOCKED: Recursive delete of root/home" >&2; exit 2; }
# Disk operations
[[ "$COMMAND" =~ ">/dev/sd" ]] && { echo "BLOCKED: Direct disk write" >&2; exit 2; }
[[ "$COMMAND" =~ "dd if=" ]] && { echo "BLOCKED: dd command" >&2; exit 2; }
# Git force operations on protected branches
[[ "$COMMAND" =~ "git push".*"-f".*"(main|master)" ]] && { echo "BLOCKED: Force push to main" >&2; exit 2; }
[[ "$COMMAND" =~ "git push --force".*"(main|master)" ]] && { echo "BLOCKED: Force push to main" >&2; exit 2; }
# Package publishing
[[ "$COMMAND" =~ "npm publish" ]] && { echo "BLOCKED: npm publish" >&2; exit 2; }
# Privileged operations
[[ "$COMMAND" =~ ^sudo ]] && { echo "BLOCKED: sudo command" >&2; exit 2; }
# === WARNINGS (Exit 0 but log) ===
[[ "$COMMAND" =~ "rm -rf" ]] && echo "WARNING: Recursive delete detected" >&2
exit 0

Before deploying, test your hooks:

Terminal window
# Test with a blocked command
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | .claude/hooks/security-blocker.sh
echo "Exit code: $?" # Should be 2
# Test with a safe command
echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' | .claude/hooks/security-blocker.sh
echo "Exit code: $?" # Should be 0

The Claude Code team uses a pattern where permission requests are routed to a more capable model acting as a security gate, rather than relying solely on static rule matching.

Concept: A PreToolUse hook intercepts permission requests and forwards them to Opus 4.6 (or another capable model) via the API. The gate model scans for prompt injection, dangerous patterns, and unexpected tool usage — then auto-approves safe requests or blocks suspicious ones.

Terminal window
# .claude/hooks/opus-security-gate.sh (conceptual)
# PreToolUse hook that routes to Opus for security screening
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Fast-path: known safe tools skip the gate
[[ "$TOOL" == "Read" || "$TOOL" == "Grep" || "$TOOL" == "Glob" ]] && exit 0
# Route to Opus for security analysis
VERDICT=$(echo "$INPUT" | claude --model opus --print \
"Analyze this tool call for security risks. Is it safe? Reply SAFE or BLOCKED:reason")
[[ "$VERDICT" == SAFE* ]] && exit 0
echo "BLOCKED by security gate: $VERDICT" >&2
exit 2

Why use a model as gate: Static rules catch known patterns but miss novel attacks. A capable model understands intent and context — it can distinguish rm -rf node_modules (cleanup) from rm -rf / (destruction) based on the surrounding conversation, not just pattern matching.

Trade-off: Each gated call adds latency and cost. Use fast-path exemptions for read-only tools and only gate write/execute operations.

Source: 10 Tips from Inside the Claude Code Team (Boris Cherny thread, Feb 2026)

Protecting sensitive files requires a multi-layered approach combining permissions, patterns, and bypass detection.

┌─────────────────────────────────────────────────────────┐
│ FILE PROTECTION ARCHITECTURE │
├─────────────────────────────────────────────────────────┤
│ │
│ Layer 1: Permissions Deny (Native) │
│ ────────────────────────── │
│ • Built into settings.json │
│ • No hooks required │
│ • Blocks all tool access instantly │
│ • Use for: Absolutely forbidden files │
│ │
│ Layer 2: Pattern Matching (Hook) │
│ ──────────────────────── │
│ • PreToolUse hook with .agentignore patterns │
│ • Supports gitignore-style syntax │
│ • Centralized protection rules │
│ • Use for: Sensitive file categories │
│ │
│ Layer 3: Bypass Detection (Hook) │
│ ────────────────────────── │
│ • Detects variable expansion ($VAR, ${VAR}) │
│ • Detects command substitution $(cmd), `cmd` │
│ • Prevents path manipulation attempts │
│ • Use for: Defense against sophisticated attacks │
│ │
└─────────────────────────────────────────────────────────┘
{
"permissions": {
"deny": [
".env",
".env.local",
".env.production",
"**/*.key",
"**/*.pem",
"credentials.json",
".aws/credentials"
]
}
}

Pros: Instant blocking, no hooks needed Cons: No custom logic, cannot log attempts

Create .agentignore (or .aiignore) in your project root:

Terminal window
# Credentials
.env*
*.key
*.pem
*.p12
credentials.json
secrets.yaml
# Config
config/secrets/
.aws/credentials
.ssh/id_*
# Build artifacts (if generated from secrets)
dist/.env
build/config/production.json

Unified hook (See: examples/hooks/bash/file-guard.sh):

.claude/hooks/file-guard.sh
# Reads .agentignore and blocks matching files
# Also detects bash bypass attempts

Pros: Gitignore syntax familiar, centralized rules, version controlled Cons: Requires hook implementation

Sophisticated attacks may try to bypass protection using variable expansion:

Terminal window
# Attack attempts
FILE="sensitive.key"
cat $FILE # Variable expansion bypass
HOME_DIR=$HOME
cat $HOME_DIR/.env # Variable substitution bypass
cat $(echo ".env") # Command substitution bypass

The file-guard.sh hook detects these patterns:

Terminal window
# Detection logic
detect_bypass() {
local file="$1"
# Variable expansion
[[ "$file" =~ \$\{?[A-Za-z_][A-Za-z0-9_]*\}? ]] && return 0
# Command substitution
[[ "$file" =~ \$\( || "$file" =~ \` ]] && return 0
return 1
}

1. Configure settings.json:

{
"permissions": {
"deny": [".env", "*.key", "*.pem"]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/file-guard.sh",
"timeout": 2000
}
]
}
]
}
}

2. Create .agentignore:

Terminal window
.env*
config/secrets/
**/*.key
**/*.pem
credentials.json

3. Copy hook template:

Terminal window
cp examples/hooks/bash/file-guard.sh .claude/hooks/
chmod +x .claude/hooks/file-guard.sh
Terminal window
# Test direct access
echo '{"tool_name":"Read","tool_input":{"file_path":".env"}}' | \
.claude/hooks/file-guard.sh
# Should exit 1 and show "File access blocked"
# Test bypass attempt
echo '{"tool_name":"Read","tool_input":{"file_path":"$HOME/.env"}}' | \
.claude/hooks/file-guard.sh
# Should exit 1 and show "Variable expansion detected"

Cross-reference: For full security hardening including CVE-specific mitigations and MCP config integrity, see Security Hardening Guide.

Instead of configuring dozens of individual hooks, use a single dispatcher that routes events intelligently based on file type, tool, and context.

The problem: As your hook collection grows, settings.json becomes unwieldy with repeated matchers and overlapping configurations.

The solution: One entry point that dispatches to specialized handlers.

.claude/hooks/dispatch.sh
#!/bin/bash
# Single entry point for all PostToolUse hooks
# Routes to specialized handlers based on file type and tool
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.command // ""')
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"')
HOOKS_DIR="$(dirname "$0")/handlers"
# Route by file extension
case "$FILE_PATH" in
*.ts|*.tsx)
[[ -x "$HOOKS_DIR/typescript.sh" ]] && echo "$INPUT" | "$HOOKS_DIR/typescript.sh"
;;
*.py)
[[ -x "$HOOKS_DIR/python.sh" ]] && echo "$INPUT" | "$HOOKS_DIR/python.sh"
;;
*.rs)
[[ -x "$HOOKS_DIR/rust.sh" ]] && echo "$INPUT" | "$HOOKS_DIR/rust.sh"
;;
*.sql|*.prisma)
[[ -x "$HOOKS_DIR/database.sh" ]] && echo "$INPUT" | "$HOOKS_DIR/database.sh"
;;
esac
# Route by tool (always runs, regardless of file type)
case "$TOOL_NAME" in
Bash)
[[ -x "$HOOKS_DIR/security.sh" ]] && echo "$INPUT" | "$HOOKS_DIR/security.sh"
;;
Write)
[[ -x "$HOOKS_DIR/new-file.sh" ]] && echo "$INPUT" | "$HOOKS_DIR/new-file.sh"
;;
esac
exit 0

Configuration (minimal settings.json):

{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write|Bash",
"hooks": [{
"type": "command",
"command": ".claude/hooks/dispatch.sh"
}]
}]
}
}

Handler directory structure:

.claude/hooks/
├── dispatch.sh # Single entry point
└── handlers/
├── typescript.sh # ESLint + tsc for .ts/.tsx
├── python.sh # Ruff + mypy for .py
├── rust.sh # cargo clippy for .rs
├── database.sh # Schema validation for .sql/.prisma
├── security.sh # Block dangerous bash commands
└── new-file.sh # Check naming conventions on Write

Benefits over individual hooks:

  • Single matcher in settings.json (instead of N matchers)
  • Easy to extend: Drop a new handler in handlers/, no config change needed
  • Language-aware: Different validation per file type
  • Composable: File-type hooks and tool hooks both run when applicable
  • Debuggable: echo "$INPUT" | .claude/hooks/dispatch.sh tests the full chain
.claude/hooks/activity-logger.sh
#!/bin/bash
# Logs all tool usage to JSONL file
INPUT=$(cat)
LOG_DIR="$HOME/.claude/logs"
LOG_FILE="$LOG_DIR/activity-$(date +%Y-%m-%d).jsonl"
# Create log directory
mkdir -p "$LOG_DIR"
# Clean up old logs (keep 7 days)
find "$LOG_DIR" -name "activity-*.jsonl" -mtime +7 -delete
# Extract tool info
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
# Create log entry
LOG_ENTRY=$(jq -n \
--arg timestamp "$TIMESTAMP" \
--arg tool "$TOOL_NAME" \
--arg session "$SESSION_ID" \
'{timestamp: $timestamp, tool: $tool, session: $session}')
# Append to log
echo "$LOG_ENTRY" >> "$LOG_FILE"
exit 0
.claude/hooks/lint-gate.sh
#!/bin/bash
# Runs linter after code changes
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Only check after Edit/Write
if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then
exit 0
fi
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
# Only lint TypeScript/JavaScript
if [[ ! "$FILE_PATH" =~ \.(ts|tsx|js|jsx)$ ]]; then
exit 0
fi
# Run ESLint
LINT_OUTPUT=$(npx eslint "$FILE_PATH" 2>&1)
LINT_EXIT=$?
if [[ $LINT_EXIT -ne 0 ]]; then
cat << EOF
{
"systemMessage": "Lint errors found in $FILE_PATH:\n$LINT_OUTPUT"
}
EOF
fi
exit 0

Chain multiple validation hooks to catch issues immediately after code changes. This pattern ensures code quality without manual intervention.

Edit/Write → TypeCheck → Lint → Tests → Notify Claude
↓ ↓ ↓ ↓
file.ts tsc check eslint jest file.test.ts

Benefits:

  • Catch errors immediately (before next Claude action)
  • No need to manually run npm run typecheck && npm run lint && npm test
  • Fast feedback loop → faster iteration
  • Prevents cascading errors (Claude gets quality signal early)
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/typecheck-on-save.sh",
"timeout": 5000
},
{
"type": "command",
"command": ".claude/hooks/lint-gate.sh",
"timeout": 5000
},
{
"type": "command",
"command": ".claude/hooks/test-on-change.sh",
"timeout": 10000
}
]
}
]
}
}

Hook order matters: Run fast checks first (typecheck ~1s), then slower ones (tests ~3-5s).

See: examples/hooks/bash/typecheck-on-save.sh

Terminal window
# Runs tsc on TypeScript files after edits
# Only reports errors (not warnings)
# Timeout: 5s (should be fast)

What it catches:

  • Type mismatches
  • Missing imports
  • Invalid property access
  • Generic constraints violations

Already documented in Example 2 above (lint-gate.sh).

What it catches:

  • Code style violations
  • Unused variables
  • Missing semicolons
  • Import order issues

See: examples/hooks/bash/test-on-change.sh

Terminal window
# Detects associated test file and runs it
# Supports: Jest (.test.ts), Pytest (_test.py), Go (_test.go)
# Only runs if test file exists

Test file detection logic:

Source FileTest File Patterns
auth.tsauth.test.ts, __tests__/auth.test.ts
utils.pyutils_test.py, test_utils.py
main.gomain_test.go

What it catches:

  • Broken functionality
  • Regression failures
  • Edge case violations
  • Integration issues

All three hooks check conditions before running:

Terminal window
# Only run on Edit/Write
[[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]] && exit 0
# Only run on specific file types
[[ ! "$FILE_PATH" =~ \.(ts|tsx|js|jsx)$ ]] && exit 0
# Only run if config exists
[[ ! -f "tsconfig.json" ]] && exit 0

This prevents wasted execution on README edits, config changes, or non-code files.

Project SizePipeline TimeAcceptable?
Small (<100 files)~1-2s per edit✅ Yes
Medium (100-1000 files)~2-5s per edit✅ Yes (with incremental)
Large (1000+ files)~5-10s per edit⚠️ Consider async or skip tests

Optimization strategies:

  1. Use async: true for lint/format (cosmetic checks)
  2. Keep typecheck sync (errors must block)
  3. Skip full test suite, run only changed file’s tests
  4. Use incremental compilation (tsc --incremental)
You: Fix the authentication logic
Claude: [Edits auth.ts]
⚠ TypeScript errors in src/auth.ts:
src/auth.ts:45:12 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
45 userId: user.id.toString(),
~~~~~~~~~~~~~~~~~~~
⚠ Tests failed in src/__tests__/auth.test.ts:
FAIL src/__tests__/auth.test.ts
● Authentication › should validate user token
Expected token to be valid
Fix implementation or update tests.

Claude sees these messages immediately and can iterate without manual test runs.

Event: Stop

Display comprehensive session statistics when Claude Code ends, inspired by Gemini CLI’s session summary feature.

After a long Claude Code session, you might wonder:

  • How much time did I spend?
  • How many API requests did Claude make?
  • Which tools did I use most?
  • What did this session cost?

Without session tracking, this information is buried in JSONL files that are hard to parse manually.

A Stop hook that automatically displays a formatted summary with:

  • Session metadata (ID, auto-generated name, git branch)
  • Duration breakdown (wall time vs active Claude time)
  • Tool usage statistics with success/error counts
  • Model usage per model (requests, input/output tokens, cache stats)
  • Estimated cost (via ccusage or built-in pricing table)

File: examples/hooks/bash/session-summary.sh

Requirements:

  • jq (required for JSON parsing)
  • ccusage (optional, for accurate cost calculation via Claude Code Usage tool)
  • bash 3.2+ (macOS compatible)

Plugin Install (Recommended):

Terminal window
claude plugin marketplace add FlorianBruniaux/claude-code-plugins
claude plugin install session-summary@florian-claude-tools

Hooks are auto-wired for SessionStart (RTK baseline) and SessionEnd (summary display). No manual configuration needed.

Manual Configuration (alternative):

{
"hooks": {
"SessionEnd": [{
"hooks": [{
"type": "command",
"command": "~/.claude/hooks/session-summary.sh"
}]
}]
}
}

Environment Variables:

VariableDefaultDescription
NO_COLOR-Disable ANSI colors
SESSION_SUMMARY_LOG~/.claude/logsOverride log directory
SESSION_SUMMARY_SKIP0Set to 1 to disable summary
═══ Session Summary ═══════════════════
ID: abc-123-def-456
Name: Security hardening v3.26
Branch: main
Duration: Wall 1h 34m | Active 14m 24s
Tool Calls: 47 (OK 45 / ERR 2)
Read: 12 Bash: 10 Edit: 8 Write: 6
Grep: 5 Glob: 4 WebSearch: 2
Model Usage Reqs Input Output
claude-sonnet-4-5 42 493.9K 2.5K
claude-haiku-4-5 5 12.4K 46
Cache: 1.2M read / 45.3K created
Est. Cost: $0.74
═══════════════════════════════════════

The hook extracts data from two locations:

1. Session JSONL file (~/.claude/projects/{encoded-path}/{session-id}.jsonl):

  • API requests count
  • Token usage per model
  • Tool calls (extracted from assistant messages)
  • Tool errors (from tool_result with is_error: true)
  • Turn durations (system messages with subtype: turn_duration)
  • Wall time (first to last timestamp)

2. Sessions index (~/.claude/projects/{encoded-path}/sessions-index.json):

  • Session summary (auto-generated by Claude)
  • Git branch
  • Message count

Session summaries are also logged to ~/.claude/logs/session-summaries.jsonl for historical analysis:

{
"timestamp": "2026-02-13T10:30:00Z",
"session_id": "abc-123-def",
"session_name": "Security hardening v3.26",
"git_branch": "main",
"project": "/path/to/project",
"duration_wall_ms": 5640000,
"duration_active_ms": 864000,
"api_requests": 47,
"tool_calls": {"Read": 12, "Bash": 10, "Edit": 8},
"tool_errors": 2,
"models": {
"claude-sonnet-4-5-20250929": {
"requests": 42,
"input": 493985,
"output": 2505,
"cache_read": 1200000,
"cache_create": 45300
}
},
"total_tokens": {
"input": 506458,
"output": 2551,
"cache_read": 1200000,
"cache_create": 45300
},
"cost_usd": 0.74
}
  • Execution time: <2s for sessions up to 100MB
  • Memory: Streaming JSONL processing via jq reduce inputs (memory-bounded)
  • Impact: Runs at session end (doesn’t block during work)

Priority 1: ccusage tool (accurate, if available)

Terminal window
ccusage session --id <session-id> --json --offline

Fallback: Built-in pricing table (as of 2026-02)

ModelInput (per 1M tokens)Output (per 1M tokens)
claude-opus-4-6$15.00$75.00
claude-sonnet-4-5$3.00$15.00
claude-haiku-4-5$0.80$4.00
  • Empty sessions (0 API requests): Displays minimal summary
  • Missing JSONL file: Falls back to sessions-index.json
  • ccusage unavailable: Uses pricing table fallback
  • No turn_duration entries: Shows wall time only
  • Very large sessions (500MB+): Streams with jq (memory safe)

Plugin system (recommended):

Terminal window
claude plugin marketplace add FlorianBruniaux/claude-code-plugins
claude plugin install session-summary@florian-claude-tools

Manual (alternative):

Terminal window
# Copy hook
cp examples/hooks/bash/session-summary.sh .claude/hooks/
chmod +x .claude/hooks/session-summary.sh
# Add to settings.json (see Configuration above)
# Test it
echo '{"session_id":"test","cwd":"'$(pwd)'"}' | .claude/hooks/session-summary.sh
FeatureGemini CLIClaude Code (with this hook)
Session summary✅ Built-in✅ Via hook
Duration tracking✅ Wall + active✅ Wall + active
Tool calls breakdown✅ Yes✅ Yes (with success/error)
Model usage✅ Requests + tokens✅ Requests + tokens + cache
Cost estimation✅ Yes✅ ccusage or pricing table
Structured logging❌ No✅ JSONL for analysis

Quick jump: What is MCP · Available Servers · Configuration · Server Selection Guide · Plugin System · MCP Security


Reading time: 15 minutes Skill level: Week 2-3 Goal: Extend Claude Code with external tools