Claude Code Hooks: How a Rule Becomes a Constraint (and Why You Will Put the First One in the Wrong Place)
A rule written in CLAUDE.md is advice: the model can ignore it, and the day it does it will not tell you. A hook is where that rule becomes a constraint. But the first hook almost always ends up in the wrong place, and the only way to find out is to break something with it. Here is the log of what I broke, with dates.
A Claude Code hook is a script the client runs at a specific event, and it can block the action by exiting with code 2. It matters when a rule has to hold every time: CLAUDE.md suggests it to the model, a hook imposes it on the process. Writing one is easy. Choosing where to put it is not.
On 30 July 2026 an infographic went out with emoji instead of SVG icons. It was meant for a LinkedIn post, on my personal profile and the company one. The rule banning emoji was there, sitting in the file that enters context in every session, and it got rationalised away: the model explained to itself why those checkmarks were fine in that particular case.
That day I learned something that then took me five more days to apply properly: a rule inside a prompt is advice, and advice gets ignored quietly.
Why is a rule in CLAUDE.md not enough?
Because CLAUDE.md enters the context and from that moment competes with everything else: the task at hand, the open files, the twenty instructions that came before. It is material the model reasons about, not a constraint someone enforces. Most of the time it works. In the case that matters, which is when the rule is inconvenient for what the agent is trying to do, it gives way.
A hook lives somewhere else entirely. It is not text, it is a process the client spawns at an event, with the payload of whatever is about to happen on standard input. It does not reason and it does not negotiate: it looks, and it exits with a number.
There are plenty of events available, and Anthropic documents the exit code semantics in full in the official hooks reference. I am not going to restate it here: what is missing is not the list, it is knowing where the rule belongs.
That makes it suitable for exactly one category of rules, the ones that admit no judgement calls. For everything else CLAUDE.md remains the better tool: a motivated rule the model understands also covers the cases you did not anticipate, whereas a hook covers exactly and only the cases you wrote down.
When is a hook the wrong choice?
When the rule is about a path.
On 1 March 2026 I had four hooks in the site repository. Two of them were file guards: one blocked edits to css/style.css, which Tailwind generates and nobody should touch by hand, the other blocked .env.deploy, which holds the FTP credentials. They worked. They stayed alive for five months.
On 5 August I deleted both, and not because they were broken. Because they were in the wrong place. A rule about a path is not a hook, it is a line of permissions:
"permissions": {
"deny": [
"Edit(./css/style.css)",
"Write(./css/style.css)",
"Read(./.env.deploy)",
"Edit(./.env.deploy)",
"Write(./.env.deploy)"
]
}
Five lines in settings.local.json do the work of two scripts. No process spawned on every write, no races between competing hooks, no timeouts to handle. And on .env.deploy there is something the hook never did: it also covers Read. Credentials should not be modified, but above all they should not be read, and I had missed that.
So the question to ask before writing a hook is blunt: can the rule be expressed as "this tool does not touch this path"? If yes, it is a permissions line. A hook earns its place when the decision depends on content, which is precisely what a permissions list cannot express.
Where should a content check live?
At the point where the context is complete, which is almost never the point where the action happens.
On 1 August I wrote brand-guard, the check that enforces our writing rules. First version: hook it to Write and Edit, pass the text to the linter, exit 2 on a violation. It looks obvious, and it is wrong in five separate ways. I found all five in the same week:
- it ran on
js,php,cssandsvg, meaning on code, where tone of voice is irrelevant. An em dash inside a PHP comment triggered exit 2. My own hooks had em dashes in their comments; - with
Writeit saw the whole file, so it blamed the agent for lines somebody else had written months earlier; - it exited 2 after the write had already happened;
- it reported line numbers from the fragment while presenting them as line numbers of the file, so corrections landed on the wrong line;
- it hung whenever standard input did not close.
My first attempt at a fix was a filter: exclude this folder, exclude that extension. I spent days adding exceptions, and the filter was never enough. Every time, something else broke, either the deploy or work on a page.
The filter was never enough because the problem was not which file to look at, it was when to look. The gate moved to the commit:
// PreToolUse on Bash: if the command is a git commit,
// check the brand rules on the ADDED lines of the diff only.
const IS_A_COMMIT = /\bgit\b[^|;&]*\bcommit\b/;
Three reasons, and they hold for any content check, not just mine:
- at commit time you see the finished file, not a half-written fragment;
- you see the diff, so pre-existing content is excluded by construction rather than by piling up exceptions;
- you block before the action, which is the only place where blocking makes sense.
Reason two is what settled it. Every exception I was writing by hand existed to tell new lines from old ones. Look at the diff, and that distinction is free.
And an escape hatch is part of the gate, not a concession to it: brand-lint:ignore on the line for a legitimate quotation, [skip brand] in the commit message when the whole check has to be skipped. The second one stays written in the repository history, which is exactly the point. A wall with no door does not get respected, it gets switched off.
Why is a block that fires after the action not really a block?
Because it turns into a loop.
A PostToolUse hook that exits 2 reports an error to the agent once the write has already happened. The agent cannot undo it: all it can do is rewrite. It rewrites, the hook checks again, and if the text still has a problem it exits 2 again. Fix, rewrite, blocked again.
In an interactive session you notice and step in. Inside a workflow with agents running in parallel you do not: the loop burns through the subagent's turn budget and kills the whole phase, with nothing anywhere saying so. That happened on 4 August, and for a full day I suspected the two path guards, which I had disabled the day before. It was not them. It was brand-guard, which had stayed on.
The rule I took from it: if it blocks, it has to be PreToolUse. If the event is PostToolUse, the most it can do is inform, which means it always exits 0 and writes a non-blocking message. That is exactly what brand-guard does today, and the real gate sits fifty lines further along, on the commit.
Lined up, the three decisions fit into a single diagram:

How do you write a hook that does not become the problem itself?
Three rules, learned on the same day and written at the top of the module all my hooks now use to read their input.
Do not wait for standard input to close. On Windows the end event sometimes never arrives: the hook hangs until the client times out, and with ten subagents running in parallel that kills the turn. Decide as soon as the JSON is complete, and wait for nothing else.
Fail open. If after a few seconds you have not understood anything, let it through. An uncertain hook that blocks is worse than no hook at all, because the work stops and the cause is visible nowhere.
Write synchronously. process.exit() truncates pending writes on a pipe: an exit 2 with a half-written message is a block with no stated reason, and the agent circles it forever.
To those I add a fourth as a principle: if the deciding script throws, exit 0. A broken hook must not stop anyone.
How do you test a hook before trusting it?
By running it. It is the only family of configuration files you test by executing rather than by re-reading, which is also why it is the only one worth writing real tests for.
A test case is one line: a fake JSON payload on standard input and the exit code you expect.
echo '{"tool_input":{"file_path":"clients/rossi-spa/report.md",
"content":"The project is finished."}}' | node .claude/hooks/brand-guard.js
echo "expected 0, got: $?"
My test bench holds dozens of cases, one per guard and per situation, and I run it every time I touch a rule. This is not discipline, it is that with hooks there is no way to know whether they work by looking at them. I have worked on dozens of hooks and tested them dozens and dozens of times before they behaved. In the end it comes down to repetition, and the bench is what makes repetition sustainable.
One note on that fake path: in my actual bench that case uses a real client name, because it needed a realistic path. Here I replaced it, and the reason I did is the same reason the confidentiality guard exists. If you write hooks that enforce rules, you get to follow those rules when you write about the hooks too.
Frequently asked questions
What is the difference between a Claude Code hook and a git hook?
They are different things solving different problems. A git hook fires on a git operation and applies to anyone working in the repository, humans included. A Claude Code hook fires on an agent action, so it sees things git never sees: a file about to be written, a command about to run, a call to an external tool. The two stack well together: the git pre-commit is the final net, the hook is the check that arrives earlier and can explain to the agent what to fix.
Do hooks slow down Claude Code sessions?
Yes, and it should be measured. Every registered hook is a process spawned on every matching event, so three separate guards on Write means three processes on every write. The countermeasure is to register a single entry point per event and have it query the individual rules from there, which is what I did once the guards grew past two. Always set an explicit timeout in the registration: without one, a hook that hangs blocks the work until the client times out.
Should a hook block or only warn?
Block only where the damage is irreversible, warn everywhere else. A credential committed to git history stays there, a client name published is already out, a writing mistake can be fixed later while the text is still around. The temptation is to block everything, and the result of that temptation is predictable: a hook that blocks too much gets disabled, and from that moment it protects nothing at all.
Where to start
Do not start with hooks. Start with CLAUDE.md, which in my experience covers almost every rule you would want enforced, and costs nothing: if you do not have one yet, the guide to writing instructions that actually change the model's behaviour comes before this one. Then look at the two or three rules that keep getting ignored and ask, for each: is this a rule about a path? Then it is a line of permissions.deny. Is it a rule about content? Then the hook belongs where the content is complete, not where it gets written.
If you want to see how the rest of the configuration fits together, the structure of a Claude Code project is the map, and this piece is one level below it.
The tools we publish, including the skills we actually use, live in the Open Lab.
Tags
Founder & CEO · Castaldo Solutions
Sono un consulente di trasformazione digitale con esperienza enterprise. Aiuto le PMI italiane ad adottare AI, CRM e architetture IT con risultati misurabili in 90 giorni.