Claude Code hooks are for rules the model must not judge

A rule written in CLAUDE.md is a request. A rule written as a hook is a fact about the environment.

That distinction is the whole post, and most tutorials on Claude Code hooks skip it. They show you how to wire a script to an event, which takes a few lines of JSON, and leave the harder question unanswered: which of your rules deserve one. The answer I have landed on is narrow. A hook is the right tool exactly when a rule must not depend on the model’s judgement.

This is the hooks chapter of the argument in the hub post: best practices that live in a prompt get talked past, and the ones that survive move into the harness.

Why a softer rule did not hold

I have a rule against code comments. It started softer: keep them short. On 2026-08-24, with that rule in place, three scratchpad scripts still opened with comments narrating the change. The rule had a judgement call in it, “short” is a judgement, and the model made the call in its own favour.

The sentence I wrote into the rule afterwards: “A rule with a judgement call in it gets talked past, so there is no judgement call.” The rule became absolute, no comments anywhere, and a PreToolUse hook now enforces it. I have a reason beyond taste. A wrong comment once misattributed a component to a third party, and that error was copied into published documentation, because a confident comment reads like a source.

The em-dash rule has the same history. It sits in my global instructions and also exists as a hook, and the memory file for it notes that the hook now enforces it. Having both is not redundancy. The instruction tells the model what I want. The hook is what happens when the instruction loses.

@golangch made the same point on 2026-09-13, in a longer piece about verifying AI-written code: “Make it deterministic.” The framing is that correctness should not depend on the model remembering to run the check. That is the test for a hook candidate. If you can write the check as a script that reads the tool input or the file on disk, and the answer needs nobody to understand intent, it is a hook. If it needs understanding, it stays advice, and you should expect it to be followed most of the time.

PreToolUse says no. PostToolUse says what happened.

The two events I use most look similar in settings.json and behave differently in every way that matters. The hooks reference specifies both.

PreToolUse PostToolUse
Runs Before the tool call After the tool call succeeds
Exit code 2 Blocks the call, stderr goes to Claude Does not block, stderr still goes to Claude
Can undo the action Not needed, nothing has happened No, the tool already ran
Structured decision hookSpecificOutput.permissionDecision set to deny Top-level decision set to block
Best at Rules about what must never happen Feedback on what just happened
Message must say How to comply What is wrong with the result

Both receive the event as JSON on stdin: session_id, transcript_path, cwd, permission_mode, hook_event_name, tool_name, tool_input and tool_use_id. PostToolUse adds tool_response. For an Edit, tool_input carries file_path, old_string, new_string and replace_all. For a Write it carries file_path and content. Paths are always absolute. Any exit code other than 0 or 2 is a non-blocking error: the action proceeds and you get a one-line notice, so a hook script that crashes has stopped enforcing anything. I treat exit codes as part of the design for that reason.

A block has to be a set of instructions

The consequence of exit code 2 on PreToolUse is that Claude receives your stderr as the reason the call failed. That text is the only channel you have to the model at the moment it matters, and “blocked” is not a message. A block that does not say how to comply produces a retry loop, or worse, a creative workaround.

This is the shape of my WebFetch guard. WebFetch against an x.com URL returns HTTP 402 with no body, and a skill handles X instead, so the hook stops the wrong path and names the right one:

#!/usr/bin/env bash
url=$(jq -r '.tool_input.url // empty')
case "$url" in
  *x.com/*|*twitter.com/*)
    echo "WebFetch cannot read X (HTTP 402, no body). Invoke the x-research skill and run bird read on this URL." >&2
    exit 2
    ;;
esac
exit 0

Wired in with a matcher, so it only runs on the tool it cares about:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "WebFetch",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/block-x-webfetch.sh"
          }
        ]
      }
    ]
  }
}

This is a trimmed version of the idea, not my production script. The part to copy is the stderr line. It states the fact (why the call cannot work) and the next action (which skill, which command). That pairing is the design of the whole layer: the hook stops the wrong path, and the skill teaches the right one. A hook with no skill behind it only tells the model what it cannot do.

The structured alternative to exit 2 is JSON on stdout with permissionDecision set to deny and a permissionDecisionReason. The effect is the same, and it also lets you return ask to put a human in the loop. Exit 2 blocks even if the JSON says allow, so do not mix them.

Feedback sees the intermediate state

PostToolUse cannot prevent anything, because the tool has already run. What it can do is tell the model what the result looks like while the model still has the context to fix it.

My lint hook runs ESLint on the file that was just edited. In one session in this repo it reported two “unsafe call” errors straight after the first of two edits. The call had been written before the import existed, so for one step the file really was broken. After the second edit added the import, a rerun was clean.

That is both the use and the noise. A PostToolUse hook sees every intermediate state, not just the final one. For a multi-edit change the first receipt can be a false alarm, and a model that treats every receipt as a command will chase it. The design implication is that PostToolUse output should read as a receipt, a statement of what the file looks like now, not as a verdict. I want the model to know the file currently fails lint, and then to decide whether the next edit is already the fix.

It also explains where I put which rules. Anything about what must never happen is PreToolUse. Anything about what the result looks like is PostToolUse.

The hooks I actually run

These are the global hooks in my ~/.claude/settings.json on the two tool events, plus the lifecycle ones that do not guard a tool call. The last column is the filter I apply to every candidate: why is this not a line in a prompt.

Event Hook What it checks Why a hook and not a rule
PreToolUse require-read-before-edit An Edit or Write to a file not read this session The model’s confidence is not evidence it read the file, and the log is
PreToolUse check-new-comments Code comments in the content being written The softer rule demonstrably did not hold
PreToolUse block-dangerous-commands Destructive Bash patterns One miss is unrecoverable
PreToolUse rm-scope-guard rm in Bash reaching outside its intended scope Same, and the scope is checkable from the command string
PreToolUse block-x-webfetch WebFetch on x.com The call can never succeed, and a skill owns the right path
PostToolUse track-reads Records which files were read Not a rule at all: it is the state the read-before-edit hook depends on
PostToolUse check-em-dash Em dashes in what was written Also stated in my instructions, and the instruction lost often enough to need this
PostToolUse check-file-size Files over 400 lines A numeric limit is a threshold, and a model cannot see a line count it has not measured
PostToolUse lint-on-edit ESLint on the edited file The linter’s verdict is a fact, not an opinion to weigh
PostToolUse check-silent-errors Error handling that swallows failures, such as a bare except: A pattern scan of the file finds it
SessionStart check-codebase-health, worktree-session-prompt Repo state, and whether the session started in a shared main checkout Must run before the model has said anything
UserPromptSubmit suggest-loop-improvements Prompt-time context Context injection, not enforcement
Stop worktree-exit-offer Whether to clean up a worktree Fires at a moment the model has no reason to think about it

That is five hooks on PreToolUse and five on PostToolUse. Notice what they have in common: none of them contains a judgement call. Each answers a question a script can answer. The ones that look like judgement, such as check-silent-errors, are really pattern checks that I decided in advance to treat as facts.

The same table tells you what I left as advice. “Prefer small functions” is not a hook. “Write tests for new behaviour” is not a hook. They need understanding, so they stay in CLAUDE.md, and I treat compliance as best-effort.

How I find out whether a hook earns its keep

Writing hooks is cheap. Keeping the wrong ones is expensive, because every hook is a chance to block a harmless action or to teach the model to route around a check.

So every block and every warning is appended as a JSON line to .harness/ledger.jsonl, which is gitignored. In this repo the ledger holds 31 events:

Rule Events
file-size 11
lint 9
read-before-edit 6
em-dash 5

The ledger is noisy on purpose. The signal is in the recurring clusters, pairs of a rule and a path prefix: file-size under src/pages accounts for 9, read-before-edit under src/content for 5, lint under src/lib for 4, and em-dash under src/content for 3.

A /reflect command reads the ledger, finds those clusters, reads my feedback memories, and proposes one of four things: a new rule, a changed hook threshold, a lint rule, or an ADR. Nothing is applied without my approval. Each reflection writes a dated file with a metric snapshot, so the next one can check whether a promoted rule reduced the mistakes it targeted.

Here is the honest state of that loop: no reflection has been written for this repo yet. The ledger exists and the clusters are visible, but I have not run the step that turns them into decisions. I am describing a mechanism I built and use partway, not a measured result. The cluster counts tell me where to look. They do not tell me whether file-size under src/pages is a real maintenance problem or a threshold set too low, and I will not guess which before reading the files.

That is also the answer to what “earns its keep” means. A hook earns it if its events are real mistakes that were prevented, and the evidence is in the ledger plus a look at what each event was. A hook whose cluster is the same path again and again is either the most valuable one you have or the most annoying, and only reading the cases separates them.

The cost, in one character

On 2026-10-08, in this repo, I made a one-character change: a trailing slash on a link in Intro.astro. The file was already 486 lines against a 400 line limit, so any edit to it, however small, was an edit to an over-limit file. The file-size hook is PostToolUse, so the edit landed. Then the hook returned a blocking error that said the file had to be split before work went on, by concern, and without flattening scoped styles into a global stylesheet.

The fix was to move the writing list and tag cloud into their own component, which took Intro.astro to 359 lines. The refactor was defensible. It was not what I had asked for, and I paid for it to fix a link.

The hook did not care that the change was one character. That is the point, because a check that makes exceptions for small edits is a check with a judgement call in it, and I have already described how that ends. It is also the cost, because a deterministic rule applies in the cases where you would have waived it.

The mitigation is in the message, not in an exception. The error named a way out, and the way out was concrete. Where a hook cannot offer a path like that, it is probably the wrong hook, and that is the kind of finding a reflection should surface.

Hooks enforce, so choose what they enforce

If a rule can be checked mechanically and a miss costs something, write the hook, and write its message as instructions. If it needs understanding, keep it as advice and do not pretend it is enforced. If it is a hook whose events you have never read, you do not yet know which kind it is.

The same mechanism shows up in the multi-agent posts. In agent teams, the TeammateIdle, TaskCreated and TaskCompleted hooks are the part worth building on, because exit code 2 refuses the work and sends the teammate back. Subagents do something related with a tool allowlist, which is a restriction you build rather than request. The common thread is the one this post started with: independence and reliability come from restrictions in the environment, not from instructions in the prompt.

SonarSource’s piece on guardrails, shared by @hackernoon on 2026-08-24, frames hooks the same way: they can enforce checks, block unwanted commands and validate AI-generated code before it ships. I agree with the claim and would add the condition. They do that for the rules you chose to turn into scripts, which is a smaller set than the rules you wish the model followed.

Frequently asked questions

What is a Claude Code hook?
A hook is a handler that Claude Code runs at a defined point in its lifecycle, configured in settings.json under an event name, an optional matcher on the tool name, and a command. The command receives a JSON description of the event on stdin. Its exit code and output decide what happens next, which is why a hook can enforce a rule where a prompt can only request one.
What is the difference between PreToolUse and PostToolUse?
PreToolUse runs before the tool call and exit code 2 blocks it, with stderr shown to Claude. PostToolUse runs after the tool has already executed, so it cannot undo anything. Exit code 2 there does not block, but stderr is still shown to Claude, which makes it the way to give feedback on something that has already happened.
When should a rule be a hook instead of a CLAUDE.md line?
When the rule has no judgement call in it and a miss costs something. A hook fires every time, while a CLAUDE.md line gets weighed against everything else in the context. If a script can check the rule from the tool input or the file on disk, it is a candidate for a hook. If checking it requires understanding intent, it stays advice.
How do I know whether a hook is worth keeping?
Log every block and warning to a file and read the log. A hook that never fires may be dead weight or may be doing its job, and the log cannot tell you which. A hook that fires constantly on the same path is either catching a real recurring mistake or blocking things that do not matter. In my repo the ledger holds 31 events, and the file-size hook accounts for 11 of them.
Nick Sawinyh
Nick Sawinyh

Web3 BD & product strategist with 10+ years in crypto, specializing in turning complex technical products into clear strategies that drive adoption and grow ecosystems.