Part 2 · Determinism & Control

Mastering Claude Code · ~7 min

Hooks & the Lifecycle

Stop asking the model to remember the rule. Wire it into the lifecycle so the harness enforces it every single time — before the call, after the call, no sampling involved.

Why this, for you: you've told Claude in CLAUDE.md to "always run the formatter" and "never rm -rf" — and it mostly listens. Mostly is the problem. A rule that holds 95% of the time is a rule you can't trust on the run that matters. Hooks move the rule out of the prompt and into the harness, where compliance is 100% because the model never gets a vote.

A CLAUDE.md instruction is a suggestion the model may follow. A hook is a shell command the harness runs whether or not the model would have. That single difference — probabilistic versus deterministic — is what makes hooks the right tool for any rule where "usually" isn't good enough.

1 Why hooks are deterministic

The guarantee is structural, not aspirational. Hooks are deterministic because the harness runs them, not the model. Claude Code invokes them at fixed points in the request loop — independent of any sampling decision the model makes.

Hooks are "must-do" rules; CLAUDE.md instructions are "should-do" suggestions. Use hooks when compliance is non-negotiable — formatting, security checks, validation. Use prompts when flexibility is acceptable and the rule can bend gracefully under load.

Two fixed points anchor everything in this lesson. PreToolUse fires before the harness dispatches a tool call — so it can still block it. PostToolUse fires after the result comes back — so it can react and auto-fix, but the action has already happened. That ordering decides which job each event can do.

2 PreToolUse: the gate (exit code 2 blocks)

Hook input arrives on stdin as JSON — not environment variables — so scripts pipe stdin through jq to read tool-input fields. A PreToolUse hook that exits 2 cancels the call and sends its stderr message back to Claude as feedback. That makes exit 2 a reliable block for PreToolUse, PermissionRequest, UserPromptSubmit, Stop, and config-change events.

# block-dangerous-commands.sh — matcher: "Bash" COMMAND=$(jq -r '.tool_input.command' < /dev/stdin) for pattern in "rm -rf /" "dd if=/dev"; do if echo "$COMMAND" | grep -qF "$pattern"; then echo "Blocked: '${pattern}' is not permitted" >&2 exit 2 # cancels the Bash call before it runs fi done

A matcher string scopes the hook. "*" or empty matches everything; a plain name or pipe-list ("Bash", "Write|Edit") matches exactly; anything else parses as a regex. For PreToolUse/PostToolUse the matcher filters on tool name — never the file path.

Over-scoped blocks get the hook disabled

A PreToolUse blocker that substring-matches rm also blocks rm node_modules and even echo "term". Those false positives push users to turn the hook off entirely — worse than no guard. Match precisely, not loosely.

3 PostToolUse: the auto-fix (side-effect, not gate)

The canonical PostToolUse job is auto-formatting. Claude doesn't reformat to project style on its own, and the usual fixes are all worse than infrastructure: prompting Claude to format burns tokens on a mechanical task, manual runs get forgotten, and inconsistent agent code muddies every diff. A PostToolUse hook runs the formatter once after every write, whether Claude or a human made the edit.

# settings.json — format the file that was just written "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }] }]

The event passes tool_input.file_path on stdin, so you format that file directly — no glob scan. Need ordering (formatter then linter)? Chain them in one command; multiple handlers on the same event fire in parallel, so they can't enforce a sequence on their own.

PostToolUse is the right event for formatting — the file must exist before a formatter can run. But it cannot block the call; the write already happened. It is a side-effect hook, not a gate. To block a write, use PreToolUse with a path check instead.

One performance note: every PostToolUse hook runs synchronously on the hot path, so a slow formatter slows every edit. For multi-second formatters set "async": true to run in the background — at the cost of losing in-loop formatter errors. For black, prettier, gofmt the blocking default is right; they finish in milliseconds.

4 When a hook is the wrong tool

Hooks fail when the rule they encode is aspirational rather than absolute. The traps:

FailureWhat bites
Hidden global stateHooks are invisible in the transcript unless they exit non-zero — a "misbehaving agent" is often a hook silently rewriting files.
Brittle schema couplingScripts parse tool_input; a renamed field in a new release makes them fail silently or block valid calls.
Oscillating editsA formatter and a fix-on-save linter in one hook can each reverse the other's output.
CLAUDE.md would doIf the rule bends gracefully — prefer a style, avoid a phrase — a prompt fails softer and keeps the model in the loop.

The deciding question is always the same: is this rule non-negotiable, or does it bend under load? Non-negotiable goes in a hook. Bendable goes in CLAUDE.md.

↪ Your win: rules the model can't skip

Retrieval practice — recall, don't peek

Question 1Hooks are deterministic because…

Question 2In a PreToolUse hook, exiting with code 2…

Question 3Auto-formatting belongs on PostToolUse, not PreToolUse, because…

Question 4A PreToolUse/PostToolUse matcher filters on…

Question 5 · spaced recall from an earlier lessonPlan Mode pays off because…

Ask me anything. Want help deciding whether a specific rule of yours should be a hook or a CLAUDE.md line, or a worked PostToolUse formatter for your stack? Next in Part 2: Permission Modes — tuning autonomy with auto mode for speed and hard-deny for the floor.
✎ Feedback