Lesson15 min

Hooks: Deterministic Automation

Lifecycle-event hooks for guaranteed behaviors — formatters, gates, notifications — that prompting alone can't promise.

Advisory vs guaranteed

Everything in CLAUDE.md is advisory: an instruction the model reads and almost always follows — but "almost always" is doing real work in that sentence. For some behaviors, "almost always" isn't good enough. You don't want the formatter run on most edits. You want it run on every edit.

Hooks are the answer: user-defined shell commands that Claude Code executes automatically at fixed lifecycle points. The model doesn't decide whether a hook runs — the harness runs it, every time, deterministically. The rule of thumb: preferences go in CLAUDE.md; guarantees go in hooks.

The lifecycle events

EventFiresTypical use
SessionStartNew session beginsInject env status or context
UserPromptSubmitYou send a promptAdd context, validate the request
PreToolUseBefore a tool runs — can block itGuardrails on dangerous commands
PostToolUseAfter a tool succeedsAuto-format edited files
PostToolUseFailureAfter a tool failsCustom error handling
NotificationClaude needs your inputDesktop notification
StopClaude is about to end its turnQuality gates — block "done" until tests pass
SubagentStopA subagent finishesValidate subagent output
PreCompactBefore context compactionPreserve critical state

The exit-code protocol

A hook is a shell command, and its exit code is its vote:

  • Exit 0 — proceed. For UserPromptSubmit and SessionStart hooks, anything printed to stdout is added to Claude's context — a clean way to inject fresh information.
  • Exit 2 — block, and feed stderr back to Claude as feedback. The model reads why it was blocked and adjusts. A PreToolUse hook exiting 2 stops the tool call itself.
  • JSON output — for finer control: explicit deny/ask/allow decisions, extra context injection, and per-event options.

That exit-2-with-stderr mechanism is the clever part: a hook isn't just a wall, it's a teacher. Block a command and say why, and Claude course-corrects in the same turn.

The canonical five

Auto-format after every edit. A PostToolUse hook matching Edit|Write runs Prettier on the changed file. Formatting stops being something Claude remembers — it's something that happens.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "npx prettier --write \"$FILE\"" }
        ]
      }
    ]
  }
}

Protect files. A PreToolUse hook that exits 2 when the target path matches migrations/ or .env, with stderr explaining the policy. Claude sees the explanation and works around the protected file instead of retrying it.

Guard rails on commands. A PreToolUse hook that exits 2 on any bash command containing rm -rf — a hard guarantee no prompt wording can bypass.

Notify when input is needed. A Notification hook fires a desktop notification, so long-running sessions don't silently stall waiting for you.

Audit logging. A PreToolUse hook appends every bash command to a log file — a complete record of what ran, independent of the transcript.

The Stop hook: "looks done" becomes "provably done"

The highest-leverage hook for quality: a Stop hook that runs your test suite and blocks the turn from ending until it passes. Without it, Claude finishes when the work looks done. With it, "done" is defined by a command exiting 0, and failures bounce straight back into the session for fixing. This mechanically closes the most common trust gap in agentic coding — work declared complete that doesn't actually pass.

There's also SessionStart with a compact matcher: after context compaction (when a long conversation is summarized to save space), critical context can get lost in summarization. This hook re-injects it deterministically.

Beyond shell commands

Not every rule is expressible as a deterministic script. "Block edits that remove error handling" needs judgment. For those, prompt-based and agent-based hooks exist — the hook consults a model to decide. Use them where mechanical rules can't reach, and keep plain shell hooks for everything else: deterministic, fast, free.

Practical tip: you don't hand-write hook configs. Ask Claude — "write a hook that runs eslint after every file edit" — and it produces the settings JSON. Review it, then enjoy behavior that no longer depends on anyone remembering anything.