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.
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:
| Event | When It Fires |
|---|---|
PreToolUse | Before any tool executes — can block the call |
PostToolUse | After a tool completes — can give feedback |
Stop | When Claude finishes a session (exits) — can block close |
SessionStart | When a new session opens |
SessionEnd | When 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) and full regex — but they match the tool name only. There is no matcher syntax for a command's content. To target only Bash calls running git, match Bash and have the hook script inspect tool_input.command from the stdin payload (covered below).
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 to the User
The tool call proceeds, and the hook's stderr is shown to the user (visible in verbose mode). Claude never sees it. Use this for operator-facing signals: "this looks unusual, but proceed."
#!/bin/bash
echo "WARNING: Editing a file outside the expected src/ directory." >&2
exit 1
If you want Claude — not just the user — to receive a non-blocking advisory, exit 0 and print JSON to stdout with additionalContext. The message is added to Claude's context without blocking the tool call:
#!/bin/bash
cat <<'JSON'
{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "ADVISORY: this file has been edited repeatedly. Consider whether you are making progress."}}
JSON
exit 0
The Hook Payload
Every hook receives a JSON envelope on stdin — not the bare tool parameters. The tool's parameters are nested under tool_input, and for PostToolUse hooks the tool's result arrives under tool_response:
{
"session_id": "abc123",
"transcript_path": "~/.claude/projects/.../session.jsonl",
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": { "file_path": "/src/app.py", "old_string": "...", "new_string": "..." },
"tool_response": { "...": "..." }
}
The most common hook-writing bug is parsing the parameters flat — d.get('file_path') against the envelope resolves to an empty string on every real invocation, so the hook silently exits 0 forever and the guard never fires. Always extract through the nesting: d.get('tool_input', {}).get('file_path', '') in Python, or jq -r '.tool_input.command' in shell.
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
# Key state on the session_id from the hook payload — NOT on $$.
# Every hook invocation is a fresh process, so $$ differs each time and
# the Stop hook would never find files written by the PostToolUse hooks.
HOOK_INPUT=$(cat)
SESSION_ID=$(echo "$HOOK_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null)
[ -z "$SESSION_ID" ] && exit 0
STATE_FILE="/tmp/claude-edits-$SESSION_ID"
TEST_RUN_FILE="/tmp/claude-tests-$SESSION_ID"
# 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
# Portable mtime: stat -f%m is BSD/macOS, stat -c%Y is GNU/Linux.
LAST_EDIT=$(stat -f%m "$STATE_FILE" 2>/dev/null || stat -c%Y "$STATE_FILE" 2>/dev/null || echo "0")
LAST_TEST=$(stat -f%m "$TEST_RUN_FILE" 2>/dev/null || stat -c%Y "$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
Two companion PostToolUse hooks maintain the state files, keyed on the same session_id. The edit tracker (matcher Edit|Write) increments the edit count; the test tracker (matcher Bash) touches $TEST_RUN_FILE whenever a test command is detected:
#!/bin/bash
# test-run-tracker.sh -- PostToolUse on Bash
HOOK_INPUT=$(cat)
SESSION_ID=$(echo "$HOOK_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null)
COMMAND=$(echo "$HOOK_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('command',''))" 2>/dev/null)
[ -z "$SESSION_ID" ] && exit 0
echo "$COMMAND" | grep -qE "pytest|npm test|vitest" && touch "/tmp/claude-tests-$SESSION_ID"
exit 0
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
HOOK_INPUT=$(cat) # Hook payload JSON arrives on stdin — params are under tool_input
FILE_PATH=$(echo "$HOOK_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# Portable hash: cksum is in POSIX and present on both macOS and Linux
# (md5sum is GNU-only; macOS ships md5). Any stable digest of the path works.
COUNT_FILE="/tmp/claude-edit-count-$(echo "$FILE_PATH" | cksum | cut -d' ' -f1)"
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
# Non-blocking advisory that Claude actually sees: JSON additionalContext on exit 0.
# (Exit 1 stderr only reaches the user — it cannot nudge the model.)
cat <<JSON
{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "ADVISORY: '$FILE_PATH' has been edited $COUNT times. Consider whether you are making progress."}}
JSON
exit 0
fi
exit 0
The graduated response is intentional: the 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 the next lesson. 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
HOOK_INPUT=$(cat)
FILE_PATH=$(echo "$HOOK_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)
OFFSET=$(echo "$HOOK_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('offset',''))" 2>/dev/null)
LIMIT=$(echo "$HOOK_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).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
Quality-Gate Loops: Iteration Enforcement via Exit 2
A quality-gate loop is a pattern for keeping Claude iterating until quality criteria are met. (This is distinct from the community "Ralph" technique — Geoffrey Huntley's Ralph Wiggum approach — which is an external while loop that re-feeds the same prompt to a fresh session; here the loop lives inside a single session, driven by a PostToolUse hook.)
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
HOOK_INPUT=$(cat)
COMMAND=$(echo "$HOOK_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('command',''))" 2>/dev/null)
# Only fire on pytest runs
echo "$COMMAND" | grep -q "pytest" || exit 0
# PostToolUse hooks receive the tool's result under tool_response — not 'output'
RESULT=$(echo "$HOOK_INPUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
r = d.get('tool_response', {})
print(r.get('stdout', '') if isinstance(r, dict) else str(r))
" 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:
| Type | Description |
|---|---|
command | Shell script. Receives the hook payload on stdin. |
prompt | LLM evaluation. Claude itself evaluates the tool call. |
agent | A full Claude Code subagent as verifier. |
command is the documented, universally available hook type and covers nearly every enforcement use case — it is the only one this lesson's examples rely on. LLM-evaluated hook types like prompt and agent move evaluation into the model itself; verify them against the current hooks schema before depending on them, and reserve them for complex judgment calls that cannot be expressed in a shell script (they are also far more expensive than a command hook).
Filtering on Command Content
Matchers target tool names only — there is no config-level filter on a command's content. To fire only on specific commands, match the tool and inspect the payload inside the script:
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/git-guard.sh" }]
}
#!/bin/bash
# git-guard.sh -- PreToolUse on Bash
COMMAND=$(cat | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('command',''))" 2>/dev/null)
case "$COMMAND" in
*"git push origin main"*|*"git push --force"*)
echo "BLOCKED: direct pushes to main are not allowed. Create a branch and open a PR." >&2
exit 2
;;
esac
exit 0
The hook fires on every Bash call but exits 0 instantly unless the command matches. 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 Drill
- Create
~/.claude/hooks/directory - Implement the doom-loop-detector.sh script from this lesson
- Wire it into settings.json as a PostToolUse hook on
Edit|Write - Open a session, edit a file five times, and verify the advisory fires
- 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.