Anthropic's annualized run-rate revenue more than tripled year over year to $30 billion by April 2026, helped substantially by Claude Code (Fortune, 2026). And yet only 29% of developers trust AI output to be accurate, down from 40% a year earlier, while 84% use or plan to use the tools anyway (Stack Overflow Developer Survey 2025, 2025).
Claude Code hooks are how you close that gap. They sit outside the model loop, run as plain shell scripts, and either allow or block what the agent is about to do. No prompt-engineering, no vibes, just exit codes.
I've been running hooks in production for eight months across two repos. This is the working set: 12 patterns I trust, plus the one Stop-hook footgun that's currently the top-voted complaint on Hacker News.
the broader Claude Code workflow guide that hooks plug into
Key Takeaways
- Hooks are deterministic shell scripts that intercept Claude Code's tool calls, they run outside the model so the agent can't talk its way past them.
- Exit code 2 blocks; exit code 1 does not. Writing JSON
decision: "block"to stdout fromexit 0silently fails, the #1 reported gotcha (Hacker News, 2026).- Stop hooks behave as advisory in practice: a correct block tells Claude to keep going, and users report it stopping anyway. Replace them with
PostToolUseflags +PreToolUsegates if you need real enforcement.
Why Are Claude Code Hooks the Most Underrated Production Tool?
Stack Overflow's 2025 survey found 29% of developers trusting AI accuracy against 46% who actively distrust it, and 52% who either reject agents outright or keep AI pinned to autocomplete (Stack Overflow, 2025). Hooks are what change that. They turn "I hope the agent doesn't rm -rf" into a regex check that runs in 4ms.
The model can be jailbroken. Prompt instructions can be overridden by a clever tool result. Even Claude Code's own deny-rules silently stop checking once a command chains more than 50 subcommands with &&, ||, or ;. Adversa AI demonstrated the bypass by padding a blocked curl with 50 harmless true commands (Adversa AI, 2026). A hook is the layer that doesn't care what the model wants to do; it cares what's actually being executed.
The hooks reference now documents 31 event names. Nine cover the vast majority of real usage, PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, Notification, SessionStart, SessionEnd and PreCompact, and the rest are specialized hooks for narrower moments in the loop (PostToolBatch, PostToolUseFailure, PermissionRequest, Setup, WorktreeCreate, and friends) (Claude Code Hooks Reference, 2026).
Why this matters more than it looks: When 1.1M+ public GitHub repos already import an LLM SDK and 693,867 of those landed in the last 12 months alone (a 178% jump year-over-year (GitHub Octoverse 2025, 2025)) the surface area for unattended agent mistakes is now enormous. Deterministic gates are no longer optional infrastructure.
the broader agentic AI shift that makes hook discipline a baseline skill
How Does the Hook Lifecycle Actually Work?
Each hook is a shell command Claude Code spawns at a specific event, piping a JSON payload to stdin. The script reads stdin, decides, and exits. Anthropic's docs are blunt: "For most hook events, only exit code 2 blocks the action. Claude Code treats exit code 1 as a non-blocking error and proceeds with the action" (Claude Code Hooks Reference, 2026). This single line is responsible for more broken hooks than every other gotcha combined.
Every hook receives the same base payload:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/repo",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf /" }
}
The script's response is read in two ways. Exit code is checked first. Then, for PreToolUse only, Claude Code parses stdout for a hookSpecificOutput JSON object that can return permissionDecision: "allow" | "deny" | "ask" | "defer" plus an updatedInput payload to rewrite the tool call before it runs. Other events use a top-level decision: "block" field with a reason string fed back to the model.
Hooks are configured in ~/.claude/settings.json (user-wide), .claude/settings.json (project, checked in), or .claude/settings.local.json (project, gitignored). Project settings override user. Plugin hooks override both. A starter PreToolUse entry looks like this:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/guard.py" }]
}
]
}
}
why deterministic context layers like hooks beat in-prompt instructions
Which Claude Code Hooks Stop Dangerous Commands? (Patterns 1–4)
Sonatype detected 34,319 new open-source malware packages in Q3 2025 alone (a 140% jump quarter over quarter) and its Repository Firewall blocked 110,270 attacks in the same window (Sonatype, 2025). The validation patterns below are how you stop a confused agent from pip install-ing the next Shai-Hulud worm into your repo.
Pattern 1: Bash deny-list. A PreToolUse hook on the Bash matcher that regex-checks tool_input.command:
#!/usr/bin/env python3
import json, re, sys
DANGER = re.compile(r"\brm\s+-rf\s+/|curl[^|]*\|\s*(ba)?sh|sudo\s|dd\s+if=|:\(\)\{")
data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")
if DANGER.search(cmd):
print(f"Blocked dangerous command: {cmd}", file=sys.stderr)
sys.exit(2) # exit 2 = BLOCK; do not use exit 1
sys.exit(0)
Pattern 2: Secret scanner on writes. Same shape, but matcher Edit|Write|MultiEdit and the regex hits AWS keys, sk-ant-… Anthropic keys, GitHub PATs, and BEGIN PRIVATE KEY blocks inside tool_input.content. Triggers the moment the agent tries to write a secret to a file, not after.
Pattern 3 (Branch protection). PreToolUse on Bash that blocks git push and git commit against main or master whenever HEAD points at a protected branch:
import json, subprocess, sys
data = json.load(sys.stdin)
cmd = data["tool_input"]["command"]
if any(x in cmd for x in ("git push", "git commit")):
branch = subprocess.check_output(["git", "branch", "--show-current"]).decode().strip()
if branch in ("main", "master"):
print(f"Refusing direct write to {branch}. Open a feature branch.", file=sys.stderr)
sys.exit(2)
Pattern 4: Dependency lockdown. Same Bash matcher; intercepts npm install <pkg>, pip install <pkg>, cargo add <pkg> against an allowlist file. Shai-Hulud, the first documented self-replicating open-source worm, compromised more than 500 packages in days by spreading autonomously across registries and developer machines, and the same year brought hijackings of trusted packages like chalk and debug (Sonatype State of the Software Supply Chain, 2026). An allowlist hook is the cheapest mitigation that exists.
What broke before I added Pattern 1: Claude Sonnet 4.5, on a session where I was tired and accept-edits was on, generated a
find . -name "*.tmp" -deletethat picked up a.tmpdirectory I'd been using as scratch storage. Eight hours of CSV exports gone. The deny-list regex took 12 minutes to write and would have caught it.
the codebase audit skill that pairs with these guardrails
How Do You Enforce Code Quality With Hooks? (Patterns 5–7)
Anthropic's prompt caching cuts cached-input cost by 90%, which is what makes always-on quality hooks affordable to run on every edit (Anthropic API pricing, 2026). The patterns below run on the agent's output, not its input, they catch slop after the model commits to it.
Pattern 5: Auto-format on save. PostToolUse matcher Edit|Write|MultiEdit. Reads the touched path, runs prettier / ruff / gofmt, exits 0:
import json, subprocess, sys
data = json.load(sys.stdin)
path = data["tool_input"].get("file_path") or data["tool_input"].get("notebook_path")
if not path: sys.exit(0)
ext = path.rsplit(".", 1)[-1]
fmt = {"py": ["ruff", "format"], "js": ["prettier", "-w"], "ts": ["prettier", "-w"], "go": ["gofmt", "-w"]}.get(ext)
if fmt: subprocess.run(fmt + [path], check=False)
sys.exit(0)
Pattern 6 (Test gating). Block any non-test Bash command if the test suite is currently red. Implemented as a PreToolUse matcher Bash that reads .claude/tests-passing (touched by your CI hook on green) and exits 2 if missing.
Pattern 7: Structured output capture. PostToolBatch, which fires after a full batch of parallel tool calls resolves, writes a summarized JSON record per batch into .claude/runs/<session_id>.json. Useful when a long agent run does 80 tool calls and you want a machine-readable replay later. Combine with the audit pattern below for full traceability.
Can Hooks Cap a Runaway Agent's Costs? (Patterns 8–10)
One developer's eight-month Claude Code run consumed 10 billion tokens, which would have cost more than $15,000 at API list rates against roughly $800 on a Max plan (Morph, 2026). A subscription absorbs that, but it does not make the underlying burn visible, and it does not stop an agent from spending your weekly limit in an afternoon. Hooks are the only place you can put a hard ceiling on that without changing the model's behavior. The three patterns below cover notifications, session priming, and a cost circuit breaker I haven't seen anyone else publish.
Pattern 8: Notification routing. Notification event hook that pipes Claude Code's permission prompts to ntfy.sh, a Slack webhook, or osascript -e 'display notification …' on macOS. Useful when you walk away from a long run and don't want to come back to a session blocked on a 30-second-old "Approve Bash?" prompt.
import json, subprocess, sys
data = json.load(sys.stdin)
msg = data.get("message", "Claude Code needs attention")
subprocess.run(["osascript", "-e", f'display notification "{msg}" with title "Claude Code"'])
sys.exit(0)
Pattern 9: SessionStart context loading. Inject git status, recent commits, and failing test output into the agent's context the moment a session opens, via the additionalContext field:
import json, subprocess, sys
log = subprocess.check_output(["git", "log", "--oneline", "-10"]).decode()
status = subprocess.check_output(["git", "status", "-sb"]).decode()
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": f"## Git\n```
{% endraw %}
\n{status}\n{log}\n
{% raw %}
```\n"
}
}))
sys.exit(0)
The agent wakes up oriented instead of asking you what branch you're on.
Pattern 10 (Cost circuit breaker). A PreToolUse hook on Bash|Edit|Write that increments a counter in a tmp file scoped by session_id. Past N tool calls in T seconds, exit 2 with "You've made 200 tool calls in 60 seconds, please summarize and confirm direction before continuing." This forces a checkpoint instead of a $300 runaway. I've never seen this pattern documented elsewhere; it took one accidentally-billed weekend to invent it.
The hook that paid for itself in one session: I added the cost circuit breaker after a Sonnet 4.5 agent spent four hours and 8M tokens chasing a phantom test failure that turned out to be a stale lock file. The hook would have stopped it at minute 12.
durable execution patterns that complement hook-based cost control
How Do You Audit What an Agent Actually Did? (Patterns 11–12)
The 1.1M public repos using LLM SDKs and the 178% YoY jump in new ones (GitHub Octoverse 2025, 2025) tell you that audit logging is no longer a "nice to have", it's the only durable record of what an autonomous agent actually did to your code.
Pattern 11: Append-only JSONL audit log. PostToolUse matcher * that appends one record per tool call to ~/.claude-audit/<date>.jsonl. Captures timestamp, session, cwd, tool, input, and exit. Cheap insurance, disk is free, accountability isn't:
import json, sys, time, pathlib
data = json.load(sys.stdin)
log = pathlib.Path.home() / ".claude-audit" / f"{time.strftime('%Y-%m-%d')}.jsonl"
log.parent.mkdir(exist_ok=True)
with log.open("a") as f:
f.write(json.dumps({
"ts": time.time(),
"session": data["session_id"],
"tool": data["tool_name"],
"cwd": data["cwd"],
"input": data.get("tool_input"),
"response": data.get("tool_response", {}).get("status"),
}) + "\n")
sys.exit(0)
Pattern 12: SubagentStop checkpointing. When a subagent finishes (a Task tool call, a parallel review agent), SubagentStop fires with a transcript path. Hook this to extract the agent's final answer and write it to a structured location. Pairs naturally with the multi-agent review approach in the multi-agent code review skill, the SubagentStop hook is what gives you a machine-readable trail of which agent flagged what.
That transcript path is also your durable record after the session ends: where Claude Code saves conversations on disk and how to find, export, and redact the JSONL your hooks write to.
Why Claude Keeps Ignoring Your Stop Hook (the gotcha section)
A Hacker News thread titled "Tell HN: Claude 4.7 is ignoring stop hooks" hit 109 points in late April 2026 (Hacker News, 2026). The reported pattern: a Stop hook configured to block until tests pass, Claude acknowledging the block message in chat, and then the session ending anyway. There are three things going on, and only one of them is a bug.
Gotcha #1, exit code mismatch. The most common failure mode isn't the stop hook itself; it's people writing print(json.dumps({"decision": "block", "reason": "tests failing"})) and exiting 0. From the docs: stdout JSON is parsed as advisory, but only exit 2 actually blocks. Top reply on the HN thread: "Exit 2 means a blocking error. Claude Code ignores stdout and any JSON in it." Half the "stop hook ignored" reports are this.
Gotcha #2. Stop hooks are tool-result-shaped messages. When a Stop hook does block correctly, its reason is delivered to the model as something structurally indistinguishable from a tool result. Claude is RLHF-trained to resist instructions inside tool results (prompt-injection defense), so it sometimes acknowledges the block and stops anyway. This is the part that's genuinely frustrating, and it's what the HN thread surfaced. Anthropic's Claude Code team replied asking for /feedback reports but hasn't shipped a documented fix at time of writing.
Gotcha #3, the block loop. The docs are explicit about what a successful block actually does: "When you block a Stop event, Claude continues the conversation instead of stopping" (Claude Code Hooks Reference, 2026). That is the whole mechanism, and it is also the trap. A Stop hook that blocks unconditionally never lets a session end. Claude keeps working, hits Stop again, gets blocked again. You need your own guard: a counter or flag file keyed on session_id, checked before you emit decision: "block". Older hook write-ups tell you to read a stop_hook_active field for this. It is not in the current reference, so don't build on it. Own the loop guard yourself.
Two of those three are hooks behaving exactly as documented: only exit code 2 blocks, and a successful block continues the conversation rather than ending it. Gotcha #2 is the only one that's a genuine defect, which is worth knowing before you spend an afternoon debugging your own script.
The deterministic workaround. Stop being a Stop hook. Replace it with two cooperating hooks:
# PostToolUse matcher: Edit|Write|MultiEdit
# Marks the workspace as "tests required"
import pathlib, sys
pathlib.Path(".claude/tests-required").touch()
sys.exit(0)
# PreToolUse matcher: Bash
# Blocks any non-test command until tests pass
import json, pathlib, re, sys
data = json.load(sys.stdin)
cmd = data["tool_input"].get("command", "")
flag = pathlib.Path(".claude/tests-required")
if flag.exists() and not re.search(r"\b(pytest|jest|npm test|cargo test)\b", cmd):
print("Tests required. Run pytest first; the flag clears on green.", file=sys.stderr)
sys.exit(2)
sys.exit(0)
The model can't argue with this. It's not delivered as a chat message; it's a refused tool call. Whatever the model thinks about it is irrelevant, the Bash command never runs.
the eval mindset that makes deterministic guardrails feel natural
Watch on YouTube: Hooks in Claude Code (Anthropic)
How Do You Debug a Hook Without Losing an Afternoon?
Three commands cover 95% of debugging. Start with claude --debug: it prints every hook invocation, the exact stdin payload, the exit code, and any stderr (Claude Code Hooks Reference, 2026). If a hook isn't firing at all, you'll see it skipped here with a reason.
Second, run the hook directly from the CLI with a synthetic payload:
echo '{"session_id":"test","cwd":"'$(pwd)'","tool_name":"Bash","tool_input":{"command":"rm -rf /"},"hook_event_name":"PreToolUse"}' \
| python3 ~/.claude/hooks/guard.py; echo "exit=$?"
Third, the in-session /hooks slash command lists every hook currently registered for the active project. If your settings file has a typo, this is where you find out.
The 30-second smoke test I run on every new hook: add
print("HOOK FIRED:", data["hook_event_name"], file=sys.stderr)at the top of the script, run withclaude --debug, watch for the line. If it doesn't appear, the matcher is wrong. If it appears but nothing happens, the exit code is wrong. Two minutes of work, saves an hour.
the wider Claude Code error catalogue for everything a hook isn't causing
Frequently Asked Questions
What's the difference between exit 1 and exit 2 in Claude Code hooks?
Exit code 2 is the only blocking exit code; exit 1 (and every other non-zero code) is treated as a non-blocking error and the tool call still proceeds (Claude Code Hooks Reference, 2026). Stderr is fed back to the model on exit 2 only. This is the single most common hook bug.
Can hooks see Claude's chat messages or only tool calls?
Hooks see tool calls and their inputs, plus user prompts via the UserPromptSubmit event. They don't see assistant chat output directly, only tool responses through PostToolUse. The Stop and SubagentStop events do receive last_assistant_message, so the final turn is visible, but nothing in between is. If you want to gate on what the model said rather than what it did, hooks are the wrong layer.
Are Claude Code hooks safe to share publicly via GitHub?
Project-level hooks in .claude/settings.json are checked into the repo and run on anyone's machine. Treat them like any other executable in the repo. Use .claude/settings.local.json (gitignored by default) for hooks that load secrets, point to absolute paths on your machine, or post to private webhooks.
Why do my Stop hooks work in plan mode but not auto-accept mode?
Stop-hook delivery semantics changed across Claude versions; the HN thread reporting Claude 4.7 ignoring stop hooks describes this (Hacker News, 2026). The deterministic workaround (PostToolUse flag + PreToolUse gate) works identically across modes because it doesn't rely on the model honoring a Stop message.
Do hooks work the same in Claude Code on the web vs CLI?
Hooks run server-side scripts and only fire in environments where Claude Code can spawn local processes: the CLI, the desktop app, and the IDE extensions. Claude Code on the web (claude.ai/code) doesn't currently execute local hooks; the file lives in your project but is ignored at runtime.
What to Do With This
Pick three patterns. Start with the Bash deny-list (Pattern 1), the audit log (Pattern 11), and one of the stop-hook workaround halves. Run them for a week. Then add the secret scanner and the cost circuit breaker.
The point of hooks isn't to lock the agent down, it's to remove the failure modes you're tired of worrying about, so you can let the agent run longer with fewer interruptions. Every hook you ship is one more thing you don't have to remember to check.
the next layer up, where hooks become the substrate for orchestrated subagents



Top comments (0)