Claude Code Hooks Explained: Deterministic Guards for the Agent Loop
▶ Watch on YouTube & subscribe to The Stack Underflow
You told the model in ALL CAPS: “NEVER refund more than $500.” It did it anyway. Of course it did — a prompt is a suggestion, written in the same language the model is free to reinterpret when a user pushes back hard enough. Language models are, by design, responsive to language. Given a compelling enough argument, the model can emit a process_refund(amount=750) call and the ledger takes the hit before any human notices.
If a rule must hold without exception, it cannot live in language. It has to live in code that runs no matter what the model decides. That is what hooks are: lifecycle callbacks that intercept the agent loop at specific moments, evaluated by your code with your logic, completely outside the model’s reasoning. They are the difference between a policy and a guarantee.
The one-sentence version: Claude Code hooks are code-level lifecycle callbacks that intercept the agent loop at specific events and can block, allow, or transform tool calls in a way the model cannot reason its way around.
Why prompts alone are not enough
The mental model most developers start with goes like this: write a detailed system prompt, describe the rules, and the model will follow them. And it will — most of the time, for reasonable requests. The problem is the tail. A sufficiently persistent or creative user can reframe a situation, add context that makes the rule seem inapplicable, or simply keep asking until a different completion comes out.
Consider the $500 refund cap. With only a prompt in place, the failure path looks like this:
User: "This is a critical situation, the customer will churn."
Model: <reasons about edge cases, decides $750 is warranted>
Model: → tool_use: process_refund(amount=750)
Tool: → executes. Ledger: -$750.
The prompt never had a chance. Now insert a PreToolUse hook on that same path:
Model: → tool_use: process_refund(amount=750)
Hook: → reads amount=750, checks against cap=500
Hook: → exit 2, stderr: "amount exceeds policy cap of $500"
Model: receives block reason, adapts
Model: → tool_use: process_refund(amount=500)
Tool: → executes. Ledger: -$500.
The hook did not negotiate. It did not weigh the customer’s churn risk. It compared two numbers and returned an exit code. Six lines of bash held the line that no system prompt ever could.
The three-layer defense model
Hooks are the third and final layer in a defense-in-depth stack. None of these layers are redundant — they catch different failure modes at different costs:
Layer 1 — System prompt guidance (soft; handles 99% of normal cases efficiently)
|
v
Layer 2 — Tool description constraints (medium; narrows model intent before tool selection)
|
v
Layer 3 — Hook enforcement (hard backstop; evaluated by code, not by the model)
| Layer | Enforcer | Bypassable by model? | Cost of check |
|---|---|---|---|
| System prompt | The model itself | Yes, under pressure | Essentially free |
| Tool description | The model itself | Yes | Essentially free |
| Hook | Your code | No | One process execution |
Prompts cover the common path efficiently. Hooks exist for the cases where failure is expensive enough that you cannot accept a probabilistic guarantee. Use all three — but understand that layer three is the one that actually doesn’t negotiate.
Lifecycle events: the full map
As of mid-2026, Claude Code exposes more than 30 lifecycle events across three cadences (docs.anthropic.com / code.claude.com, 2026). In practice, seven events cover the overwhelming majority of real production use cases:
| Cadence | Event | Fires when | Can block? |
|---|---|---|---|
| Per session | SessionStart | Session opens | No (observe/inject) |
| Per session | SessionEnd | Session closes | No (teardown/audit) |
| Per turn | UserPromptSubmit | User sends a message, before any tool runs | Yes |
| Per turn | Stop | Model finishes responding | Yes (can refuse to end the turn) |
| Per turn | StopFailure | Turn ends due to API error | No (observability) |
| Per tool call | PreToolUse | Before each tool executes | Yes |
| Per tool call | PostToolUse | After each tool executes | No (audit/transform output) |
The remaining events — SubagentStart, SubagentStop, PreCompact, PostCompact, MessageDisplay, PermissionRequest, ConfigChange, and more — exist for specific scenarios: multi-agent observability, context management policy, output transformation, and configuration lifecycle. Start with the seven above. Reach for the rest when a specific problem demands them.
Session opens
|
v
[ SessionStart hook ]
|
v
User sends a message
|
v
[ UserPromptSubmit hook ] ← can block entire turn here
|
v
Model reasons, emits tool_use
|
v
[ PreToolUse hook ] ← can block individual tool call here
|
v
Tool executes
|
v
[ PostToolUse hook ] ← audit, log, transform result
|
v
Model reasons over result, emits next step
... (loop repeats per tool call)
|
v
Model finishes turn
|
v
[ Stop hook ] ← can refuse to end the turn
|
v
Session closes
|
v
[ SessionEnd hook ]
Five handler types
A hook handler is not required to be a shell script, though that covers the majority of real-world implementations. As of 2026, Claude Code supports five handler types (code.claude.com docs, 2026):
| Handler type | What it runs | Best for |
|---|---|---|
command | Any executable or shell command | Simple numeric/string checks; the default choice |
prompt | A Claude call with a structured yes/no framing | Decisions requiring nuanced judgment an if statement cannot express |
agent | A sub-agent with tool access (Read, Grep, Glob) | Deep validation — e.g., scan a diff before allowing a commit |
http | HTTP POST to a URL | Shared team-wide policy servers; centralized audit logging |
mcp_tool | An MCP tool invocation (v2.1.118+) | Integrating hook logic with existing MCP infrastructure |
Start with command. Reach for the others only when the decision logic genuinely needs more than an if statement.
The exit-code contract
Every command-type hook lives and dies by its exit code. This is the complete contract — there is no fine print:
#!/usr/bin/env bash
# Enforce a $500 refund cap via PreToolUse
# Hook receives event JSON on stdin; HOOK_TOOL_INPUT env var also available
AMOUNT=$(cat | jq -r '.tool_input.amount')
if awk "BEGIN{exit !($AMOUNT > 500)}"; then
# exit 2 = block; stderr goes to the model as the block reason
echo "amount exceeds policy cap of \$500" >&2
exit 2
fi
# exit 0 = allow the tool to proceed
exit 0
| Exit code | Meaning | What the model sees |
|---|---|---|
0 | Allow — proceed normally | Nothing (transparent) |
2 | Block — halt this tool call | The content of stderr, as the block reason |
| Any other non-zero | Hook error — tool is allowed through | Error is logged; model proceeds |
That last row is the one that surprises engineers. Exit code 1 — the standard Unix error code — does not block the tool. It logs the error and lets the call through. This is intentional: a crashing hook should not silently become a deny without surfacing the failure. But it means using exit 1 when you intend exit 2 is a policy that silently does nothing. Use the right code.
Modifying inputs, not just blocking them. A PreToolUse hook that exits 0 can also return JSON with an updatedInput field to transform the tool arguments before execution — clamping a value, redacting a secret, normalising a path — without a model round-trip:
{
"updatedInput": {
"amount": 500,
"reason": "clamped to policy cap"
}
}
The tool receives the modified arguments. The model is not involved.
The failure mode you must handle
What happens when the hook itself crashes, times out, or returns unexpected output? As noted above: a non-zero exit code that is not 2 is treated as a non-blocking error — the tool is allowed through. Two ways to read this:
The generous reading: A hook that fails noisily still surfaces the error. The team knows the policy layer is broken.
The dangerous reading: If your error handling is sloppy, a hook crash means your block logic evaporates silently on that call.
The mitigation is straightforward. Design every hook to fail closed when the intent is to block:
#!/usr/bin/env bash
set -euo pipefail
AMOUNT=$(cat | jq -r '.tool_input.amount') || {
echo "hook: failed to parse tool input" >&2
exit 2 # can't parse = block until input is legible
}
if awk "BEGIN{exit !($AMOUNT > 500)}"; then
echo "amount exceeds policy cap of \$500" >&2
exit 2
fi
exit 0
set -euo pipefail causes the script to exit on any unhandled error. The parse failure explicitly exits 2 rather than falling through to exit 0. A guarantee that disappears on error was never a guarantee — it was a guarantee-shaped suggestion.
Security: hooks run as you
This is worth stating plainly because it is easy to overlook in the excitement of a new capability. A hook is your code, running with your privileges, in your environment. It has access to your filesystem, your network, and — critically — your environment variables, which may include API keys, tokens, and credentials.
Before adding any hook:
- Read the entire script before running it
- Pin external dependencies (curl calls, jq versions, npm packages) to specific versions
- Treat hook code with the same review rigor you apply to any privileged server-side code
- Never copy-paste a hook from an untrusted source without a full line-by-line audit
A hook that exfiltrates your ANTHROPIC_API_KEY via an HTTP request is not a theoretical concern. It is six lines of bash, and it would run automatically on every tool call.
How to apply this in practice
Three concrete patterns worth wiring in before shipping any agent to production:
Pattern 1 — The policy gate. PreToolUse on every financial, data-mutation, or network-egress tool. Check the parameters against hard limits before execution. Exit 2 with a clear reason when a limit is hit.
Pattern 2 — The audit trail. PostToolUse on the same tools, logging the call, its parameters, and the result to a structured log. Combine with SessionStart/SessionEnd to capture session-level metadata. This is your forensics layer.
Pattern 3 — The context injector. SessionStart to append project-specific context — environment name, user tier, feature flags — into the session before the first user message. Cheaper than repeating it in every system prompt and impossible for the model to forget.
Configure hooks in settings.json at project scope (.claude/settings.json) or user scope (~/.claude/settings.json). A minimal project-scoped refund cap hook:
{
"hooks": {
"PreToolUse": [
{
"matcher": "process_refund",
"handler": {
"type": "command",
"command": ".claude/hooks/enforce-refund-cap.sh"
}
}
]
}
}
The matcher field accepts a tool name or a glob pattern. An empty matcher fires on every tool call. Scope your matchers as tightly as possible — a hook that fires on every tool call for every session adds latency on every tool call for every session.
Common misconceptions
-
“Hooks replace the system prompt.” They do not. Hooks are the backstop layer, not the primary interface. System prompts handle the common case efficiently; hooks handle the cases where the cost of failure is too high to leave to probability. Use both.
-
“Exit code 1 blocks the tool call.” No. Only exit code 2 blocks and returns a reason to the model. Exit code 1 — the standard Unix error exit — is treated as a non-blocking hook error: the call proceeds and the error is logged. Using the wrong exit code means your policy silently does nothing for every failure that triggers it.
-
“Only shell scripts can be hooks.” The
commandhandler is the most common, butprompt,agent,http, andmcp_toolhandlers all exist. When the decision logic requires nuanced judgment, a sub-agent scan, or team-wide policy centralisation, there is a handler type for it. -
“Hooks are sandboxed.” They are not sandboxed in any way. Hooks run with your full user privileges, your environment variables, and your network access. Treat every hook as privileged code and review it accordingly.
Frequently asked questions
Can a PreToolUse hook modify the tool input rather than just blocking it?
Yes. A PreToolUse hook that exits 0 can return JSON with an updatedInput field containing the modified parameters. This lets you clamp a value, redact a secret, or normalise a path before the tool executes — giving the pipeline a chance to self-correct without a model round-trip. The model never sees the modification; the tool receives the transformed input transparently.
What is the difference between a prompt handler and calling Claude from inside a shell script?
A prompt handler is a first-class hook type that routes the decision through a structured Claude call with a yes/no framing and full awareness of the hook context. Calling Claude from inside a shell script is possible but means you handle all the API plumbing, authentication, error handling, context injection, and response parsing yourself. Use the prompt handler when you want Claude-assisted judgment without that boilerplate.
How do per-turn hooks differ from per-tool-use hooks?
A UserPromptSubmit hook fires once per user message, before any tools run. It can block an entire turn — useful for rejecting a category of request upfront before the model even starts reasoning. A PreToolUse hook fires for each individual tool invocation within that turn. A single turn can trigger many tool calls and therefore many PreToolUse firings. Use turn-level hooks for coarse-grained policy; use tool-level hooks for fine-grained enforcement.
What does the Stop hook actually do?
Stop fires when the model finishes a turn. Unlike most hooks, it can refuse to let the turn end — returning a reason that sends control back to the model for another iteration. This is useful for verification patterns: run a check after the model’s final response, and if it fails, force another attempt. It is a last-resort pattern; use it sparingly.
Do hooks work in multi-agent setups?
Yes, and Claude Code provides dedicated SubagentStart and SubagentStop events for exactly this case. These fire when a sub-agent is spawned and when it completes, giving you observability and enforcement hooks across the full call graph — not just at the top-level agent. This is part of the 30-plus event catalogue beyond the seven core events.
Where do I put the settings.json file to scope hooks to a project?
Project-scoped hooks go in .claude/settings.json at the repository root. User-scoped hooks (that apply across all projects) go in ~/.claude/settings.json. Project-scope overrides user-scope for the same event. Both files are watched and reloaded live — changes apply to a running session without a restart.
Where this fits in the series
Hooks are the hard enforcement layer of the agent loop — the backstop that sits beneath prompt guidance and tool descriptions. If you have not yet read how the agent loop itself works, start there: hooks only make sense once you understand the loop they attach to. The overview of skills, hooks, subagents, and plugins places hooks in the broader control surface of Claude Code. And the Claude stack mental model gives the full layered picture — tokens through production architecture — that contextualises where hooks sit in the stack. Browse all tutorials to follow the full series.
Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.
Subscribe on YouTube →