Codex vs Claude

Hooks Are the Rules That Cannot Be Argued With

David Guzenburg/ / 11 min read

The question about any rule you care about is not whether you wrote it down clearly. It is whether anything enforces it.

hookssafetyquality gatesaudit

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.

A hook is code that runs automatically

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.

Takeaway

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.

Keep reading
Codex vs Claude

Approval Fatigue: The Control Degrades Every Time You Use It

Interactive permission prompts spend a consumable resource. Why the count matters more than the wording, why deny lists beat allow lists, and how to make destructive commands break the rhythm instead of matching it.

Codex vs Claude

Generated Placeholder Assets: Borrowing Against a Design You Have Not Made

Agent-generated placeholder icons unblock a build and quietly ship to production. Path conventions no production file may import, a four-line CI gate, watermarks, and the provenance record that makes the licensing question answerable.

Codex vs Claude

Local Machine or Managed Container: The Difference That Actually Survives

Claude Code runs locally and Codex runs in the cloud is the first thing every comparison says, and it stopped being true. What each product treats as home, and what home costs you.

Codex vs Claude

Host Administration by Agent: Sorting Changes by How Badly They Undo

An agent is genuinely good at diagnosis and command composition, and a host has no git. Reversible versus restorable versus unrecoverable, the docker prune that eats your local database, and scheduling the revert before you touch the firewall.

← The Hook Lifecycle: Everyone Uses Two Events Out of Thirty  ·  Mobile Task Handoff or Workstation-Bound Sessions? →

All codex vs claude articles  ·  Every article