Course · Lesson 15 of 30
Hooks
Pro · 6 min read
Everything so far — CLAUDE.md rules, command prompts, skill bodies — is advice to a model: strongly weighted, usually followed, never guaranteed. A hook is different in kind. It's a program that runs at fixed points in the agent's loop — most importantly before each tool call — and can allow or block the action. The model isn't consulted; code runs. Hooks are where "please never touch the migrations" becomes "attempts to touch the migrations fail," and the skill this lesson teaches — placing each rule correctly on the advice/enforcement line — is arguably the central skill of production agentic work, because every increase in autonomy you'll ever grant is underwritten by enforcement, not trust.
The mechanics
In Claude Code, hooks are configured in settings
(.claude/settings.json for the project — shared, versioned, exactly
where team invariants belong) as shell commands bound to lifecycle
events: PreToolUse (before a tool runs; can block), PostToolUse
(after it runs; observe/react), plus session-lifecycle events.
Matchers scope a hook to particular tools — all Bash commands, only
Edit/Write, and so on.
A PreToolUse hook receives the pending call as JSON on stdin: tool
name and arguments — for shell, the command string; for edits, the
file path and content. It rules by exit code: 0 allows; exit 2
blocks, with stderr fed back to the model as the reason. (Hooks can
also emit structured JSON for finer control, but the exit-code
contract covers most real hooks.) A few dozen lines of script gives
you an inspection point on every action the agent takes.
The fed-back-reason detail is better than it sounds: the model reads the denial and adjusts course. That makes denial messages steering, delivered at the exact moment of the mistake:
BLOCKED: direct edits to migrations/ are not allowed.
Create a new migration with `npm run migration:new`
instead.
A good denial converts a blocked wrong action into a correct next action, in one turn, with no human involved. Write every denial as if it's the only documentation the model will read at that moment — because it is.
What belongs behind a hook
Not everything. A hook is a hard gate, and hard gates on soft preferences make agents useless — you'd be building a linter that segfaults on style. The filter: invariants, not tastes — things that are never correct, where one violation costs more than a thousand false-block annoyances:
- Destructive commands:
rm -rfoutside the workspace,git push --force,DROPagainst anything that matters. - Protected paths: generated directories, vendored code, migration history, lockfiles — your CLAUDE.md "never touch" list (lesson 8), made true.
- Secret hygiene: block reads of
.env*and key files; block commits whose diff matches credential patterns (lesson 5's layered defense, wired into the loop). - Scope containment for parallel work: deny writes outside the agent's own worktree (lesson 13 — and inherit its path lesson: resolve containment from the invocation's directory, and test with two worktrees, or the check passes in single-agent testing and fails precisely when the fleet arrives).
- Process invariants: "no commit while tests fail," enforced by
a hook that runs the quick suite before allowing
git commit.
Everything else — style, structure, taste — stays advice. The standard pattern is the pairing: CLAUDE.md states the rule (so the model plans around it, and the denial never fires), the hook enforces it (so a planning failure can't become damage). Advice for the 99%, enforcement for the 1%; you've seen this pairing in lessons 5 and 8, and it recurs through every autonomy lesson ahead.
Writing hooks that fail safe
Hooks are security-adjacent code. Two rules from that world transfer directly, and both are cheap now and nearly impossible to retrofit after an incident:
1. A broken gate must be a closed gate. Decide what happens when your hook itself fails — script crash, missing dependency, unparseable input. If your logic treats "crashed" like "allowed," the gate is at its most open exactly when it's most broken. Structure so that only an explicit success allows and everything else — including the hook's own errors — denies:
#!/bin/bash
set -euo pipefail # any failure -> non-zero
input=$(cat)
path=$(jq -r '.tool_input.file_path // empty' <<<"$input")
case "$path" in
*/migrations/*) echo "BLOCKED: migrations/ is append-only; use npm run migration:new" >&2; exit 2 ;;
esac
exit 0 # explicit allow, last line
2. Prove it blocks — once, on purpose. A hook that has never fired is a hope with a config entry (the CLAUDE.md caveat, one level up: enforcement machinery has its own silent failure mode, which is passing everything). After writing a hook, ask the agent to violate it — request the forbidden edit, the dangerous command — and watch the denial happen and the model reroute. Sixty seconds converts a belief into a control. Re-prove after refactors; a matcher typo disarms a hook without a single symptom.
Operational craft, briefly: keep hooks fast (they're on the hot path of every matched action, hundreds of times per session — budget tens of milliseconds, not seconds); keep them deterministic (no model calls inside a gate — judgement is probabilistic and gates must not be; lesson 28 elevates this to a pipeline-wide law); and scope matchers tightly so you're not paying the hook tax on tools it never needed to inspect.
Why this lesson is the hinge of the course
Everything before it made instructions persistent, packaged, and triggered — but they stayed instructions. Hooks make the environment itself opinionated, and that's precisely what makes the second half of the course sane: unattended runs (lesson 18), scheduled jobs (17), CI agents (28), and fleets (19, 22, 29) are all agents acting where nobody is watching — tolerable exactly because the invariants don't depend on anyone watching. The teams that skip this lesson don't actually skip it; they take it later, from an incident, at incident prices.
A personal teaching project, in development. The registry it builds is a teaching artifact and is not an assurance opinion about any issuer. Not affiliated with or endorsed by any employer, or by any vendor whose tools it describes. Where a unit depends on a specific flag or path, it names the version it was verified against. All units.