All Days
Day 35

Advanced Hooks Mastery

Claude Code Mastery // Day 35

Hooks run outside the LLM loop.
Deterministic. Zero token cost. Total control.

Hooks intercept every tool call, session event, and notification before or after it happens. No AI reasoning involved. Pure logic gates you define.

8
lifecycle events
0
token cost
4
exit codes

The Complete Event System

Every hook fires on a specific lifecycle event. 8 events cover the entire Claude Code runtime, from session start to teardown.

EventFires WhenUse For
PreToolUseBefore any tool executesBlock operations, inject context, validate inputs
PostToolUseAfter any tool completesLint code, format output, log changes
SessionStartSession opensLoad dynamic context, check environment
StopClaude finishes respondingNotify user, log cost, trigger downstream
NotificationClaude needs user inputSound alert, system notification
InstructionsLoadedCLAUDE.md files loadedDebug which rules loaded, log active config
WorktreeCreateNew worktree createdCustom initialization, env setup
WorktreeRemoveWorktree removedCustom teardown, cleanup temp files
PreToolUse and PostToolUse are the workhorses. The other 6 handle session lifecycle. Most setups only need 3–4 events total.

The Exit Code System

Your hook's exit code tells Claude what to do next. Three possible outcomes, each with different behavior.

0
Allow
Proceed as planned. The tool call or event continues normally. If the hook outputs JSON, Claude receives it as context.
1
Soft Fail
Something went wrong but it is recoverable. Claude can retry the action or adjust its approach based on the hook's output.
2
Block
Hard stop. Claude cannot proceed with this action. Your hook's stderr becomes the error message Claude sees.
#!/bin/bash
# Example: block dangerous bash commands

COMMAND="$CLAUDE_TOOL_INPUT_COMMAND"

if echo "$COMMAND" | grep -qE "rm -rf|git push --force|git reset --hard"; then
  echo "BLOCKED: dangerous command detected" >&2
  exit 2   # Hard block
fi

exit 0     # Allow everything else

Context Injection

When a hook returns JSON to stdout, Claude reads it as a message. This creates a feedback loop where your tools inform the AI without spending tokens on reasoning.

1PostToolUse hook fires after Claude edits a file
2Hook runs eslint on the edited file, finds 3 errors
3Hook outputs JSON to stdout with the error details
4Claude reads the message and auto-fixes the errors without you asking
#!/bin/bash
# PostToolUse hook: auto-lint after edits

FILE="$CLAUDE_TOOL_INPUT_FILE_PATH"

# Only lint JS/TS files
if [["$FILE" =~ \.(js|ts|jsx|tsx)$ ]]; then
  RESULT=$(npx eslint "$FILE" 2>&1)
  if [ $? -ne 0 ]; then
    # Return JSON that Claude will read as a message
    echo "{\"type\": \"text\", \"text\": \"ESLint found errors in $FILE:\\n$RESULT\"}"
    exit 0  # exit 0 = allow, but Claude sees the message
  fi
fi

exit 0
The key insight: exit 0 + JSON stdout = Claude proceeds AND receives your context. This is how you build feedback loops without blocking anything.

The 3 Hook Types

Most people only know shell scripts. Two more types exist and they unlock patterns that shell scripts cannot reach.

Type A: Shell Script Hooks

The basics. Most people know this one.

A shell script that runs on the event. Receives tool input via environment variables. Returns exit code + optional JSON.

  • Full access to the filesystem and CLI tools
  • Can run any language (bash, python, node)
  • Best for: file ops, linting, git checks, system commands
// settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": { "tool": "Bash" },
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/bash-safety.sh"
          }
        ]
      }
    ]
  }
}

Type B: HTTP Hooks

Call external services. Almost nobody uses these.

Instead of running a script, send an HTTP request to an endpoint. The response body becomes the hook output. Perfect for integrating with external systems.

  • Trigger Slack notifications on specific events
  • Update Notion pages when files change
  • Log to analytics or monitoring dashboards
  • Call webhooks on any platform
// settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": { "tool": "Bash", "command": "git push" },
        "hooks": [
          {
            "type": "http",
            "url": "https://hooks.slack.com/services/T00/B00/xxx",
            "method": "POST"
          }
        ]
      }
    ]
  }
}

Type C: Prompt Hooks

AI-powered safety checks. Almost nobody knows these exist.

Run an LLM on the hook event data. The prompt receives the tool input and returns a judgment. Use this for nuanced decisions that regex cannot handle.

  • AI code reviewer before every bash execution
  • Semantic safety checks on file writes
  • Context-aware permission decisions
  • Natural language policy enforcement
// settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": { "tool": "Bash" },
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Is this bash command safe? If risky, respond EXIT_CODE=2. Command: {{input.command}}"
          }
        ]
      }
    ]
  }
}
Cost note: Prompt hooks DO consume tokens since they invoke an LLM. Use sparingly on high-risk events only. Shell hooks remain at zero token cost.

Async Hooks

Add "async": true and the hook runs in the background. Claude does not wait for it. Use for fire-and-forget operations.

When to use async

Logging to external services, analytics tracking, sending notifications, syncing state. Anything where you do not need the result to decide whether to proceed.

Critical limitation: Async hooks cannot block. Exit codes are ignored. If you need to prevent an action, the hook must be synchronous.
// settings.json — async logging hook
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": {},
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/log-changes.sh",
            "async": true
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# log-changes.sh — runs in background after every tool use

DATE=$(date +"%Y-%m-%d %H:%M:%S")
TOOL="$CLAUDE_TOOL_NAME"
FILE="$CLAUDE_TOOL_INPUT_FILE_PATH"

echo "[$DATE] $TOOL on $FILE" >> ~/.claude/changes-$(date +%Y-%m-%d).log

Matcher Patterns

Matchers filter which tool calls trigger your hook. Click any pattern below to highlight it.

{"tool": "Edit"}
Matches Edit tool only
{"tool": "Bash", "command": "rm"}
Matches rm commands in Bash
{"tool": "Write", "path": "*.env"}
Blocks .env file writes
{}
Matches everything (wildcard)
{"tool": "Bash", "command": "git commit"}
Intercepts git commits
{"tool": "Write", "path": "src/*"}
Matches writes to src directory

Matcher Builder

Select a tool and optional filter to generate the matcher JSON.

{}

The 10 Most Powerful Hook Patterns

Production-ready patterns. Each one is a full implementation you can copy and use immediately.

After Claude edits a JS/TS file, ESLint runs automatically. If errors are found, the results are returned as JSON context. Claude reads the errors and fixes them in the next turn without you asking.

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

---

#!/bin/bash
# ~/.claude/hooks/auto-lint.sh

FILE=$"$CLAUDE_TOOL_INPUT_FILE_PATH"
if [[ $FILE =~ \.(js|ts|jsx|tsx)$ ]]; then
  RESULT=$(npx eslint "$FILE" --format compact 2>&1)
  if [ $? -ne 0 ]; then
    echo '{"type":"text","text":"ESLint errors in $FILE:\n$RESULT"}'
  fi
fi
exit 0

Every time Claude finishes a response, this hook logs the session cost to a daily log file. Over time, you build a complete cost history across all sessions.

// settings.json
{
  "hooks": {
    "Stop": [{
      "matcher": {},
      "hooks": [{
        "type": "command",
        "command": "bash ~/.claude/hooks/cost-tracker.sh",
        "async": true
      }]
    }]
  }
}

---

#!/bin/bash
# ~/.claude/hooks/cost-tracker.sh

DATE=$(date +"%Y-%m-%d %H:%M:%S")
COST="$CLAUDE_SESSION_COST"
PROJECT="$CLAUDE_PROJECT_DIR"

echo "[$DATE] cost=$COST project=$PROJECT" >> ~/.claude/cost-log.csv

When working on Client A, block any write to Client B's folder. Prevents cross-contamination of client data during multi-project sessions.

#!/bin/bash
# ~/.claude/hooks/client-protect.sh

FILE="$CLAUDE_TOOL_INPUT_FILE_PATH"
ALLOWED_CLIENT="$CLAUDE_ACTIVE_CLIENT"  # Set via env var

# Check if writing to a client folder that's not the active one
if echo "$FILE" | grep -q "01_Projects/Clients/"; then
  if ! echo "$FILE" | grep -q "$ALLOWED_CLIENT"; then
    echo "BLOCKED: Cannot write to another client's folder" >&2
    echo "Active client: $ALLOWED_CLIENT" >&2
    exit 2
  fi
fi

exit 0

Intercepts git commit commands and scans staged files for API keys, tokens, passwords, and other secrets. Blocks the commit if anything suspicious is found.

#!/bin/bash
# ~/.claude/hooks/secret-scan.sh

CMD="$CLAUDE_TOOL_INPUT_COMMAND"

# Only run on git commit commands
if ! echo "$CMD" | grep -q "git commit"; then
  exit 0
fi

# Check staged files for secrets
SECRETS=$(git diff --cached --diff-filter=ACM -z --name-only | \
  xargs -0 grep -lE \
  "(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9]{48}|ghp_[a-zA-Z0-9]{36})" \
  2>/dev/null)

if [ -n "$SECRETS" ]; then
  echo "BLOCKED: Potential secrets found:" >&2
  echo "$SECRETS" >&2
  exit 2
fi

exit 0

Combines a shell hook to detect config file changes with an HTTP hook to send a Slack notification. Two hooks chained on the same event.

// settings.json
{
  "hooks": {
    "PostToolUse": [{
      "matcher": { "tool": "Edit", "path": "*config*" },
      "hooks": [
        {
          "type": "command",
          "command": "bash ~/.claude/hooks/check-config-change.sh"
        },
        {
          "type": "http",
          "url": "https://hooks.slack.com/services/T00/B00/xxx",
          "method": "POST",
          "headers": { "Content-Type": "application/json" },
          "body": "{\"text\": \"Config file modified by Claude Code.\"}",
          "async": true
        }
      ]
    }]
  }
}

A prompt hook that runs an LLM judgment on every bash command. For high-risk environments where regex patterns are not enough and you need semantic understanding of intent.

// settings.json
{
  "hooks": {
    "PreToolUse": [{
      "matcher": { "tool": "Bash" },
      "hooks": [{
        "type": "prompt",
        "prompt": "Analyze this bash command for safety risks. If risky, respond EXIT_CODE=2 with explanation. If safe, respond EXIT_CODE=0. Command: {{input.command}}"
      }]
    }]
  }
}
Token cost: This fires on every bash command. Use with caution. Consider narrowing scope with {"command": "rm"} matcher filter.

Before Claude edits any file, create a timestamped backup. Keeps the last 10 versions. Cheap insurance against bad edits.

#!/bin/bash
# ~/.claude/hooks/file-backup.sh

FILE="$CLAUDE_TOOL_INPUT_FILE_PATH"
BACKUP_DIR=~/.claude/backups

if [ -f "$FILE" ]; then
  BASENAME=$(basename "$FILE")
  TIMESTAMP=$(date +%Y%m%d-%H%M%S)
  mkdir -p "$BACKUP_DIR"
  cp "$FILE" "$BACKUP_DIR/${BASENAME}.${TIMESTAMP}.bak"

  # Keep only last 10 backups per file
  ls -t "$BACKUP_DIR/${BASENAME}."*.bak 2>/dev/null | \
    tail -n +11 | xargs rm -f 2>/dev/null
fi

exit 0

After a git push, extract the branch name (which contains the Jira ticket ID) and update the ticket status via Jira API. Keeps project management in sync automatically.

#!/bin/bash
# ~/.claude/hooks/jira-update.sh

CMD="$CLAUDE_TOOL_INPUT_COMMAND"
if ! echo "$CMD" | grep -q "git push"; then
  exit 0
fi

# Extract ticket ID from branch name (e.g., feat/PROJ-123-description)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
TICKET=$(echo "$BRANCH" | grep -oE "[A-Z]+-[0-9]+")

if [ -n "$TICKET" ]; then
  curl -s -X POST \
    -H "Authorization: Basic $JIRA_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"transition": {"id": "31"}}' \
    "https://your-org.atlassian.net/rest/api/3/issue/$TICKET/transitions"
fi

exit 0

Plays a sound and shows a macOS notification when Claude finishes responding. Walk away from your desk and know when to come back.

#!/bin/bash
# ~/.claude/hooks/stop-notify.sh

# Play system sound
afplay /System/Library/Sounds/Glass.aiff &

# macOS notification
osascript -e 'display notification "Claude has finished." with title "Claude Code" sound name "Glass"'

exit 0

When a session starts, gather dynamic context (git status, open PRs, current branch, recent commits) and inject it as a JSON message. Claude starts every session already knowing the project state.

#!/bin/bash
# ~/.claude/hooks/session-context.sh

BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
STATUS=$(git status --short 2>/dev/null | head -20)
RECENT=$(git log --oneline -5 2>/dev/null)
OPEN_PRS=$(gh pr list --limit 5 --json title,number 2>/dev/null)

CONTEXT=$(cat <<EOF
Branch: $BRANCH
Modified files:
$STATUS

Recent commits:
$RECENT

Open PRs: $OPEN_PRS
EOF
)

echo '{"type":"text","text":"Session context:\n$CONTEXT"}'
exit 0

Hook Builder

Configure your hook and get ready-to-paste code. Select your options, generate the settings.json snippet and the shell script.

Run async (background, cannot block)
Generated settings.json snippet
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": {},
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/my-hook.sh"
          }
        ]
      }
    ]
  }
}
Generated shell script
#!/bin/bash
# ~/.claude/hooks/my-hook.sh

# Add your logic here
echo "Hook fired on PreToolUse"

exit 0

Knowledge Check

5 questions. Test what you just learned.

Q1: What exit code blocks a tool call?

Q2: What makes async hooks different from regular hooks?

Q3: What is a "prompt hook" used for?

Q4: If a PostToolUse hook returns JSON with a message, what happens?

Q5: Which hook event fires when Claude finishes a response?

don't miss what's next.

playbooks, templates, and tools that actually save you hours. straight to your inbox. no spam. unsubscribe anytime.

AY Automate // Claude Code 30-Day Series // Day 35 — Advanced Hooks Mastery // 2026