Skip to main content
BlogAI

Deterministic Hooks vs Agent Judgement in Claude Code

A hook fires whether the agent agrees or not, while an instruction only competes for attention. The five axes I use to decide which layer carries a rule.

Matthews Wong

Software Engineer

September 202611 min read
Deterministic Hooks vs Agent Judgement in Claude Code

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.

A hook always fires; an instruction is sometimes followed

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.

Five axes where the two layers genuinely differ

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.

AxisDeterministic hookAgent judgement
Does it always fireYes, on its event, whether or not the agent agreesOnly when the instruction outweighs everything else in the window
Can the result be argued withNo. Exit 2 blocks the call and the reason is whatever your script printedYes. It is advice, and a plausible counter-argument beats it
Cost per invocationThe wall time of your script, identical every run, zero tokensTokens in every request that carries the rule, plus the reasoning to apply it
What happens when it is wrongIt blocks correct work and cannot grant an exceptionIt waves through the exact thing you wrote it to catch
What rule it can expressAnything decidable from a path, a diff, a command string or an exit codeAnything 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.

Sort every rule by whether a script can decide it

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.

Stacked bands sorting five real repository rules onto either the hook layer or the judgement layer, each tagged with the reason it belongs there
The last band is the one to watch: a rule that is easy to state, easy to write down and quietly optional, because nothing fails when it is skipped.

What the judgement layer is actually for

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.

The costs a hook enthusiast skips

Four of them, roughly in the order they have cost me time.

  • Slow. A typecheck wired to every write measured about 3.5 seconds on my own repository, on every write, whether or not the file it touched was typed.
  • Broad. A hook that blocks a whole file extension to enforce a rule about one kind of edit blocks correct work too, and correct work then finds another route.
  • Blind to intent. A hook reads a path and a payload. It cannot be told that this edit is the exception, so it has no way to grant one.
  • Unarguable. The property that makes hooks valuable is also what makes a bad one worse than a bad instruction: you can reason with a paragraph.

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.

Hook feedback is a teaching interface

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 0

The 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.

Three side-by-side panels comparing a bare blocking exit, a message naming the rule, and a message naming the file and the fix, with what the agent does after each
Notice that the first two panels both end in another attempt, so the cost of a vague hook message is measured in retries rather than in politeness.

Firing judgement deterministically

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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

When should a rule be a Claude Code hook instead of an instruction?

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.

Can a Claude Code hook actually block a tool call?

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.

Why does my typecheck hook make Claude Code feel slow?

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.

What makes a hook failure message good?

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.

Are there Claude Code hooks that use judgement rather than a fixed rule?

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.