Claude Code hooks: good at steering, weak as a gate
A hook runs whether the model agrees or not, which makes it good at chores like formatting and at steering an agent toward the route you want. As a gate it is weaker than it looks: it checks only the tools you point it at, a broken check lets the call through unless you opt into blocking, and in some setups a mod answers after it.
Contents
Claude Code hooks are commands you register in a settings file, and Claude Code runs them at fixed points in a session: before a tool call, after it, when Claude stops, when it needs your attention. The model never decides whether a hook runs. That is the appeal, and also why people reach for hooks as a safety layer and then trust them more than the docs do.
My own record has both sides. In February my agent setup ran a Bash guard, a context hook on every prompt and, on a few agents, an LLM check at the end of every turn. In April the guard was removed with a commit note that reads “ineffective, agents bypass via Write/Edit”. This howto covers what hooks do well, how to write one that fails closed, and the paths a hook never sees. The commands below ran on Claude Code 2.1.310 on Linux; where a claim rests on the docs alone, it says so.
What is a Claude Code hook, and which events can block?
A hook is an event, a matcher and a handler, and only some events let the handler stop anything. PreToolUse can block a tool call. PostToolUse cannot, because the tool already ran.
The configuration nests three levels: the event (PreToolUse, Stop), a matcher group that filters it (for tool events, the tool name, such as Bash or Edit|Write), and one or more handlers. A handler is a shell command in most setups, but it can also be an HTTP endpoint, an MCP tool, a single LLM prompt or a subagent. The reference lists more than thirty events. The ones most setups touch:
per session: SessionStart, SessionEnd
per turn: UserPromptSubmit, Stop, StopFailure
per tool call: PreToolUse, PermissionRequest, PostToolUse, PostToolUseFailure
on the side: Notification, PreCompact, PostCompact, FileChanged, ConfigChangeA command hook reads the event as JSON on stdin and answers with its exit code and stdout. For a hook that prints nothing or plain text, the rule is simple: exit 0 means no objection, exit 2 means block, and any other code is a non-blocking error, so Claude Code shows a hook error notice and the call continues through the normal permission checks. What exit 2 does depends on the event. The reference has a per-event table; these rows cover most setups:
PreToolUse Yes Blocks the tool call
UserPromptSubmit Yes Blocks the prompt, so it never reaches Claude
Stop Yes Prevents Claude from stopping, continues the conversation
PostToolUse No Shows stderr to Claude; the tool already ran
Notification No Exit code and stderr are ignored
SessionStart No Shows stderr to user onlyFor finer control a hook exits 0 and prints one JSON object, and the fields have to sit at the right level. Put permissionDecision at the top level and Claude Code ignores it without an error. On PreToolUse the decision goes inside hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use the deploy script, not a raw push"
}
}The decision can be allow, deny, ask or defer (the last only in a claude -p run), and updatedInput next to it replaces the tool’s arguments before the call runs. When several hooks answer the same call, the most restrictive answer wins, in the order deny, defer, ask, allow. Once a hook prints valid JSON, the JSON decides: a hook that exits 1 with a valid deny still denies.
How do you write your first Claude Code hook?
Write a script that reads stdin, register it under an event in .claude/settings.json, and test it by piping JSON into it before Claude Code ever calls it. Here is a guard that blocks a marker command:
#!/bin/bash
# .claude/hooks/guard-bash.sh
set -euo pipefail
input=$(cat)
cmd=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["tool_input"].get("command",""))' <<<"$input")
if [[ "$cmd" == *marker-deny* ]]; then
echo "Blocked by guard-bash.sh" >&2
exit 2
fi
exit 0Make it executable with chmod +x, then register it. A project’s .claude/settings.json holds hooks for that project and can be committed; ~/.claude/settings.json holds them for all your projects:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh",
"onFailure": "block" }
]
}
]
}
}Test the script on its own first:
echo '{"tool_name":"Bash","tool_input":{"command":"touch marker-deny-x"}}' \
| CLAUDE_PROJECT_DIR=$PWD .claude/hooks/guard-bash.sh; echo "exit=$?"
# Blocked by guard-bash.sh
# exit=2Inside Claude Code, /hooks lists every hook it loaded and the file it came from. For a run, start Claude Code with --debug-file and read the Hooks: lines, which name the event, the command, the exit status and the time it took. With exactly this script and these settings, I asked Claude for two Bash calls, touch ok-file and touch marker-deny-file. The log shows both, and only the first file exists:
Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 0 (25ms)
Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 2 (25ms)The docs’ own examples parse the input with jq, and my first copy of the guard did too, without set -euo pipefail. The machine I tested on has no jq. The guard printed jq: command not found to stderr, cmd came back empty, the script reached exit 0, and Claude Code logged a clean status 0. Claude ran the command the guard existed to stop, and nothing on screen said the guard had broken. Strict mode changes the exit: a missing parser now makes the script exit 127. I checked that with the published script by renaming its python3 call to a binary that doesn’t exist, and with onFailure set the call was refused:
Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 127 (16ms)
[WARN] Hooks: PreToolUse command hook failed; blocking (onFailure: "block")Which Claude Code hook patterns are worth running?
The ones where a missed run costs little and a reliable run saves a step every time: formatting, notifications, context, and checks before Claude stops. A guard is the riskiest pattern on the list, for reasons the last three sections make concrete.
Formatting is the docs’ first example, and the one I’d start with. A PostToolUse hook on Edit|Write runs your formatter on the file Claude just touched (from the docs, not tested here):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}It misses files Claude changes through Bash. For a file that must stay formatted whatever touches it, the docs point to a FileChanged hook.
A Notification hook with a matcher such as permission_prompt or idle_prompt tells you when Claude needs you, through notify-send, osascript, or a script that messages your phone. I covered the hooks that report a finished or waiting agent in more detail in driving Claude Code from tmux.
Context can be put back. On SessionStart, a command’s plain stdout is added to Claude’s context, and the compact matcher fires right after compaction, which is when project conventions tend to fall out of the summary. UserPromptSubmit can add context to every prompt the same way. Mine injected the current time. The first fix in its history was the output format (a flat additionalContext field did nothing until it moved under hookSpecificOutput), and the commits after that were about frequency: a per-agent throttle, then an interval moved from 30 minutes to 5. Anything a hook injects on every prompt is paid for on every prompt, and on UserPromptSubmit a command hook gets 30 seconds by default, not the usual 10 minutes. For context that never changes, the docs suggest CLAUDE.md over a hook.
A Stop hook can check the work before Claude stops and send it back with a reason. As a prompt hook, a model judges the turn and answers with ok false and a reason to continue it. Mine checked an agent’s turn for three violations, one of them claiming “done” while tests failed. The history records invalid JSON from the judge, and two classification problems worth knowing in advance. The first version flagged violations from earlier in the conversation, fixed by telling the model to judge the latest turn only. A later version flagged an agent that had analyzed some feedback and asked a question, as if it had claimed a fix, and the rule was narrowed to explicit completion words. A Stop judge is a classifier and needs tuning like one, and as I argued in a prompt is not an invariant, a model in the gate is still weighing, not enforcing. For nudging an agent back to finish its work, weighing is fine.
Two Stop mechanics to know. The hook fires whenever Claude finishes responding, not only at the end of a task. And a hook that blocks keeps sending Claude back, so Claude Code overrides it after eight blocks in a row with no tool call in between. The input carries stop_hook_active, true when Claude is already continuing because of a Stop hook; exit 0 then, so the hook doesn’t block its own continuation.
Slow work can run in the background. A command hook with "async": true doesn’t hold Claude, and when it exits, the additionalContext and systemMessage fields from its JSON output reach Claude on the next turn. A test runner’s raw output doesn’t, so wrap it in a script that prints that JSON. An async hook can’t block anything, Claude Code doesn’t enforce timeout on it, and under claude -p one still running at teardown is killed.
What does a slow Claude Code hook cost?
Its own run time on every matching event, because a synchronous hook holds the action until it exits. My working guards took 25 to 28 ms per tool call in the debug logs. A hook that calls a network service or a model costs far more, and the default timeout for a command hook is 10 minutes on most events.
The arithmetic is per event, not per session. A PreToolUse hook with no matcher runs on every tool call in the agentic loop, subagents included, since settings hooks fire there too. Three ways to keep the bill down, all from the docs:
matcher narrows by tool name, e.g. "Bash" or "Edit|Write"
if narrows by tool and arguments in permission-rule syntax,
e.g. "Bash(git *)", so the process never spawns for other calls
async moves work that can't block anyway off the critical pathPrompt and agent hooks are the expensive kind. A prompt hook is one model call with a 30-second default timeout, so on Stop it is one call at the end of every response. An agent hook spawns a subagent that can take up to 50 tool-use turns, with a 60-second default. To find the slow hook, read the duration at the end of each Hooks: line in the debug log:
Hooks: PreToolUse:Write [...guard-files.sh] finished with status 2 (26ms)When does a Claude Code hook let the action through?
Whenever it fails, unless you set onFailure to "block". A script that can’t start, exits with a code other than 0 or 2 and prints no valid decision, prints malformed JSON or times out is a non-blocking error, and the tool call continues through the normal permission checks. The docs warn about the case that bites most often:
When you set up a policy hook, watch for this notice on its first run,
because a mistyped path in `settings.json` means the hook never runs.I ran exactly that case: a PreToolUse hook pointing at a script that doesn’t exist. Without onFailure, the debug log shows status 127 and the touch ran. With "onFailure": "block" on the same hook, the log adds the warning line shown above and the call is refused.
This changed recently. When I wrote about ten permission bypasses Claude Code closed in three releases on October 7, failing open was the only documented behavior for a crashing or hanging hook script, and the workaround was a bash EXIT trap that turns any failure into exit 2. onFailure arrived in v2.1.295 the next day. For command and http hooks it counts as failures a script that can’t start, an exit code other than 0 or 2, an HTTP error, a timeout, and output that can’t be parsed or fails validation.
It does not catch a script that breaks internally and still exits 0 with no output, which is what my jq guard did. To onFailure that run was a success. So both halves stay: strict mode or an EXIT trap so internal failures surface as a non-zero exit, and onFailure so that exit blocks. The field has no effect on Stop, SubagentStop, TaskCompleted and TeammateIdle hooks, or on async hooks.
Does a Claude Code Edit|Write hook stop Bash from writing the file?
No. I registered two guards in a test repository: the Bash guard, and an Edit|Write guard that blocks any path containing protected. Then I asked Claude to write one file with the Write tool and another with Bash:
Write protected-a.txt blocked by guard-files.sh (status 2)
Bash echo two > protected-b.txt guard-bash.sh had no rule for it (status 0), file existsMy April commit records the same gap from the other side: the guard watched Bash, and the agents edited around it with Write and Edit. A file guard has to cover every tool that can write the file, and Bash can write anything, so a guard keyed to a tool name protects a route, not the file. The docs’ own advice for formatting points at the same fix: FileChanged reacts to the file, whatever wrote it.
Where else does a Claude Code hook stop protecting you?
At the edge of the configuration it runs in. Beyond the tool-name gap, the docs list four more.
The if filter is best-effort. When Claude Code can’t tell which commands a Bash input runs, it runs the hook anyway, which is the safe direction. But the reference is plain about what that means for enforcement:
Because the if filter is best-effort, use the permission system rather
than a hook to enforce a hard allow or deny.Permission modes don’t switch a hook’s deny off. A PreToolUse deny holds in every mode, including bypassPermissions: my Bash guard blocked its marker command in a run started with --dangerously-skip-permissions. The reverse holds too, per the docs and untested here: a hook’s allow can’t override a deny rule from settings.
Something can answer after your hook. On a machine with no managed settings and no Team or Enterprise login, a mod can approve a call your PreToolUse hook blocked. I tested that in a mod can approve what your hook blocked on 2.1.287, and the hooks guide carries the same note today.
Headless runs trust the repository. Per the docs, an interactive session holds back hooks until you accept the workspace trust dialog. Under claude -p or the SDK there is no dialog, and hooks committed in a repository’s .claude/settings.json run in a folder you have never opened, which I went through in what a cloned repo’s settings file can still run. For a repository you didn’t write, --settings '{"disableAllHooks": true}' turns off its hooks for one run, though managed hooks keep running.
And after the fact is too late. PostToolUse can replace what Claude sees with updatedToolOutput, and the docs warn that the files written, commands run and requests sent have already taken effect. A redaction hook there hides a secret from the model, not from the network.
What should your first Claude Code hook be?
A formatter or a notification hook. It earns its keep on the first day, and when it breaks, the cost is an unformatted file. A guard that breaks fails in the direction it was written to prevent, and quietly.
If you write a guard anyway, give it what a production check gets: strict mode or an EXIT trap, onFailure: "block", and one test with the dependency removed and one with malformed input. Then call it what it is. A hook keyed to tool names and command text is steering: it catches the commands an agent usually writes, on the routes you thought of. The part of a guard I’d keep even knowing that is the reason string. My old guard’s messages named the approved route (a wrapper script, a safer way to read a credential), so a block handed the agent the next move instead of a bare refusal. Write the reason for the model that will read it. The rule that has to hold however the command is spelled needs a boundary that doesn’t read commands at all, which the permission-bypasses piece argued from the deny-rule side.
01 / Discussion
No comment section here — all discussions happen on X.
02 / More articles
Claude auto mode becomes the default in the Claude apps: what the check blocks and what 89% measured
Anthropic's study is honest about its setup: tired testers against one planted command. It reports no false-positive rate and no miss rate on real traffic, and in cloud sessions there is no Manual mode to switch back to.
Claude Code cost in 2026: what Pro, Max and the API charge for the same work
The plan prices are flat and the token prices are public, so the comparison looks like arithmetic. It isn't quite, because Claude Code caches your conversation differently depending on how you pay, and the tool most people use to put a dollar figure on subscription usage prices the cache you had, not the one you would get.
Claude Code sandbox on Linux: what srt walls off, and what bypassPermissions opens
The built-in sandbox is a real OS boundary around shell commands, and only around them. How strong it is depends on two things the setup screen never shows: your permission mode, and what existed when the command started. A test on Ubuntu of what srt blocks and leaves open, the Linux bug that still breaks it, and when a container or VM fits better.