Deterministic Hooks vs Agent Judgement in Claude Code

Photo by Xunil via Wikimedia Commons (Public domain)
Ask whether a script holding the tool input, the repository and an exit code could decide it without knowing your intent. If it could — formatting, a typecheck, a forbidden import, a protected file — make it a hook, because a hook fires on its event whether or not the agent agrees. Keep judgement calls, such as whether an abstraction is right, in prose where they can still be reasoned about.
Yes, on the events that support blocking. On PreToolUse an exit code of 2 blocks the tool call outright, and the reason the agent sees is whatever your script wrote to stderr. PostToolUse cannot block, because the tool has already run by the time it fires; it can only feed a message back so the agent fixes what it just wrote.
It is probably attached to PostToolUse, which fires once per tool and therefore fires concurrently when the agent makes parallel tool calls. A typecheck measured at about 3.5 seconds on my repository then runs several times for a single batch of writes. Moving the same script to PostToolBatch, which fires exactly once after the whole batch resolves, removes the duplication without losing the feedback.
It names the rule, the offending file and the route that does work, printed to stderr, because on PreToolUse that text is the entire reason the agent receives. A bare exit 2 blocks the write and teaches nothing, so the agent retries almost the same thing. A message that names the file and the replacement turns a block into a single edit with no search step.
Yes. A hook handler with its type set to prompt sends the hook input to a model, Haiku by default, and reads back a verdict of ok true or ok false with a reason, so the trigger stays deterministic while the verdict is judged. An agent hook goes further and can read files and run commands before deciding, but the documentation marks it experimental and recommends command hooks for production workflows.

Photo by Xunil via Wikimedia Commons (Public domain)
Key Takeaway
A Claude Code hook is a shell command wired to a lifecycle event, so it runs whether the agent agrees or not; an instruction in CLAUDE.md is advice competing for attention and is followed most of the time. Move every mechanically checkable rule into a hook and reserve judgement for what no script can decide.
A PreToolUse hook in my own configuration blocks the Write and Edit tools on any path ending in .tsx until a particular skill has been activated through a tool that subagents cannot call. It is a reasonable rule with an unreachable precondition, so the write never lands. The route left open is to build the file somewhere else and copy it into place with cp, which is exactly what I now do. The hook did not make the code safer. It moved the write to a path it does not watch.
That is the honest case against turning every rule into code, and I still think the deterministic layer should carry most of them. This post is the line I draw between the two: the five axes I compare a candidate rule on, what is left for judgement once the checkable rules have moved, the costs of the hook side that enthusiasts skip, and the one Claude Code feature that lets a judged verdict fire on a deterministic trigger. Event names, exit-code semantics and hook types come from the Claude Code hooks reference; the timing figure is from my own repository.
The asymmetry is not about capability. A model is perfectly capable of running Prettier, noticing a forbidden import, or remembering that six files in this repository are generated rather than edited. It does all of that correctly most of the time. The problem is the distribution: most of the time is invisible until the once it is not, and by then the change is merged. Claude Code's own documentation puts it plainly — hooks give you deterministic control, so certain actions always happen rather than relying on the model to choose to run them.
An instruction has to win attention. It sits in the context window beside the task, the conversation so far, every tool result and every other rule in the same file, and it is followed when the model weighs it above all of that. A hook competes with nothing. It is a shell command attached to a lifecycle event, spawned by the harness when the event fires and the matcher matches, and its exit code is read as a decision — on PreToolUse, exit 2 blocks the tool call outright. The agent's opinion never enters the calculation. That, not sophistication, is the entire difference.
Capability is the wrong axis, because both layers can express almost any rule you can state. These five are the ones that actually diverge, and each of them has decided a rule for me at some point.
| Axis | Deterministic hook | Agent judgement |
|---|---|---|
| Does it always fire | Yes, on its event, whether or not the agent agrees | Only when the instruction outweighs everything else in the window |
| Can the result be argued with | No. Exit 2 blocks the call and the reason is whatever your script printed | Yes. It is advice, and a plausible counter-argument beats it |
| Cost per invocation | The wall time of your script, identical every run, zero tokens | Tokens in every request that carries the rule, plus the reasoning to apply it |
| What happens when it is wrong | It blocks correct work and cannot grant an exception | It waves through the exact thing you wrote it to catch |
| What rule it can express | Anything decidable from a path, a diff, a command string or an exit code | Anything at all, including taste and intent, with no guarantee of firing |
Read the fourth row twice. Both layers fail, but in opposite directions, and the costs are not symmetric. A wrong instruction costs you one bug. A wrong hook costs you the harness, because the second time a correct change is blocked for a bad reason somebody turns the hook off — and hooks get turned off in groups, not one at a time.
The sorting question is not how important the rule is. It is whether a script holding the tool input, the repository and an exit code can decide it without knowing intent. If it can, the rule belongs in a hook however small it looks: formatting, a typecheck, a forbidden import, a file that must not be edited, the shape of a commit message. Importance is what tempts people into writing a long paragraph in CLAUDE.md about a rule a six-line script would enforce perfectly and for ever.
// .claude/settings.json — two events, because they answer two questions.
// PreToolUse decides whether the write may happen at all; PostToolBatch
// reports on the batch that just landed and has no matcher of its own.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-generated.sh",
"args": [],
"timeout": 5
}
]
}
],
"PostToolBatch": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/typecheck.sh",
"timeout": 120
}
]
}
]
}
}Two fields keep a hook narrow, and both matter more than the script does. The matcher filters by tool name — Edit and Write above, so a Bash call never spawns the handler at all. The if field takes permission-rule syntax, so a handler can be scoped to something as specific as a Bash call matching an rm pattern and stays unspawned otherwise. A hook that runs on every tool call only to work out that it has nothing to do is the most common reason a harness feels slow.

Once every checkable rule has moved, what is left in CLAUDE.md is short and it is all judgement: is this the right abstraction, does this name say what the function does, is this the trade we want, will the next person read this error message and know what to do. None of these has a passing state a script can compute. Writing them as rules produces the kind of instruction that sounds enforceable, is not, and leaves everyone with the comfortable impression that the question is handled.
The other thing only the prose layer holds is why. A hook sees a tool call — a path, a diff, a command string. It cannot know that you are editing the generated file because you are debugging the generator, or that this commit skips the changelog because it is a revert. Reasons live in the instruction layer, and reasons are what let a reader, human or agent, recognise the legitimate exception that a hook would simply refuse.
Write the instruction layer as reasons, not rules. A paragraph explaining why six files are generated survives a refactor; a list of their paths goes stale the day one of them moves. And if you want those paths enforced rather than explained, that was never the prose layer's job.
Four of them, roughly in the order they have cost me time.
The slow one has a real fix and it is an event name, not an optimisation. PostToolUse fires once per tool, which means it fires concurrently when the agent makes parallel tool calls — five writes in one batch are five typechecks racing each other for the same CPU. PostToolBatch fires exactly once, after every call in the batch resolves and before the next model request, and it takes no matcher at all. Moving the same script from the per-tool event to the per-batch event turned a per-write tax into one run per batch, and the feedback still lands before the agent's next turn.
A hook nobody can switch off is a hook nobody keeps. disableAllHooks is one boolean in a settings file and it turns off every hook, the custom status line and the file-suggestion command at once — there is no per-hook opt-out at that level. A guardrail that adds seconds to every write does not get tuned. It gets disabled, along with the four good hooks sitting next to it.
A hook has two jobs and most of them only do the first. Blocking is the mechanism; the message is the product. On PreToolUse, exit 2 blocks the tool call and the reason the agent receives is whatever the script wrote to stderr, so a bare exit 2 blocks the write and says nothing — and the agent's next move is to attempt roughly the same thing again.
#!/usr/bin/env bash
# PreToolUse on Edit|Write: keep the agent out of the generated files.
# The hook payload arrives on stdin; the path lives at tool_input.file_path.
set -euo pipefail
payload=$(cat)
file=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty')
[ -z "$file" ] && exit 0
case "$file" in
*/messages/en.json|*/messages/id.json|*/lib/blog-meta.ts)
# Wrong: blocks, teaches nothing, and gets retried almost verbatim.
# exit 2
# Right: name the rule, the file, and the route that does work. On
# PreToolUse, stderr IS the reason the agent receives, so this text is
# the entire difference between a wall and a guardrail.
cat >&2 <<MSG
Blocked: $file is generated by scripts/merge-blog-staging.mjs.
Edit the staged fragment instead, then re-run the merge:
/tmp/blog-staging/agent-SLUG/en.json
node scripts/merge-blog-staging.mjs
MSG
exit 2
;;
esac
exit 0The difference is not politeness, it is retry count. A message naming the rule stops the same write from being tried twice. A message naming the file, the rule and the route that does work turns the block into a single edit with no search step in between, because the agent already holds everything it needs. The same reasoning is why I leave this repository's figure-review hook on PostToolUse and let it print rather than block: a component is legitimately inconsistent with its figures for a few minutes mid-edit, and PostToolUse cannot un-run the write anyway — the tool has already executed by the time the hook sees it.

The dichotomy in the title is not quite the end of the story, and the exception is the most useful thing I have added to a settings file this year. A hook handler does not have to be a shell command. With the type set to prompt, Claude Code sends the hook's input and your prompt to a model — Haiku by default, overridable with the model field — and reads back a verdict of ok true or ok false with a reason. The trigger is still deterministic. Only the verdict is judged.
// The trigger is still deterministic. Only the verdict is judged.
// Haiku answers by default; the "model" field overrides that.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "A blog component under app/components/blogs was edited this turn. If dateModified in lib/blog-meta.ts was not bumped for that slug, respond with {\"ok\": false, \"reason\": \"bump dateModified for that slug\"}."
}
]
}
]
}
}There is a heavier form. An agent hook spawns a subagent that can read files and run commands before deciding, with a 60-second default timeout and up to 50 tool-use turns; the documentation marks it experimental and says to prefer command hooks for production workflows. I use the prompt form for questions about the turn that just happened, and command hooks for everything a script can settle, which is still nearly everything. The rule I ended on is not hooks over instructions. It is that determinism is a property of the trigger, and the trigger is the part you should never leave to a model.
The 2025 Stack Overflow developer survey found 51 percent of professional developers using AI tools daily while trust in their accuracy fell to an all-time low, and that pairing is the argument for the whole exercise: a rule that needs no trust is worth more than one that does. So sort your rules once. Anything decidable from a path, a diff or an exit code becomes a hook whose failure message names the fix; anything that needs intent stays prose, and stays short enough to be read. Then go and measure what your slowest hook costs on every write, because that is the one about to be switched off.
Sources & further reading