On this page
Lesson 9 of 30

Lesson 08: Hooks

What Are Hooks?

Hooks are shell commands (or LLM prompts) that Claude Code runs automatically at specific points in its lifecycle. They give you deterministic control over Claude's behavior — ensuring certain actions always happen rather than relying on the LLM to choose them.

Use hooks to:

  • Auto-format code after every edit
  • Block dangerous commands before they execute
  • Send notifications when Claude needs your input
  • Inject context at session start or after compaction
  • Enforce rules like "never modify .env files"
  • Audit every tool call for compliance logging
  • Verify task completion with LLM-powered checks

Where Hooks Live

Hooks are defined in JSON settings files. Where you put them determines their scope:

Location Scope Shareable?
~/.claude/settings.json All your projects No (local to your machine)
.claude/settings.json Single project Yes (commit to repo)
.claude/settings.local.json Single project No (gitignored)
Managed policy settings Organization-wide Yes (admin-controlled)
Plugin hooks/hooks.json When plugin is enabled Yes (bundled with plugin)
Skill or agent frontmatter While component is active Yes (in the component file)

Project-level hooks (.claude/settings.json) are great for team workflows — everyone on the project gets the same hooks automatically.


The `/hooks` Command

The fastest way to create, view, and manage hooks is the interactive /hooks menu inside Claude Code. Type /hooks and you will see:

  1. A list of all hook events
  2. Your currently configured hooks (labeled [User], [Project], [Local], or [Plugin])
  3. Options to add new hooks, delete existing ones, or disable all hooks

The menu walks you through choosing an event, setting a matcher, entering a command, and picking a storage location — no manual JSON editing required.

Tip: Hooks added through /hooks take effect immediately. If you edit settings files manually while Claude Code is running, changes require a review in /hooks or a session restart.


Hook Events — The Complete List

Claude Code fires 17 hook events across the session lifecycle. Here is every event:

Session Lifecycle

Event When It Fires Can Block?
SessionStart Session begins or resumes No
SessionEnd Session terminates No
PreCompact Before context compaction No
ConfigChange A settings or skills file changes mid-session Yes

User Input

Event When It Fires Can Block?
UserPromptSubmit You submit a prompt, before Claude processes it Yes

Tool Execution

Event When It Fires Can Block?
PreToolUse Before a tool call executes Yes
PermissionRequest When a permission dialog is about to appear Yes
PostToolUse After a tool call succeeds No (already ran)
PostToolUseFailure After a tool call fails No (already failed)

Agent Completion

Event When It Fires Can Block?
Stop Claude finishes responding Yes (force continue)
SubagentStart A subagent is spawned No
SubagentStop A subagent finishes Yes (force continue)
Notification Claude sends a notification No

Teams & Tasks

Event When It Fires Can Block?
TeammateIdle An agent-team teammate is about to go idle Yes
TaskCompleted A task is being marked complete Yes

Worktrees

Event When It Fires Can Block?
WorktreeCreate A worktree is being created (replaces default git behavior) Yes
WorktreeRemove A worktree is being removed No

Hook Configuration Format

The configuration has three levels of nesting:

  1. Hook event — which lifecycle point to respond to
  2. Matcher group — filter when it fires (e.g., "only for the Bash tool")
  3. Hook handlers — one or more commands/prompts to run when matched
JSON
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/validate-bash.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Check if all tasks are complete: $ARGUMENTS"
          }
        ]
      }
    ]
  }
}

Hook Types

There are three types of hook handlers:

Command Hooks (`type: "command"`)

Run a shell command. The script receives JSON on stdin and communicates results through exit codes and stdout.

Field Required Description
type Yes "command"
command Yes Shell command to execute
timeout No Seconds before canceling (default: 600)
async No If true, runs in the background without blocking
statusMessage No Custom spinner message while running

Prompt Hooks (`type: "prompt"`)

Send a prompt to a Claude model for single-turn evaluation. The model returns a yes/no decision as JSON. Good for decisions requiring judgment rather than deterministic rules.

Field Required Description
type Yes "prompt"
prompt Yes Prompt text. Use $ARGUMENTS for the hook input JSON
model No Model to use (defaults to a fast model like Haiku)
timeout No Seconds before canceling (default: 30)

The model must respond with:

JSON
{ "ok": true }

or:

JSON
{ "ok": false, "reason": "Explanation for why the action should be blocked" }

Agent Hooks (`type: "agent"`)

Spawn a subagent that can use tools (Read, Grep, Glob) to verify conditions before returning a decision. Use when verification requires inspecting actual files.

Field Required Description
type Yes "agent"
prompt Yes Prompt describing what to verify. Use $ARGUMENTS placeholder
model No Model to use (defaults to a fast model)
timeout No Seconds before canceling (default: 60)

Uses the same ok/reason response format as prompt hooks.

Not all events support all types. Events like PreToolUse, PostToolUse, Stop, UserPromptSubmit, PermissionRequest, PostToolUseFailure, SubagentStop, and TaskCompleted support all three types. Events like SessionStart, SessionEnd, Notification, SubagentStart, PreCompact, ConfigChange, TeammateIdle, WorktreeCreate, and WorktreeRemove only support type: "command".


Matchers — Filtering When Hooks Fire

The matcher field is a regex string that controls which specific occurrences trigger the hook. Omit it, use "", or use "*" to match everything.

Each event type matches on a different field:

Event What Matcher Filters Example Values
PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest Tool name Bash, Edit|Write, mcp__.*
SessionStart How session started startup, resume, clear, compact
SessionEnd Why session ended clear, logout, prompt_input_exit
Notification Notification type permission_prompt, idle_prompt
SubagentStart, SubagentStop Agent type Bash, Explore, Plan
PreCompact Compaction trigger manual, auto
ConfigChange Config source user_settings, project_settings, skills

Some events (UserPromptSubmit, Stop, TeammateIdle, TaskCompleted, WorktreeCreate, WorktreeRemove) do not support matchers — they fire on every occurrence.

Since matchers are regex, Edit|Write matches either tool, and mcp__memory__.* matches all tools from the memory MCP server.

Matching MCP Tools

MCP tools follow the naming pattern mcp__<server>__<tool>:

  • mcp__memory__create_entities — Memory server's create entities tool
  • mcp__filesystem__read_file — Filesystem server's read file tool
  • mcp__github__.* — All tools from the GitHub server

The JSON Input/Output System

Hooks communicate with Claude Code through stdin (JSON input), stdout (JSON or text output), stderr (error messages), and exit codes.

Input: What Hooks Receive

Every hook receives a JSON payload on stdin with common fields plus event-specific data:

JSON
{
  "session_id": "abc123",
  "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
  "cwd": "/home/user/my-project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test"
  }
}

Common fields on every event: session_id, transcript_path, cwd, permission_mode, hook_event_name.

Event-specific fields vary — PreToolUse adds tool_name and tool_input, UserPromptSubmit adds prompt, Stop adds stop_hook_active and last_assistant_message, SessionStart adds source and model, and so on.

Output: Exit Codes

Exit Code Meaning
0 Success — action proceeds. Stdout is parsed for JSON output
2 Blocking error — action is prevented. Stderr is fed back to Claude
Any other Non-blocking error — action proceeds. Stderr shown in verbose mode

Output: JSON for Fine-Grained Control

Instead of just exit codes, exit 0 and print a JSON object to stdout for richer control:

Universal fields (work on all events):

Field Default Description
continue true If false, Claude stops entirely
stopReason none Message shown to user when continue is false
suppressOutput false If true, hides stdout from verbose mode
systemMessage none Warning message shown to the user

Decision control varies by event:

For PreToolUse, use hookSpecificOutput:

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

permissionDecision can be "allow" (bypass permission prompt), "deny" (block the call), or "ask" (show the permission dialog). You can also include updatedInput to modify tool parameters before execution.

For Stop, PostToolUse, SubagentStop, UserPromptSubmit, ConfigChange, use top-level decision:

JSON
{
  "decision": "block",
  "reason": "Tests must pass before stopping"
}

For PermissionRequest, use hookSpecificOutput with a decision object:

JSON
{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "allow"
    }
  }
}

Important: Choose one approach per hook — either exit codes alone, or exit 0 with JSON. Claude Code ignores JSON on exit 2.


Practical Examples

1. Auto-Format Code After Edits

Run Prettier on every file Claude writes or edits:

JSON
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}

2. Block Dangerous Commands

Prevent rm -rf from ever running:

Bash
#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command')

if echo "$COMMAND" | grep -q 'rm -rf'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Destructive rm -rf command blocked by hook"
    }
  }'
else
  exit 0
fi

Configuration:

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

3. Desktop Notifications

Get notified when Claude needs your input so you can switch to other tasks:

macOS:

JSON
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

Linux:

JSON
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'Claude needs your attention'"
          }
        ]
      }
    ]
  }
}

4. Protect Sensitive Files

Block edits to .env, lock files, and .git/:

Bash
#!/bin/bash
# .claude/hooks/protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0
JSON
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

5. Re-Inject Context After Compaction

When compaction summarizes the conversation, critical details can be lost. Re-inject them automatically:

JSON
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
          }
        ]
      }
    ]
  }
}

Any text your SessionStart hook prints to stdout is added to Claude's context.

6. LLM-Powered Completion Check

Use a prompt hook to verify Claude actually finished all requested tasks before stopping:

JSON
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "You are evaluating whether Claude should stop working. Context: $ARGUMENTS\n\nAnalyze the conversation and determine if:\n1. All user-requested tasks are complete\n2. Any errors need to be addressed\n3. Follow-up work is needed\n\nRespond with JSON: {\"ok\": true} to allow stopping, or {\"ok\": false, \"reason\": \"what remains\"} to continue working.",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

7. Audit Logging

Log every tool call for compliance:

JSON
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_name' >> ~/.claude/logs/tool-audit.log"
          }
        ]
      }
    ]
  }
}

8. Run Tests in the Background

Use async hooks to run tests after file changes without blocking Claude:

JSON
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests-async.sh",
            "async": true,
            "timeout": 300
          }
        ]
      }
    ]
  }
}

The async: true flag lets Claude continue working while tests run. When they finish, results are delivered on the next conversation turn via systemMessage.


Async Hooks

By default, hooks block Claude until they complete. For long-running tasks, set "async": true to run in the background:

JSON
{
  "type": "command",
  "command": "/path/to/slow-script.sh",
  "async": true,
  "timeout": 120
}

Async hooks cannot block or control Claude's behavior — by the time they finish, the triggering action has already proceeded. Only type: "command" hooks support async. Results are delivered on the next conversation turn.


Environment Variables

Hook scripts have access to these environment variables:

Variable Description
$CLAUDE_PROJECT_DIR The project root directory
${CLAUDE_PLUGIN_ROOT} The plugin's root directory (for plugin hooks)
$CLAUDE_CODE_REMOTE Set to "true" in remote web environments
$CLAUDE_ENV_FILE Path to write persistent env vars (SessionStart only)

Use $CLAUDE_PROJECT_DIR to reference scripts with absolute paths:

JSON
{
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/my-script.sh"
}

Persisting Environment Variables

In SessionStart hooks, write export statements to $CLAUDE_ENV_FILE to set environment variables for all subsequent Bash commands in the session:

Bash
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
  echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
  echo 'export DEBUG_LOG=true' >> "$CLAUDE_ENV_FILE"
fi
exit 0

Hooks in Skills and Agents

Hooks can be defined directly in skill and subagent YAML frontmatter. These hooks are scoped to the component's lifecycle — they only run while that skill or agent is active.

YAML
---
name: secure-operations
description: Perform operations with security checks
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/security-check.sh"
---

For subagents, Stop hooks are automatically converted to SubagentStop.


Debugging Hooks

Verbose Mode

Toggle verbose mode with Ctrl+O to see hook output in the transcript. On exit 0, stdout is shown in verbose mode. On exit 2, stderr is fed back to Claude.

Debug Flag

Run claude --debug to see detailed hook execution:

[DEBUG] Executing hooks for PostToolUse:Write
[DEBUG] Found 1 hook matchers in settings
[DEBUG] Matched 1 hooks for query "Write"
[DEBUG] Hook command completed with status 0: 

Test Hooks Manually

Pipe sample JSON into your script to test it outside of Claude Code:

Bash
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | ./my-hook.sh
echo $?  # Check exit code

Common Issues

Hook not firing?

  • Run /hooks to confirm it appears under the correct event
  • Check that matcher patterns match exactly (they are case-sensitive)
  • Verify you are triggering the right event type

JSON validation failed?

  • If your shell profile (.zshrc, .bashrc) prints text on startup, it interferes with JSON parsing. Wrap echo statements so they only run in interactive shells:
Bash
if [[ $- == *i* ]]; then
  echo "Shell ready"
fi

Stop hook runs forever?

  • Check stop_hook_active in the input and exit early if it is true:
Bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0  # Allow Claude to stop — hook already triggered a continuation
fi

jq: command not found?

  • Install jq: brew install jq (macOS), apt-get install jq (Linux), or use Python/Node for JSON parsing instead.

Best Practices

Keep hooks fast. Hooks run synchronously by default — slow hooks block Claude. For expensive operations, use "async": true or run tasks in the background.

Handle failures gracefully. Only exit 2 when you intentionally want to block. Unexpected blocking will frustrate you.

Use absolute paths. Hooks may run from varying working directories. Use $CLAUDE_PROJECT_DIR or full paths.

Quote shell variables. Always use "$VAR" not $VAR to prevent word splitting.

Validate inputs. Never trust input data blindly — check for path traversal (..), skip sensitive files, and sanitize before acting.

Choose the right hook type. Use command for deterministic rules (formatting, blocking patterns). Use prompt for judgment calls (is the task really done?). Use agent when you need to inspect files to verify conditions.


Practical Exercise

Build a PreToolUse hook that:

  1. Matches only Bash tool calls
  2. Reads the JSON input from stdin with jq
  3. Checks if the command contains rm — if so, logs it to ~/.claude/logs/deletions.log with a timestamp
  4. Blocks rm -rf / and rm -rf ~ with a JSON permissionDecision of "deny"
  5. Allows all other commands to proceed

Then add a PostToolUse hook with matcher Edit|Write that runs your project's linter on the edited file.