ASK KNOX
beta
LESSON 182

Hook Architecture — Exit Codes and Enforcement

Hooks are Claude Code's nervous system — they fire before and after every tool call. Exit codes determine whether Claude proceeds, gets warned, or is forced to change course.

8 min read·Claude Code Operations

Every tool call Claude makes — every file read, every bash command, every edit — can be intercepted.

That is what hooks are: lifecycle callbacks that run before or after tool execution. They are how you enforce quality gates, detect loops, prevent mistakes, and build the kind of disciplined agent behavior that separates production systems from demo environments.

Hook Lifecycle Events

Claude Code fires hooks at specific points in its execution:

EventWhen It Fires
PreToolUseBefore any tool executes — can block the call
PostToolUseAfter a tool completes — can give feedback
StopWhen Claude finishes a session (exits) — can block close
SessionStartWhen a new session opens
SessionEndWhen a session closes cleanly

PreToolUse and PostToolUse are the workhorses. Stop is for quality gates that must pass before Claude considers work done.

Matchers: Targeting Specific Tools

Every hook has a matcher that determines which tool calls trigger it. The matcher is a regex applied to the tool name.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/read-limit-guard.sh" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/doom-loop-detector.sh" }]
      }
    ],
    "Stop": [
      {
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/test-gate.sh" }]
      }
    ]
  }
}

Matchers support pipe-separated alternatives (Edit|Write), full regex, and specific patterns like Bash(git *) to match only Bash calls with git commands.

The Three Exit Codes

This is the core of hook architecture. The exit code your hook script returns determines what Claude does next.

Exit 0 — Silent Pass

The tool call proceeds. No output is injected into Claude's context. Use this when the hook checks pass and nothing needs Claude's attention.

#!/bin/bash
# Everything looks fine
exit 0

Exit 2 — Blocking Feedback

This is the most powerful exit code. When your hook exits 2, its stderr output is fed back to Claude as a feedback message. Claude must respond to it. The original tool call is blocked.

#!/bin/bash
echo "BLOCKED: File has been edited 8 times. Stop and ask the user for guidance." >&2
exit 2

Exit 2 is how you enforce hard rules: tests must pass, files cannot be edited infinitely, certain commands require confirmation. Claude cannot ignore exit 2 — the message is injected into its context and it is required to address it.

Exit 1 (and Other Non-Zero Codes) — Non-Blocking Warning

The tool call proceeds, but the hook output is visible in verbose mode. Claude sees it but does not have to respond to it. Use this for advisory signals: "this looks unusual but proceed" rather than "you must stop."

#!/bin/bash
echo "WARNING: Editing a file outside the expected src/ directory." >&2
exit 1

Production Example: test-gate.sh (Stop Hook)

This hook fires when Claude tries to end a session. It blocks the close if source files were edited without running tests.

#!/bin/bash
# test-gate.sh -- Stop hook
# Blocks session close if source files were edited without test run

STATE_FILE="/tmp/claude-edits-$$"
TEST_RUN_FILE="/tmp/claude-tests-$$"

# Check if any source files were edited this session
if [ ! -f "$STATE_FILE" ]; then
  exit 0  # No edits tracked, safe to close
fi

EDIT_COUNT=$(cat "$STATE_FILE" 2>/dev/null || echo "0")

# Check if tests were run since last edit
if [ ! -f "$TEST_RUN_FILE" ]; then
  echo "BLOCKED: $EDIT_COUNT source files were edited but tests have not been run." >&2
  echo "Run the test suite before ending this session." >&2
  exit 2
fi

LAST_EDIT=$(stat -f "%m" "$STATE_FILE" 2>/dev/null || echo "0")
LAST_TEST=$(stat -f "%m" "$TEST_RUN_FILE" 2>/dev/null || echo "0")

if [ "$LAST_EDIT" -gt "$LAST_TEST" ]; then
  echo "BLOCKED: Source files were edited after the last test run." >&2
  echo "Re-run tests to verify your changes before closing." >&2
  exit 2
fi

exit 0

The companion PostToolUse hook on Bash calls updates $TEST_RUN_FILE whenever a test command is detected in the bash invocation.

Production Example: doom-loop-detector.sh (PostToolUse)

The doom loop is one of the most expensive failure modes in agentic coding: Claude edits a file, something does not work, it edits again, still does not work, edits again, and so on. Without a circuit breaker, this can run for 20+ iterations before Claude gives up or the user intervenes.

#!/bin/bash
# doom-loop-detector.sh -- PostToolUse on Edit|Write
# Advisory at 5 edits, blocking at 8

TOOL_INPUT=$(cat)  # Tool input JSON arrives on stdin
FILE_PATH=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('file_path',''))" 2>/dev/null)

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

COUNT_FILE="/tmp/claude-edit-count-$(echo "$FILE_PATH" | md5sum | cut -c1-8)"
COUNT=0

if [ -f "$COUNT_FILE" ]; then
  COUNT=$(cat "$COUNT_FILE")
fi

COUNT=$((COUNT + 1))
echo "$COUNT" > "$COUNT_FILE"

if [ "$COUNT" -ge 8 ]; then
  echo "LOOP DETECTED: '$FILE_PATH' has been edited $COUNT times in this session." >&2
  echo "You may be stuck. Stop and ask the user how to proceed." >&2
  exit 2
elif [ "$COUNT" -ge 5 ]; then
  echo "ADVISORY: '$FILE_PATH' has been edited $COUNT times. Consider whether you are making progress." >&2
  exit 1
fi

exit 0

The graduated response is intentional: advisory at 5 gives Claude a chance to self-correct, blocking at 8 forces human intervention.

Production Example: read-limit-guard.sh (PreToolUse)

Covered in detail in Lesson 140. The short version: this hook fires before every Read call, counts the target file's lines, and exits 2 if the file exceeds 2000 lines without offset/limit parameters.

#!/bin/bash
# read-limit-guard.sh -- PreToolUse on Read
TOOL_INPUT=$(cat)
FILE_PATH=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('file_path',''))" 2>/dev/null)
OFFSET=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('offset',''))" 2>/dev/null)
LIMIT=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('limit',''))" 2>/dev/null)

[ -z "$FILE_PATH" ] && exit 0
[ -n "$OFFSET" ] && exit 0
[ -n "$LIMIT" ] && exit 0
[ ! -f "$FILE_PATH" ] && exit 0

LINE_COUNT=$(wc -l < "$FILE_PATH" 2>/dev/null || echo "0")

if [ "$LINE_COUNT" -gt 2000 ]; then
  echo "BLOCKED: '$FILE_PATH' has $LINE_COUNT lines (limit: 2000 per Read call)." >&2
  echo "Use offset and limit parameters to read this file in chunks." >&2
  exit 2
fi

exit 0

RALPH Loops: Iteration Enforcement via Exit 2

RALPH (Recursive Agentic Loop with Hard Pass) is a pattern for keeping Claude iterating until quality criteria are met.

The pattern: your PostToolUse hook evaluates the output of each tool call. If quality criteria are not met, it exits 2 with specific instructions. Claude is forced to try again with better inputs.

Example: a PostToolUse hook on Bash that checks test coverage:

#!/bin/bash
TOOL_INPUT=$(cat)
COMMAND=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('command',''))" 2>/dev/null)

# Only fire on pytest runs
echo "$COMMAND" | grep -q "pytest" || exit 0

RESULT=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('output',''))" 2>/dev/null)

# Check coverage line
COVERAGE=$(echo "$RESULT" | grep -o 'TOTAL.*[0-9]\+%' | grep -o '[0-9]\+%' | tail -1 | tr -d '%')

if [ -n "$COVERAGE" ] && [ "$COVERAGE" -lt 90 ]; then
  echo "COVERAGE GATE FAILED: $COVERAGE% < 90% minimum." >&2
  echo "Write tests to cover the uncovered lines before proceeding." >&2
  exit 2
fi

exit 0

Claude will keep writing tests until coverage reaches 90%, because every run below threshold triggers an exit 2 that forces it to try again.

Hook Types

Beyond command (shell scripts), hooks support:

TypeDescription
commandShell script. Receives tool input on stdin.
promptLLM evaluation. Claude itself evaluates the tool call.
agentA full Claude Code subagent as verifier.
httpWebhook to an external endpoint.

For most enforcement use cases, command is fastest and most reliable. prompt and agent types are expensive — reserve them for complex judgment calls that cannot be expressed in a shell script.

The if Filter

Beyond matchers, hooks support an if condition for fine-grained targeting:

{
  "matcher": "Bash",
  "if": "input.command.startsWith('git')",
  "hooks": [{ "type": "command", "command": "~/.claude/hooks/git-guard.sh" }]
}

This fires only on Bash calls where the command starts with git. Useful for enforcing branch policies, requiring PR descriptions, or blocking force pushes.

Complete Settings.json Structure

{
  "cleanupPeriodDays": 365,
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/read-limit-guard.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/doom-loop-detector.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/test-gate.sh"
          }
        ]
      }
    ]
  }
}

Lesson 182 Drill

  1. Create ~/.claude/hooks/ directory
  2. Implement the doom-loop-detector.sh script from this lesson
  3. Wire it into settings.json as a PostToolUse hook on Edit|Write
  4. Open a session, edit a file five times, and verify the advisory fires
  5. Edit it three more times and verify the exit 2 blocking fires

When you see Claude respond to your hook's blocking message and ask for guidance instead of continuing to edit, the circuit breaker is working.