Claude Code Hooks Reference

July 20, 2026 · View on GitHub

Hooks let you run shell commands, LLM prompts, verification agents, or HTTP webhooks automatically when Claude Code performs actions. Configure in ~/.claude/settings.json (global) or .claude/settings.json (per-project).


Hook Types

1. command - Run a shell command

{
  "type": "command",
  "command": "npm run lint",
  "shell": "bash",
  "timeout": 30,
  "statusMessage": "Linting...",
  "async": false,
  "once": false
}

2. prompt - Evaluate with an LLM

{
  "type": "prompt",
  "prompt": "Is this safe? $ARGUMENTS",
  "model": "claude-sonnet-4-6",
  "timeout": 30
}

3. agent - Spawn a sub-agent with tools

{
  "type": "agent",
  "prompt": "Verify unit tests pass after this change. $ARGUMENTS",
  "model": "claude-sonnet-4-6",
  "timeout": 120
}

4. http - POST to a webhook URL

{
  "type": "http",
  "url": "https://hooks.slack.com/services/YOUR/WEBHOOK",
  "headers": {
    "Authorization": "Bearer $MY_TOKEN"
  },
  "allowedEnvVars": ["MY_TOKEN"],
  "timeout": 10
}

All 27 Events

Tool Events

EventMatcher FieldDescription
PreToolUsetool_nameBefore tool execution - can block it
PostToolUsetool_nameAfter tool execution
PostToolUseFailuretool_nameAfter tool execution fails
PermissionDeniedtool_nameAfter auto mode denies a tool
PermissionRequesttool_nameWhen permission dialog shows

Session Events

EventMatcher FieldDescription
SessionStartsource (startup, resume, clear, compact)New session starts
SessionEndreason (clear, logout, prompt_input_exit)Session ending
UserPromptSubmit-When user sends a message
Stop-Before Claude finishes responding
StopFailureerror (rate_limit, auth, billing, etc.)Turn ends due to API error

Agent Events

EventMatcher FieldDescription
SubagentStartagent_typeSubagent started
SubagentStopagent_typeSubagent concludes

Compaction Events

EventMatcher FieldDescription
PreCompacttrigger (manual, auto)Before compaction
PostCompacttrigger (manual, auto)After compaction

File & Config Events

EventMatcher FieldDescription
FileChangedfilename patternWatched file changes
ConfigChangesource (user/project/local/policy/skills)Config files change
CwdChanged-Working directory changes

Task Events

EventMatcher FieldDescription
TaskCreated-Task being created
TaskCompleted-Task being completed

Other Events

EventMatcher FieldDescription
Setuptrigger (init, maintenance)Repo setup
Notificationnotification_typeNotifications sent
Elicitationmcp_server_nameMCP server requests input
ElicitationResultmcp_server_nameAfter user responds to MCP
InstructionsLoadedload_reasonInstruction file loaded
WorktreeCreate-Worktree created
WorktreeRemove-Worktree removed
TeammateIdle-Teammate about to go idle

Exit Code Behavior

Exit CodeMeaning
0Success - proceed normally
2BLOCK - stderr shown to Claude, action stopped
OtherWarning - stderr shown to user only, doesn't block

Configuration Format

{
  "hooks": {
    "<EventName>": [
      {
        "matcher": "<optional string to match>",
        "hooks": [
          {
            "type": "command|prompt|agent|http",
            ...hook-specific fields
          }
        ]
      }
    ]
  }
}

Common Fields (all hook types)

  • if - filter condition using permission syntax (e.g., "Bash(git *)", "Write(*.ts)")
  • timeout - max seconds
  • statusMessage - shown during execution
  • once - remove hook after first execution

Hook input: JSON on stdin

A command hook receives its input as JSON on stdin. There is no $ARGUMENTS variable. Reading only $ARGUMENTS produces a hook that exits 0 on every real invocation: it looks installed, logs nothing, and enforces nothing.

{
  "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" }
}

The tool's own arguments are nested under .tool_input, so it is .tool_input.file_path and .tool_input.command, not .file_path / .command.

COMMAND=$(jq -r '.tool_input.command // empty')

Environment variables

Set for every hook process: $CLAUDE_PROJECT_DIR, $CLAUDE_PLUGIN_ROOT, $CLAUDE_PLUGIN_DATA, $CLAUDE_EFFORT, $CLAUDE_CODE_REMOTE, $CLAUDE_CODE_BRIDGE_SESSION_ID. $ENV_VAR interpolation applies to http headers only, and the variable must be listed in allowedEnvVars.

prompt-type hooks are the exception: $ARGUMENTS interpolates the hook input into the prompt string. That is a settings.json template substitution, not something a shell script can read.


Practical Examples

Auto-lint after every file write

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "eslint --fix \"$(jq -r '.tool_input.file_path')\"",
            "timeout": 30,
            "statusMessage": "Linting..."
          }
        ]
      }
    ]
  }
}

Block dangerous git commands

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' | grep -qE '(force.push|--force|--hard|rm -rf /)' && echo 'BLOCKED: dangerous command' >&2 && exit 2 || exit 0",
            "if": "Bash(git *)"
          }
        ]
      }
    ]
  }
}

Agent verification after code changes

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "agent",
            "prompt": "Verify the code change is correct. Check: 1) No syntax errors, 2) Tests still pass, 3) No regressions. Context: $ARGUMENTS",
            "model": "claude-sonnet-4-6",
            "timeout": 120,
            "statusMessage": "Verifying changes..."
          }
        ]
      }
    ]
  }
}

LLM security review before Bash

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Security review this shell command. Check for: secret leaks, destructive operations, network exfiltration, privilege escalation. Command: $ARGUMENTS. Reply JSON: {\"decision\": \"approve\"} or {\"decision\": \"block\", \"reason\": \"...\"}",
            "model": "claude-haiku-4",
            "timeout": 15
          }
        ]
      }
    ]
  }
}

Slack notification on session end

{
  "hooks": {
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "http",
            "url": "https://hooks.slack.com/services/T00/B00/xxx",
            "headers": {
              "Content-Type": "application/json"
            },
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Auto-save WIP commit when Claude stops

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "git diff --quiet || (git add -A && git commit -m 'WIP: auto-save from Claude session')",
            "async": true,
            "statusMessage": "Auto-saving..."
          }
        ]
      }
    ]
  }
}

Run tests after writing test files

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | grep -q '\\.test\\.' && npm test -- --bail || exit 0",
            "timeout": 60,
            "statusMessage": "Running tests..."
          }
        ]
      }
    ]
  }
}

Block writes to protected files

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | grep -qE '(package-lock\\.json|\\.env|secrets)' && echo 'BLOCKED: protected file' >&2 && exit 2 || exit 0"
          }
        ]
      }
    ]
  }
}

Notify on errors

{
  "hooks": {
    "StopFailure": [
      {
        "matcher": "rate_limit",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude hit rate limit\" with title \"Claude Code\"'",
            "async": true
          }
        ]
      }
    ]
  }
}

Block silent error patterns in code

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/check-silent-errors.sh",
            "timeout": 10,
            "statusMessage": "Checking for silent errors..."
          }
        ]
      }
    ]
  }
}

The check-silent-errors.sh script scans written files for swallowed exceptions and blocks the write (exit 2) if found:

#!/bin/bash
# Block writes that introduce silent error handling
# Catches: bare except, except-pass, empty catch {}, catch-with-only-console.log

# Claude Code pipes the tool payload as JSON on stdin. Check \$1 first so a CI
# caller passing a path never blocks on a `cat` with no stdin behind it.
FILE_PATH="${1:-}"
if [ -z "$FILE_PATH" ] && [ ! -t 0 ]; then
  FILE_PATH=$(cat 2>/dev/null | jq -r '.tool_input.file_path // empty' 2>/dev/null)
fi
[ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0

VIOLATIONS=""

case "$FILE_PATH" in
  *.py)
    # Bare except with no type
    grep -nP '^\s*except\s*:' "$FILE_PATH" | grep -v '# silent-ok' > /tmp/silerr 2>/dev/null && \
      VIOLATIONS="$VIOLATIONS\n  Bare except:\n$(cat /tmp/silerr)"
    # except ... : pass
    grep -nP -A1 '^\s*except\b' "$FILE_PATH" | grep -P '^\s*pass\s*$' > /tmp/silerr 2>/dev/null && \
      VIOLATIONS="$VIOLATIONS\n  except/pass:\n$(grep -nP -B1 '^\s*pass\s*$' "$FILE_PATH" | grep -P 'except' | head -5)"
    # except ... : continue
    grep -nP -A1 '^\s*except\b' "$FILE_PATH" | grep -P '^\s*continue\s*$' > /tmp/silerr 2>/dev/null && \
      VIOLATIONS="$VIOLATIONS\n  except/continue:\n$(grep -nP -B1 '^\s*continue\s*$' "$FILE_PATH" | grep -P 'except' | head -5)"
    # except ... : ...
    grep -nP -A1 '^\s*except\b' "$FILE_PATH" | grep -P '^\s*\.\.\.\s*$' > /tmp/silerr 2>/dev/null && \
      VIOLATIONS="$VIOLATIONS\n  except/...:\n$(grep -nP -B1 '^\s*\.\.\.\s*$' "$FILE_PATH" | grep -P 'except' | head -5)"
    ;;
  *.ts|*.tsx|*.js|*.jsx)
    # Empty catch block
    grep -nP 'catch\s*(\([^)]*\))?\s*\{\s*\}' "$FILE_PATH" | grep -v '// silent-ok' > /tmp/silerr 2>/dev/null && \
      VIOLATIONS="$VIOLATIONS\n  Empty catch block:\n$(cat /tmp/silerr)"
    # catch with only console.log (not console.error/warn)
    grep -nP -A1 'catch\s*(\([^)]*\))?\s*\{' "$FILE_PATH" | grep -P '^\s*console\.log\(' > /tmp/silerr 2>/dev/null && \
      VIOLATIONS="$VIOLATIONS\n  catch with console.log (use console.error):\n$(cat /tmp/silerr)"
    ;;
  *) exit 0 ;;
esac

rm -f /tmp/silerr

if [ -n "$VIOLATIONS" ]; then
  echo "⛔ SILENT ERROR in $FILE_PATH:$VIOLATIONS" >&2
  echo "Fix: add logging + re-raise or return sentinel. Exempt with '# silent-ok'." >&2
  exit 2
fi
exit 0

Pair with a CLAUDE.md rule to also fix existing silent errors when touching old files:

### Development Rules
- **No silent errors** - when editing any file, also fix existing silent error
  patterns (bare `except:`, `except: pass`, empty `catch {}`). Every exception
  handler must log with context and either re-raise or return a sentinel.
  Add `# silent-ok` only for genuinely intentional cases.

Watch .env changes and reload

{
  "hooks": {
    "FileChanged": [
      {
        "matcher": ".envrc|.env",
        "hooks": [
          {
            "type": "command",
            "command": "direnv export json > /tmp/claude-env.json",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Settings File Locations

FileScope
~/.claude/settings.jsonGlobal (all projects)
.claude/settings.jsonProject (committed)
.claude/settings.local.jsonPersonal project (gitignored)

Priority: user settings > project settings > local settings