Hooks Are the Rules That Cannot Be Argued With
The question about any rule you care about is not whether you wrote it down clearly. It is whether anything enforces it.
Instructions ask; hooks decide
Everything you write in an instruction file is a request. It is read by a model that is also reading your code, your test output, a dependency's README and whatever was in the issue it was asked to work on. Most of the time the request is honoured. It is not a mechanism that can be relied on for anything where "most of the time" is insufficient.
A hook is a program that runs at a defined point in the agent's lifecycle, receives structured input, and returns a decision. It does not weigh your instruction against other context, because it is not a model. It runs, and its exit code or output determines what happens next.
That difference is the entire value proposition, and it is why the right question about any rule you care about is not "have I written this down clearly" but "is this enforced by something that cannot be talked out of it".
The lifecycle is wider than people use
Most hook configurations in the wild use two events: before a tool call and after one. The surface is considerably larger than that — on the order of thirty distinct events covering session start and end, prompt submission, permission requests and denials, tool failures, subagent and task lifecycle, compaction, configuration and directory changes, file changes, and worktree creation and removal.
Knowing the range matters because the two events everyone uses are not always the right ones. Enforcing a rule about what enters context belongs at prompt submission or session start, not at every tool call. Reacting to a failed tool call is a different event from reacting to a successful one, and conflating them produces handlers full of conditionals that are really two handlers.
The four things hooks are genuinely good at
Blocking. Refusing a tool call outright, with a reason the agent can read. This is the one that turns a policy into a control.
Rewriting. Modifying a tool call before it executes — adding a flag, redirecting a path, filtering a command's output before it becomes context.
Reacting. Running something deterministic after a change: a formatter, a type check, a test for the touched module.
Recording. Writing an audit line for every tool call, which is the cheapest way to get a durable record of what an agent did — the subject of auditing agent tool calls.
Formatting is the obvious win and the wrong first hook
Everyone's first hook runs the formatter after an edit, and it works. It is also the least valuable thing hooks do, because a formatter is already enforced at commit time by every reasonable repository, so the hook is saving a round trip rather than adding a guarantee.
The high-value first hook is a denial: one rule about one thing that must never happen in your repository. It takes the same twenty minutes and it changes what is possible rather than what is convenient.
#!/usr/bin/env bash
# PreToolUse: generated files are outputs. Editing one is always a mistake,
# and the mistake survives until the next build silently overwrites it.
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')
case "$path" in
*/dist/*|*/build/*|*.generated.*|*/__generated__/*|*.lock)
jq -n --arg p "$path" '{hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("\($p) is generated. Change the source
or the generator; this file is rewritten by the build.")}}'
exit 0 ;;
esac
echo '{}'
The reason string is doing as much work as the denial. An agent that is told why a path is refused redirects to the generator; one that is simply blocked tries three variations of the same edit.
Filtering output is the underrated one
A hook can transform what a command returns before the agent sees it, which is a lever on context that instructions cannot provide. A test run that emits four thousand lines of passing output and twelve lines of failure can be filtered to the twelve lines.
This is worth more than it sounds. It reduces cost, it improves the agent's focus, and it removes an entire failure mode where the interesting information is buried in the middle of a wall of noise and gets summarised away. It is one of the few interventions that makes the agent both cheaper and better rather than trading one for the other.
Exit codes and structured output are not the same
A hook can communicate in two registers: a simple exit code, or a JSON object describing a decision. The second is what you want for anything conditional, because it lets you say deny with a reason, or allow with a modified input, rather than only pass or fail.
The practical failure here is a hook that returns malformed JSON, which tends to be treated as no decision at all. Validate your hook's output during development by piping it through a parser, and make the default path of every hook emit an empty object rather than nothing, so a code path you did not think about degrades to allow rather than to a parse error.
Hooks run on every event, so they must be fast
A pre-tool hook that takes 400 milliseconds adds 400 milliseconds to every tool call, which across a session of two hundred calls is over a minute of pure waiting. Worse, it is waiting the human notices, and slow tooling gets disabled.
Keep the hot path in shell or a compiled binary rather than starting an interpreter. Do the expensive check on a narrower event — after a file change rather than before every tool call. And profile it once: the difference between a hook that costs 5 milliseconds and one that costs 300 is usually a process start you did not need.
Hook configuration is an execution path with no confirmation step, so it is worth protecting like one. Keep hook scripts in the repository, review changes to them like any other code, and deny the agent write access to the hooks directory — an agent that can edit its own guardrails does not have guardrails.
What hooks cannot do
They cannot judge intent. A hook sees a command string, a file path, a tool
name; it does not know whether this edit to auth.py is the fix
you asked for or a mistake. Everything in the class of "is this change
correct" remains with tests, review and types.
They also cannot reason about accumulation. Each invocation sees one event. A rule like "no more than three files changed without running the tests" requires state, which means the hook has to write and read its own record, and that is where hook logic starts to become a program with its own bugs.
The gap between hooks and permissions
These overlap and are not interchangeable. Permission rules are declarative, static, and evaluated by the harness: good for a stable list of paths and command patterns. Hooks are code: good for decisions that depend on the current state of the repository, on the content of a file, or on anything requiring a computation.
"Never read .env" is a permission rule. "Refuse a commit that
would leave the working tree with a merge conflict marker in it" is a hook,
because answering it requires grepping the staged changes. Use the declarative
mechanism wherever it suffices; it is easier to audit and it cannot have a
bug.
Testing a hook is not optional
A hook that silently fails is worse than no hook, because it produces confidence without enforcement. And hooks fail silently by default: a missing dependency, a permission bit, a path assumption that held on your machine.
#!/usr/bin/env bash
# Feed each hook a synthetic event and assert the decision.
set -euo pipefail
expect() { # expect <hook> <json-input> <grep-pattern>
out=$(printf '%s' "$2" | "$1")
printf '%s' "$out" | jq . >/dev/null || { echo "invalid JSON from $1"; exit 1; }
printf '%s' "$out" | grep -q "$3" || { echo "FAIL $1 (wanted $3)"; exit 1; }
echo "ok: $1 -> $3"
}
expect hooks/deny-edits-to-generated.sh \
'{"tool_input":{"file_path":"pkg/dist/bundle.js"}}' '"deny"'
expect hooks/deny-edits-to-generated.sh \
'{"tool_input":{"file_path":"src/index.ts"}}' '^{}$'
Two cases per hook — one that must be blocked, one that must pass — catches nearly everything, and running it in CI means a hook that breaks gets noticed at the point of breakage rather than during the incident it was supposed to prevent.
Start with three
A useful opening set, in order of value. One denial covering the thing that must never happen in your repository. One output filter on your noisiest command. One audit line per tool call, appended to a file.
That is an hour of work and it covers the three registers: prevention, context quality, and the record. Everything else — formatters, type checks, notifications, per-language niceties — is refinement, and can accumulate over time as review comments keep repeating and you decide to stop having the conversation.
Where hooks fit against everything else
It helps to see the whole ladder, because each rung catches a different class of problem and people frequently reach for the wrong one.
Instructions shape behaviour in the common case and cost nothing. Permission rules make a static list of paths and commands unavailable. Hooks make computed decisions at defined moments. A sandbox constrains what the process can reach regardless of any of the above. Review and tests catch whether the work is correct. CI is the last gate before anything is shared.
Hooks sit in the middle, and their distinguishing property is that they can look at the current state. That is what to use them for. Anything expressible as a fixed list belongs one rung down, where it is easier to audit; anything about whether a change is correct belongs several rungs up, where a human or a test can judge it.
The failure mode of enthusiasm
Hooks accumulate. Each one is individually justified, and after a year you have eleven, three of which duplicate a check the linter already does, two of which were written for a problem that no longer exists, and one that is silently failing.
The symptom is a session that feels sluggish and an agent that seems to be fighting the tooling. The fix is a periodic read-through: for each hook, what would happen if it were removed, and is that outcome caught anywhere else. A hook whose only function is duplicating the pre-commit hook can go, and removing it makes the remaining ones easier to reason about.
Hooks are a team artefact
A hook in your personal configuration protects you. A hook in the repository protects everyone, including the person who joins next month and does not know the rule exists.
That is the argument for keeping them checked in and shared rather than in individual settings: the value of a mechanical rule is that nobody has to know it, and a rule that lives on one machine only works for one person. It also means the rules get reviewed when they change, which is the only thing standing between a shared enforcement layer and a shared source of confusion.
The last thing worth saying is that hooks are the cheapest place to put a decision you have already made. Most teams have three or four rules that everyone knows, that get restated in review, and that get broken anyway during a busy week. Each of those is a twenty-line script away from never being discussed again, and the relief of not having the same conversation for the ninth time is worth more than the enforcement.
An instruction is a request weighed against everything else in context; a hook is a program whose decision is final. Use hooks for the rules that must hold: one denial for the thing that must never happen, one filter on your noisiest command so failures are not buried, and one audit line per tool call. Keep them fast, make every path emit valid JSON, deny the agent write access to the hooks directory, and test each one with a blocked case and a passing case in CI.