I’ve been using Claude Code for months before I discovered hooks. Now I can’t imagine working without them. Hooks are scripts that run automatically before or after Claude Code performs an action — and they unlock an entire category of workflows that aren’t possible with prompts alone.

Want to automatically lint every file Claude edits? Hook. Want to block dangerous commands without relying on the deny list alone? Hook. Want to log every action Claude takes for audit purposes? Hook. Want to auto-run tests after every code change? Hook.

Here’s everything I’ve learned about building effective hooks, with real examples you can steal.


What Are Hooks?

Hooks are handlers that Claude Code executes at specific points in its workflow. They’re configured in .claude/settings.json and run automatically — no prompting required.

Note: When this post was first written, Claude Code had 4 hook events. It now has 17. The system has evolved significantly — from a simple pre/post tool mechanism into a full lifecycle event system covering sessions, agents, teams, and infrastructure.

Each hook receives context about the current event. Your handler reads it, does its thing, and signals back whether to continue, block, or provide feedback.

Here’s the complete event table, grouped by lifecycle:

Session Lifecycle

HookWhen It RunsMatchersCan Block?
SessionStartSession begins (startup, resume, clear, compact)startup, resume, clear, compactNo
PreCompactBefore context compactionmanual, autoNo
SessionEndSession endsNo

User Interaction

HookWhen It RunsMatchersCan Block?
UserPromptSubmitUser submits a promptYes
NotificationClaude sends a notificationpermission_prompt, idle_prompt, auth_success, elicitation_dialogNo

Tool Lifecycle

HookWhen It RunsMatchersCan Block?
PreToolUseBefore Claude runs a toolTool name (e.g. Bash, Edit)Yes
PermissionRequestClaude requests permission for a toolTool nameYes
PostToolUseAfter a tool succeedsTool nameNo (feedback)
PostToolUseFailureAfter a tool failsTool nameNo (feedback)

Agent Lifecycle

HookWhen It RunsMatchersCan Block?
SubagentStartA subagent spawnsNo
SubagentStopA subagent stopsYes
StopClaude stops a turnYes

Team Events

HookWhen It RunsMatchersCan Block?
TeammateIdleA teammate goes idleYes
TaskCompletedA task is completedYes

Infrastructure

HookWhen It RunsMatchersCan Block?
ConfigChangeConfiguration changesYes (except policy changes)
WorktreeCreateA git worktree is createdYes
WorktreeRemoveA git worktree is removedNo

Each hook receives a JSON payload on stdin with details about the event. Your handler reads it and exits with a status code (for blocking hooks) or returns structured feedback.


The 4 Handler Types

This is where Claude Code’s hook system gets interesting. Most tutorials only cover shell scripts — but there are actually 4 handler types, each suited for different jobs.

command — Shell Script

The classic. Receives JSON on stdin, runs your script. This is what all the examples later in this post use.

{
"type": "command",
"command": ".claude/hooks/my-script.sh",
"timeout": 10
}

Best for: file operations, custom logic, calling external tools, anything requiring conditional branching.


prompt — AI Yes/No Decision

A single-turn Claude Haiku call that makes a blocking decision. No scripting needed. You write a prompt, it returns {"ok": true} or {"ok": false, "reason": "..."}.

{
"type": "prompt",
"prompt": "Check if this shell command is safe to run: $ARGUMENTS. Respond with JSON: {\"ok\": true} if safe, or {\"ok\": false, \"reason\": \"explanation\"} if it looks dangerous.",
"model": "claude-haiku-4-5",
"timeout": 30
}

This is powerful for situations where you’d normally write a regex-based script but the logic is actually fuzzy. “Is this command safe?” is a hard problem for a bash script. It’s easy for an LLM.


agent — Multi-Turn AI with File Access

A multi-turn subagent with tools: Read, Grep, Glob. For verification tasks that need to examine your codebase before deciding.

{
"type": "agent",
"prompt": "Verify all unit tests still pass before allowing this change. Run the test suite and check the results. $ARGUMENTS",
"timeout": 120
}

Use this when a yes/no answer requires actually looking at files. For example: “Does this database migration have a corresponding rollback?” or “Does the modified API endpoint still match its contract tests?”


http — POST to Webhook URL

POSTs the event JSON to an external URL. For audit pipelines, monitoring dashboards, external approval gates, or any service that needs to know what Claude is doing.

{
"type": "http",
"url": "http://localhost:8080/hooks/events",
"headers": { "Authorization": "Bearer $MY_TOKEN" },
"allowedEnvVars": ["MY_TOKEN"],
"timeout": 30
}

Note the allowedEnvVars field — you explicitly allowlist which environment variables can be referenced in the config. This prevents accidental credential leakage.


Quick Reference

TypeDescriptionBest For
commandShell script, JSON on stdinFile ops, custom logic, external tools
promptAI yes/no decision (Haiku)Content validation, safety checks
agentMulti-turn AI with file accessComplex verification, test checking
httpPOST to webhook URLExternal services, audit, monitoring

Your First Hook: The Command Blocker

Let’s start with the most practical hook — blocking dangerous commands. This is a command-type hook that catches problems before they happen.

Step 1: Create the Hook Script

.claude/hooks/block-dangerous.sh
#!/bin/bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
command=$(echo "$input" | jq -r '.tool_input.command // ""')
# Only check Bash commands
if [ "$tool_name" != "Bash" ]; then
exit 0
fi
# Block file deletion
if echo "$command" | grep -qE '\brm\s+'; then
echo '{"error": "BLOCKED: rm is not allowed. Delete files manually."}' >&2
exit 2
fi
# Block force push
if echo "$command" | grep -qE 'git\s+push\s+.*(-f|--force)'; then
echo '{"error": "BLOCKED: Force push is not allowed."}' >&2
exit 2
fi
exit 0

Step 2: Make It Executable

Terminal window
chmod +x .claude/hooks/block-dangerous.sh

Step 3: Register It in Settings

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-dangerous.sh"
}
]
}
]
}
}

Now every Bash command Claude tries to run goes through your script first. If it matches a dangerous pattern, the script returns exit code 2 with an error message, and Claude sees the command was blocked.


Hook 2: Auto-Lint After Edits

This one saves me 5-10 prompts per session. Every time Claude edits a file, the hook automatically runs the linter on that file.

.claude/hooks/auto-lint.sh
#!/bin/bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
file_path=$(echo "$input" | jq -r '.tool_input.file_path // ""')
# Only run after Edit tool
if [ "$tool_name" != "Edit" ]; then
exit 0
fi
# Get file extension
ext="${file_path##*.}"
case "$ext" in
ts|tsx)
npx eslint --fix "$file_path" 2>/dev/null
;;
py)
python -m black "$file_path" 2>/dev/null
;;
kt)
ktlint --format "$file_path" 2>/dev/null
;;
esac
exit 0

Register it as a PostToolUse hook:

{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/auto-lint.sh"
}
]
}
]
}
}

Now Claude’s edits are automatically formatted. No more “please run prettier” follow-ups.


Hook 3: Action Logger

For teams that need to audit AI tool usage, this hook logs every action Claude takes to a file.

.claude/hooks/audit-log.sh
#!/bin/bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Create log entry
log_entry=$(echo "$input" | jq -c --arg ts "$timestamp" '{
timestamp: $ts,
tool: .tool_name,
input: .tool_input
}')
# Append to log file
echo "$log_entry" >> .claude/audit.jsonl
exit 0

Register for all tools:

{
"hooks": {
"PreToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/audit-log.sh"
}
]
}
]
}
}

An empty matcher string matches all tools. Every action gets logged with a timestamp, tool name, and input parameters. This is gold for compliance and debugging.


Hook 4: Secret Detection

This hook scans every file Claude reads or edits for accidentally exposed secrets.

.claude/hooks/secret-scan.sh
#!/bin/bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
# Check for file operations
file_path=""
if [ "$tool_name" = "Edit" ] || [ "$tool_name" = "Write" ]; then
file_path=$(echo "$input" | jq -r '.tool_input.file_path // ""')
fi
if [ -z "$file_path" ] || [ ! -f "$file_path" ]; then
exit 0
fi
# Scan for common secret patterns
if grep -qE '(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36})' "$file_path"; then
echo '{"error": "BLOCKED: Potential secret detected in file. Review before proceeding."}' >&2
exit 2
fi
exit 0

This catches AWS access keys, API keys with common prefixes, and GitHub tokens before they get committed.


Hook 5: Test Runner After Changes

The most powerful workflow hook — automatically runs relevant tests when Claude modifies source files.

.claude/hooks/auto-test.sh
#!/bin/bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
# Only trigger after Edit
if [ "$tool_name" != "Edit" ]; then
exit 0
fi
file_path=$(echo "$input" | jq -r '.tool_input.file_path // ""')
# Map source file to test file
test_file=""
if [[ "$file_path" == *".ts" ]]; then
test_file="${file_path%.ts}.test.ts"
elif [[ "$file_path" == *".kt" ]]; then
test_file=$(echo "$file_path" | sed 's|/main/|/test/|')
test_file="${test_file%.kt}Test.kt"
fi
# Run the test if it exists
if [ -n "$test_file" ] && [ -f "$test_file" ]; then
echo "Running tests for: $test_file" >&2
# Run test (don't block on failure — just inform)
if [[ "$file_path" == *".ts" ]]; then
npx jest "$test_file" --silent 2>&1 | tail -5 >&2
elif [[ "$file_path" == *".kt" ]]; then
./gradlew test --tests "$(basename ${test_file%.kt})" 2>&1 | tail -5 >&2
fi
fi
exit 0

Note: This runs tests as a side effect but always exits 0, so it doesn’t block Claude’s workflow. It just shows test results in the output so Claude can see if something broke.


Combining Hooks

The real power is combining multiple hooks. Here’s a complete .claude/settings.json hooks section that wires up all the examples above:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-dangerous.sh"
}
]
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/audit-log.sh"
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/secret-scan.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/auto-lint.sh"
},
{
"type": "command",
"command": ".claude/hooks/auto-test.sh"
}
]
}
]
}
}

The execution order:

  1. Before any action: Log it
  2. Before Bash: Check for dangerous commands
  3. Before Write: Scan for secrets
  4. After Edit: Auto-lint, then run tests

Advanced Features

Matchers

Matchers are regex patterns that filter which events trigger a hook. A few practical examples:

"matcher": "Bash" // Only Bash tool
"matcher": "Edit|Write" // Edit or Write
"matcher": "mcp__memory__.*" // Any memory MCP tool
"matcher": "mcp__.*__write.*" // Any MCP write operation
"matcher": "" // Match everything (empty = wildcard)

Tool names for MCP servers follow the pattern mcp__{server}__{tool}. Regex matching means you can target entire MCP server categories without listing each tool individually.

Async Hooks

By default, command hooks run synchronously — Claude waits for them. For slow operations (indexing, telemetry, non-blocking background tasks), add "async": true:

{
"type": "command",
"command": ".claude/hooks/send-telemetry.sh",
"async": true
}

Claude continues immediately. The hook runs in the background. Only available for command type.

CLAUDE_ENV_FILE

SessionStart hooks have a special superpower: they can inject environment variables for the entire session. If your SessionStart hook writes to the path in $CLAUDE_ENV_FILE, those variables become available to all subsequent hooks and tools.

.claude/hooks/session-init.sh
#!/bin/bash
# Runs at session start — inject env vars
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo "PROJECT_ID=$(cat .project-id)" >> "$CLAUDE_ENV_FILE"
echo "DEPLOY_ENV=staging" >> "$CLAUDE_ENV_FILE"
fi

This is only available on SessionStart. Other hooks cannot write to CLAUDE_ENV_FILE.

Stop Hook Loop Prevention

If you have a Stop hook that does something, be careful: if the hook itself causes Claude to stop again, you get an infinite loop. Check the stop_hook_active field in the payload to detect this:

#!/bin/bash
input=$(cat)
stop_hook_active=$(echo "$input" | jq -r '.stop_hook_active // false')
# Bail if we're already in a stop hook loop
if [ "$stop_hook_active" = "true" ]; then
exit 0
fi
# ... rest of your stop hook logic

Hook Scoping

Hooks can live at multiple levels, and they all stack:

  • User settings (~/.claude/settings.json) — applies to all projects
  • Project settings (.claude/settings.json) — applies to this project
  • Local settings (.claude/settings.local.json) — your personal overrides, not committed
  • Plugin hooks — from installed Claude Code plugins
  • Skill/agent YAML frontmatter — hooks declared inside custom agent definitions

All matching hooks at all levels run. More specific matchers win ordering ties.


Hook Design Principles

After building a dozen hooks, here are the principles I follow:

1. Hooks should be fast. PreToolUse hooks run synchronously — slow hooks make Claude feel laggy. Keep them under 500ms. If you need to run something slow (like a full test suite), do it in PostToolUse and don’t block on the result. Or use "async": true.

2. Use exit codes correctly.

  • exit 0 = success, continue normally
  • exit 2 = block the action (with error message on stderr)
  • Any other non-zero = error, but don’t block

3. Always handle missing data. The JSON payload might not have every field. Use jq -r '.field // ""' with defaults, and always check before acting on a value.

4. Don’t modify Claude’s output. Hooks can block actions or add side effects, but they shouldn’t change what Claude produces. That creates confusing debugging situations.

5. Log liberally. Hooks run silently. When something goes wrong, logs are your only window into what happened.

6. Match the handler type to the job. Use command for deterministic logic. Use prompt when the decision is fuzzy or language-based. Use agent when you need to examine files before deciding. Use http when an external system needs to know.


Debugging Hooks

When a hook isn’t working:

  1. Test it standalone — pipe sample JSON into the script manually:

    Terminal window
    echo '{"tool_name":"Bash","tool_input":{"command":"rm file.txt"}}' | .claude/hooks/block-dangerous.sh
    echo $? # Should be 2
  2. Check permissions — the script must be executable:

    Terminal window
    ls -la .claude/hooks/
  3. Check jq is installed — most hooks depend on it:

    Terminal window
    which jq
  4. Check the matcher — an empty matcher matches everything, a specific matcher like "Bash" only matches that tool.

  5. Check hook scoping — a hook in ~/.claude/settings.json applies everywhere; a hook in .claude/settings.json only applies to this project. Make sure you’re editing the right file.


Key Takeaways

  1. Hooks are Claude Code’s extension system. They let you customize behavior without changing prompts.
  2. 17 events, not 4. The system now covers session lifecycle, user interaction, tool lifecycle, agent lifecycle, team events, and infrastructure — not just PreToolUse and PostToolUse.
  3. 4 handler types, not just shell scripts. command for logic, prompt for AI decisions, agent for file-aware verification, http for external systems.
  4. PreToolUse hooks are your security layer. Block dangerous commands, scan for secrets, validate inputs.
  5. PostToolUse hooks automate tedious follow-ups. Auto-lint, auto-test, auto-format.
  6. Keep hooks fast and simple. Under 500ms, clear exit codes, handle missing data.
  7. Combine hooks for defense in depth. Multiple lightweight hooks beat one complex monolith.

Hooks turn Claude Code from a smart coding assistant into a customizable development platform. Once you start building them, you’ll wonder how you worked without them.


For advanced patterns including prompt hooks, agent hooks, and 7 copy-paste recipes, see Beyond Shell Scripts: Advanced Hook Patterns.


The hooks system is covered in depth in Phase 11: Automation & Headless of the Claude Code Mastery course. Phases 1-3 are free.