Skip to content
· 14 min read claude-code automation developer-tools ai workflow ·

Claude Code Hooks: The Field Manual You Actually Need

Claude Code Hooks: The Field Manual You Actually Need

Some links in this article are affiliate links. We may earn a small commission if you purchase through them, at no extra cost to you. See our privacy policy for details.

Stop Letting Claude Run Unsupervised

Here is the problem. You hand Claude Code a task, it starts executing tools, and you are sitting there babysitting every permission prompt like a hall monitor. Or worse, you turn on auto-accept and pray nothing catches fire.

Hooks fix this. They are shell commands, HTTP calls, or AI prompts that fire at specific points in Claude’s execution lifecycle. Think of them as tripwires and gate checks you plant across the workflow. Claude hits a trigger point, your hook runs, and you decide what happens next: allow, deny, modify, log, or block.

No plugins. No extensions. Just config and scripts.

What Hooks Actually Are

A hook is a user-defined action that executes when Claude Code hits a specific event during a session. You are not modifying Claude’s behavior. You are inserting control points around it.

The mental model: hooks are middleware for your AI coding assistant. Same concept as GitA distributed version control system that tracks changes to files over time, enabling collaboration, branching, and complete history of every modification. Read more → hooks, CI pipeline stages, or request interceptors. An event fires, your code runs, and the output determines what happens next.

Hooks can be:

  • Command hooks: Shell scripts or CLI commands that run locally
  • HTTP hooks: Outbound requests to a local or remote endpoint
  • Prompt hooks: An AI prompt that gets evaluated inline
  • Agent hooks: A sub-agent that spins up to handle the check

Most of the time, you are writing command hooks. Shell scripts. The thing you already know how to do.

Where Hooks Live

Configuration goes in JSONA lightweight, human-readable data format used to exchange structured information between systems, based on JavaScript object syntax. Read more → files. Three scopes, hierarchical precedence:

FileScopeShared
~/.claude/settings.jsonAll projects on this machineNo
.claude/settings.jsonThis project, all contributorsYes, commit it
.claude/settings.local.jsonThis project, only youNo, gitignore it

Higher specificity wins. Local overrides project. Project overrides user. Simple chain of command.

The structure inside any of these files:

{
  "hooks": {
    "EventName": [
      {
        "matcher": "ToolNameOrPattern",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/your/script.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

That is the skeleton. Every hook config follows this pattern. Event name as the key, array of matcher objects, each matcher containing an array of hook definitions.

The Event Catalog

Claude Code fires events at every meaningful lifecycle point. Here are the ones that matter for day-to-day operations, grouped by when you would actually care about them.

Session Lifecycle

SessionStart: Fires when a session begins or resumes. Use this to load environment variables, set up tooling context, or initialize project-specific state. Your script gets access to CLAUDE_ENV_FILE; write exports to that file and they persist for the entire session.

SessionEnd: Session terminates. Cleanup, logging, metrics collection.

CwdChanged: Working directory changes mid-session. Reload environment configs, update path-dependent state.

Before a Tool Runs

PreToolUse: The big one. Fires before any tool executes. You can inspect what Claude is about to do and return a decision:

  • allow: Let it through, skip the permission prompt
  • deny: Kill it. Tool does not execute. Claude gets your reason.
  • ask: Force the permission prompt regardless of mode
  • defer: Pause execution for external approval (CI, Slack, whatever)

You can also return updatedInput to modify the tool’s parameters before execution. Rewrite a command, change a file path, sanitize an argument. Claude never knows you touched it.

PermissionRequest: Fires when the permission dialog would appear. Same decision options. This is your programmatic “approve all” or “deny all” gate.

After a Tool Runs

PostToolUse: Tool succeeded. Inspect the output, run validation, trigger linters, log the action, or inject feedback that Claude will see in its context.

PostToolUseFailure: Tool failed. Provide corrective context so Claude adjusts its approach instead of blindly retrying.

Response Lifecycle

Stop: Claude finished its response. You can block the completion and force it to continue, or inject a system message. Useful for enforcing output standards.

SubagentStop: A sub-agent finished. Same controls as Stop but scoped to delegated work.

UserPromptSubmit: Fires before Claude processes what you typed. Add context, validate input, or block the prompt entirely.

Notification Events

Notification: Fires on permission prompts, idle states, auth events. The matcher filters by notification type: permission_prompt, idle_prompt, auth_success.

Matchers: Targeting Your Hooks

The matcher field is a regex pattern that filters which specific tools or event subtypes trigger your hook. No matcher means it fires on everything for that event.

Common patterns:

"Bash"              # Only Bash tool calls
"Write|Edit"        # File write or edit operations
"mcp__memory__.*"   # Any tool from the memory MCP server
"mcp__.*__write.*"  # Any write operation on any MCP server
"*"                 # Everything (same as omitting matcher)

For PreToolUse and PostToolUse, the matcher targets tool names. For Notification, it targets notification types. For SubagentStop, it targets agent types.

There is also the if field for tighter filtering within tool events:

{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "if": "Bash(rm *)",
      "command": "./hooks/block-destructive.sh"
    }
  ]
}

The if pattern matches against the tool name and a substring of its arguments. This hook only fires on Bash calls that contain “rm” in the command. Everything else passes through untouched.

What Your Hook Receives

Every hook gets a JSON payload on stdin with context about the event. The common fields across all events:

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/current/working/directory",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse"
}

Tool events add tool_name and tool_input with the full parameters Claude passed. A Bash hook gets tool_input.command. A Write hook gets tool_input.file_path and tool_input.content. You parse what you need with jq and act on it.

Environment variables available to command hooks:

VariablePurpose
CLAUDE_PROJECT_DIRRoot of the current project
CLAUDE_ENV_FILEFile path for persisting env vars (SessionStart)
CLAUDE_CODE_REMOTE”true” if running in web mode

What Your Hook Returns

Your hook communicates back through exit codes and JSON on stdout.

Exit codes:

  • 0: Success. stdout JSON is processed.
  • 2: Blocking error. stderr is fed back to Claude. Execution halts for this hook.
  • 1, 3+: Non-blocking error on most events. Hook failure does not stop the workflow.

JSON output for PreToolUse:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Blocked: destructive filesystem operation",
    "additionalContext": "Use targeted rm commands instead of recursive wildcards"
  }
}

JSON output for PostToolUse/Stop (blocking):

{
  "decision": "block",
  "reason": "Output failed validation. Fix lint errors before continuing."
}

Universal fields any hook can return:

{
  "continue": true,
  "suppressOutput": false,
  "systemMessage": "Warning shown to the user in the UI",
  "additionalContext": "Injected into Claude's context window"
}

Set continue to false with a stopReason to kill the session. Use additionalContext to feed Claude information it would not otherwise have. Use systemMessage to surface warnings in the terminal.

Practical Cases That Actually Matter

Theory is useless without application. Here are real scenarios, configured and explained.

Case 1: Block Destructive Commands

You do not want Claude running rm -rf, git push --force, DROP TABLE, or anything that nukes state. This is your first hook. Deploy it before anything else.

Script at .claude/hooks/guard-destructive.sh:

#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if [ -z "$COMMAND" ]; then
  exit 0
fi

BLOCKED_PATTERNS=(
  'rm -rf'
  'rm -r /'
  'git push.*--force'
  'git reset --hard'
  'DROP TABLE'
  'DROP DATABASE'
  'mkfs\.'
  '> /dev/sd'
)

for PATTERN in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qE "$PATTERN"; then
    echo "{
      \"hookSpecificOutput\": {
        \"hookEventName\": \"PreToolUse\",
        \"permissionDecision\": \"deny\",
        \"permissionDecisionReason\": \"Blocked: matched destructive pattern '$PATTERN'\"
      }
    }"
    exit 0
  fi
done

exit 0

Config in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-destructive.sh",
            "timeout": 10,
            "statusMessage": "Checking command safety..."
          }
        ]
      }
    ]
  }
}

Make the script executable: chmod +x .claude/hooks/guard-destructive.sh

Claude tries rm -rf /tmp/build, the hook catches it, denies it, and tells Claude why. Claude adjusts. No data lost. No drama.

Case 2: Auto-Lint After Every File Change

Every time Claude writes or edits a file, run the linter. If it fails, feed the errors back so Claude fixes them in the same pass instead of you catching it later.

Script at .claude/hooks/lint-on-write.sh:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [ -z "$FILE_PATH" ]; then
  exit 0
fi

case "$FILE_PATH" in
  *.ts|*.tsx|*.js|*.jsx)
    LINT_OUTPUT=$(cd "$CLAUDE_PROJECT_DIR" && npx eslint "$FILE_PATH" 2>&1)
    LINT_EXIT=$?
    ;;
  *.py)
    LINT_OUTPUT=$(cd "$CLAUDE_PROJECT_DIR" && python -m ruff check "$FILE_PATH" 2>&1)
    LINT_EXIT=$?
    ;;
  *)
    exit 0
    ;;
esac

if [ $LINT_EXIT -ne 0 ]; then
  ESCAPED=$(echo "$LINT_OUTPUT" | jq -Rs .)
  echo "{
    \"additionalContext\": \"Lint errors detected in $FILE_PATH. Fix these before moving on:\\n$LINT_OUTPUT\"
  }"
fi

exit 0

Config:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-on-write.sh",
            "timeout": 30,
            "statusMessage": "Running linter..."
          }
        ]
      }
    ]
  }
}

Claude writes a TypeScript file with an unused import. The hook catches it, feeds the error back, and Claude removes the import. All in one cycle. No second pass needed.

Case 3: Session Bootstrap

Load your NVM version, set environment variables, and prime the session with project context on startup. No more “please use Node 20” prompts.

Script at .claude/hooks/session-init.sh:

#!/bin/bash
if [ -z "$CLAUDE_ENV_FILE" ]; then
  exit 0
fi

# Load NVM and set Node version
export NVM_DIR="$HOME/.nvm"
if [ -s "$NVM_DIR/nvm.sh" ]; then
  source "$NVM_DIR/nvm.sh"
  nvm use 20 > /dev/null 2>&1
fi

# Persist environment for the session
echo "export NODE_ENV=development" >> "$CLAUDE_ENV_FILE"
echo "export EDITOR=vim" >> "$CLAUDE_ENV_FILE"

# If there is a .nvmrc, respect it
if [ -f "$CLAUDE_PROJECT_DIR/.nvmrc" ]; then
  NODE_VERSION=$(cat "$CLAUDE_PROJECT_DIR/.nvmrc")
  echo "export PATH=\"$NVM_DIR/versions/node/v$NODE_VERSION/bin:\$PATH\"" >> "$CLAUDE_ENV_FILE"
fi

exit 0

Config:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-init.sh",
            "timeout": 15,
            "statusMessage": "Initializing session environment..."
          }
        ]
      }
    ]
  }
}

Session starts. Environment is loaded. No asking, no prompting, no wasted cycles.

Case 4: Audit Log for Compliance

Every tool call gets logged with a timestamp, tool name, and parameters. Useful for security reviews, incident reconstruction, or just knowing what happened in a session.

Script at .claude/hooks/audit-log.sh:

#!/bin/bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
SESSION=$(echo "$INPUT" | jq -r '.session_id // "unknown"')

LOG_DIR="$CLAUDE_PROJECT_DIR/.claude/logs"
mkdir -p "$LOG_DIR"

echo "$INPUT" | jq -c "{
  timestamp: \"$TIMESTAMP\",
  session: \"$SESSION\",
  event: \"$EVENT\",
  tool: \"$TOOL_NAME\",
  input: .tool_input
}" >> "$LOG_DIR/audit-$(date +%Y-%m-%d).jsonl"

exit 0

Config:

{
  "hooks": {
    "PreToolUse": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-log.sh",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

Every action, timestamped, structured, searchable. Pipe it into your SIEMA platform that collects, correlates, and analyzes log data from across your infrastructure to detect security threats and support incident investigation. Read more →, grep it for incident responseThe structured process of detecting, containing, eradicating, and recovering from a cybersecurity incident to minimize damage and prevent recurrence. Read more →, or just keep receipts.

Case 5: Protect Sensitive Files

Deny reads or writes to files that contain secrets, credentials, or infrastructure configs that Claude has no business touching.

Script at .claude/hooks/protect-sensitive.sh:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.command // empty')

PROTECTED_PATTERNS=(
  '\.env'
  'credentials'
  '\.pem$'
  '\.key$'
  'secrets\.'
  'terraform\.tfstate'
  'kubeconfig'
  '\.kube/config'
)

for PATTERN in "${PROTECTED_PATTERNS[@]}"; do
  if echo "$FILE_PATH" | grep -qE "$PATTERN"; then
    echo "{
      \"hookSpecificOutput\": {
        \"hookEventName\": \"PreToolUse\",
        \"permissionDecision\": \"deny\",
        \"permissionDecisionReason\": \"Access denied: '$FILE_PATH' matches protected pattern. Sensitive files are off limits.\"
      }
    }"
    exit 0
  fi
done

exit 0

Config:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Write|Edit|Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-sensitive.sh",
            "timeout": 5,
            "statusMessage": "Checking file access..."
          }
        ]
      }
    ]
  }
}

Claude tries to read your .env file to “check the database URL.” Hook says no. Claude works with what it has or asks you for the value. Your secrets stay where they belong.

Case 6: MCP Server Tool Monitoring

If you are running MCPAn open standard for connecting AI assistants to external data sources and tools, enabling them to access real-time information and take actions. Read more → servers (memory, database, external services), you want visibility into what Claude is doing with those tools.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__.*",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-log.sh",
            "timeout": 5,
            "statusMessage": "Logging MCP operation..."
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "mcp__.*__write.*|mcp__.*__delete.*|mcp__.*__update.*",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-log.sh",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

The mcp__<server>__<tool> naming convention makes regex targeting clean. Log everything, or scope it to write/delete operations. Your call.

Case 7: HTTP Webhook for Team Visibility

Push hook events to an external endpoint. Slack notifications, dashboards, approval workflows. Whatever your team needs.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "http",
            "url": "http://localhost:9090/hooks/pre-tool",
            "timeout": 15,
            "headers": {
              "Authorization": "Bearer $HOOK_API_TOKEN"
            },
            "allowedEnvVars": ["HOOK_API_TOKEN"]
          }
        ]
      }
    ]
  }
}

The allowedEnvVars field explicitly whitelists which environment variables get resolved in the headers. Nothing leaks that you did not approve.

Combining Multiple Hooks

You can stack hooks on the same event. They execute in order. If any hook denies or blocks, the chain stops.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-destructive.sh",
            "timeout": 10
          },
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-log.sh",
            "timeout": 5
          }
        ]
      },
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-sensitive.sh",
            "timeout": 5
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-on-write.sh",
            "timeout": 30
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-init.sh",
            "timeout": 15
          }
        ]
      }
    ]
  }
}

This is a real production config. Destructive command guard, audit logging, sensitive file protection, auto-linting, and session bootstrap. All running without you lifting a finger after initial setup.

Async Hooks

Some hooks do not need to block execution. Logging is a perfect example. Set "async": true and the hook fires in the background. Claude does not wait for it.

{
  "type": "command",
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-log.sh",
  "async": true,
  "timeout": 10
}

Use async for: logging, metrics, notifications. Keep synchronous for: guards, validators, linters.

Debugging Hooks

Hooks not firing? Here is the checklist:

  1. Check the file location. Is your config in the right settings file for the scope you need?
  2. Validate the JSON. One misplaced comma kills the entire hooks config. Use jq . .claude/settings.json to validate.
  3. Check script permissions. chmod +x on your hook scripts. This catches people more than anything.
  4. Check the matcher. RegexA pattern language for matching, searching, and manipulating text, used in everything from input validation to log analysis. Read more → is exact. "Bash" matches Bash, not bash. Tool names are PascalCase.
  5. Check exit codes. Exit 0 means success. Exit 2 means blocking error. Anything else is a soft failure.
  6. Read stderr. If your script errors, stderr output shows in the Claude Code interface.
  7. Use /hooks in the CLI. Type it during a session to see all configured hooks and their status.
  8. Test your script standalone. Pipe sample JSON into your script and check the output: echo '{"tool_input":{"command":"rm -rf /"}}' | ./hooks/guard-destructive.sh

Security Considerations

Hooks run with your user permissions. They can do anything you can do. That means:

  • Do not put secrets in hook scripts. Use environment variables.
  • Do not fetch or execute remote code in hooks without verifying the source.
  • Commit .claude/settings.json with your hook configs so the team gets the same guardrails.
  • Keep .claude/settings.local.json in .gitignore for machine-specific overrides.
  • Review hook scripts in PRs the same way you review application code. They are part of your security posture.
  • The allowedEnvVars field on HTTP hooks exists for a reason. Use it. Do not leak tokens through lazy wildcard configs.

When Not to Use Hooks

Hooks are not a silver bullet. Do not use them for:

  • Complex business logic. If your hook script exceeds 50 lines, you are building a service, not a hook. Extract it.
  • Replacing CI/CDThe practice of automatically building, testing, and deploying code changes whenever developers push updates, catching bugs early and shipping faster. Read more →. Hooks run locally. They are not a substitute for pipeline gates.
  • Micromanaging Claude. If you are writing a hook for every possible edge case, you are fighting the tool instead of using it. Set the big guardrails and trust the model for the rest.

The Bottom Line

Hooks give you programmatic control over Claude Code’s execution lifecycle. Set them up once, forget about them, and let them enforce your standards automatically. The five minutes you spend writing a guard script saves you from the one time Claude decides rm -rf is the right answer.

Start with the destructive command guard. Add the sensitive file protector. Layer in audit logging. Then customize from there. That is your deployment order. That is your minimum viable hook setup.

Now stop reading and go configure them.

Related Posts