CLAUDE.md says "should." Hooks say "must."
That is the fundamental difference, and it changes everything about reliability.
The reliability gap
A CLAUDE.md rule like "always run lint after editing a file" works about 80% of the time. Claude reads it, understands it, and usually follows it. But under context pressure — when the window is filling up, when it is deep in a complex task, when it is rushing to finish — it might skip that step. Not out of malice. Out of the same kind of oversight a tired developer would have.
A PostToolUse hook that runs ESLint after every Edit works 100% of the time. Zero exceptions. It fires automatically, outside the agent's control. The agent cannot skip it, forget it, or decide it is not relevant this time.
Use hooks for things that must always happen. Use CLAUDE.md and skills for things that should happen when relevant.
Hook lifecycle events
Claude Code exposes five lifecycle events. Each fires at a specific moment, and each has different capabilities.
PreToolUse
Fires before a tool executes. Can BLOCK the action (exit code 2) or WARN (print to stderr). Use for: preventing writes to sensitive files, blocking destructive git commands, reminding about conventions before edits.
PostToolUse
Fires after a tool completes. Cannot block — the action already happened. Use for: auto-formatting, linting, type checking, quality analysis after edits.
Stop
Fires after each Claude response completes. Use for: session summaries, pattern extraction, cost tracking, saving session state.
PreCompact
Fires before context compaction (when the context window is too full and Claude summarizes). Use for: saving state that must survive compaction, preserving important context.
SessionStart
Fires when a new session begins. Use for: loading previous session state, detecting project type, running initial checks.
Real hook examples
Here is what hooks look like in practice. These go in your .claude/settings.json file.
Auto-lint after every edit
The most common first hook. Every time Claude edits a file, ESLint runs automatically on that file.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit",
"command": "npx eslint --fix $CLAUDE_FILE_PATH 2>/dev/null || true"
}
]
}
}The 2>/dev/null || true at the end is important. If the file is not a JavaScript/TypeScript file, or if ESLint is not configured for it, the hook silently succeeds instead of erroring out. You do not want a CSS edit to crash your hook.
Block writes to sensitive files
This one prevents Claude from writing to .env files. Ever. No exceptions.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"command": "if echo $CLAUDE_FILE_PATH | grep -q '.env'; then echo 'BLOCKED: Cannot write to .env files' >&2; exit 2; fi"
}
]
}
}Exit code 2 is the key. It tells Claude Code to block the action entirely. The agent sees the error message and adjusts its approach. Exit code 0 means continue. Any stderr output without exit code 2 is treated as a warning — Claude sees it but proceeds.
The hook input schema
Hooks receive structured data. On stdin, your hook gets a JSON object with:
tool_name— which tool is being used (Edit, Write, Bash, etc.)tool_input— the parameters being passed (command, file_path, old_string, new_string)- For PostToolUse only:
tool_output— what the tool returned
This means your hooks can be smart. You can write a hook that only blocks Bash commands containing rm -rf, or a PostToolUse hook that only runs type checking when a .ts file was edited.
#!/bin/bash
# Read the JSON input from stdin
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only run TypeScript check on .ts/.tsx files
if [[ "$FILE_PATH" == *.ts ]] || [[ "$FILE_PATH" == *.tsx ]]; then
npx tsc --noEmit 2>&1 | head -20
fiSession persistence hooks
The lifecycle events are not just for quality checks. They create continuity across sessions.
Here is the pattern: SessionStart loads the most recent session summary from a file. During the session, Claude works normally. When the session ends, a Stop hook parses the transcript and saves structured state — what tasks were completed, which files were modified, what tools were used, and any unresolved issues.
If context compaction happens mid-session (the window fills up and Claude summarizes), a PreCompact hook saves the current state first. When the session resumes after compaction, the critical context is preserved.
This creates a persistent agent. Session boundaries become invisible. You can close your terminal, come back the next day, and Claude picks up where it left off — not because it remembers, but because the hooks saved and restored state automatically.
{
"hooks": {
"SessionStart": [
{
"command": "cat .claude/session-state.json 2>/dev/null || echo '{}'"
}
],
"Stop": [
{
"command": ".claude/scripts/save-session-state.sh"
}
],
"PreCompact": [
{
"command": ".claude/scripts/save-session-state.sh --pre-compact"
}
]
}
}Start simple
I want to be clear: you do not need all of this on day one. The correct starting point is one PostToolUse hook that runs your linter after edits. Live with that for a week. Notice what it catches.
Then add one PreToolUse hook that blocks something dangerous — writing to .env, force-pushing to main, deleting migration files. Whatever scares you most.
After that, let your needs drive the additions. Every hook you add should solve a real problem you have already experienced. Do not add hooks preemptively. You will end up with a complex system that solves imaginary problems and slows down real work.
The rule: if CLAUDE.md says it and Claude keeps forgetting it, that rule should be a hook. If Claude follows the rule 95% of the time, leave it in CLAUDE.md. Hooks are for the 100% cases.
As your hook library grows, you will want profiles. The pattern used by mature setups is an environment variable (like HOOK_PROFILE) that selects which hooks are active:
- Minimal — essential lifecycle and safety hooks only. Fast. For quick tasks and experiments.
- Standard — balanced quality and safety. The default for daily work.
- Strict — additional reminders, stricter guardrails, more aggressive quality checks. For production-critical work or unfamiliar codebases.
You toggle between profiles by changing one variable, not by editing hook configuration files. This is the kind of infrastructure that only makes sense after you have 5+ hooks — do not build it preemptively.