Lab 2.2 ended at a ceiling: your don't-touch rule held in most sessions, and "most" was the best CLAUDE.md could do, because memory is context and context is persuasion. Part 1 told you why persuasion degrades: attention dilutes, compaction drops instructions, and a probabilistic system stays probabilistic no matter how firmly you phrase the rule. Hooks are the other kind of thing. A hook is your code, run by the harness at a fixed lifecycle point, with the power to block what the model wanted to do. It does not read the room. It does not degrade at 80% context fill. It exits 2, and the edit dies.
The lifecycle, and where your code attaches
Hooks are user-defined shell commands (HTTP endpoints and model-evaluated prompt hooks exist too; commands are the workhorse) that execute at specific points in a session. The docs' framing is the whole thesis: they provide deterministic control, ensuring actions always happen rather than relying on the LLM to choose. When an event fires, Claude Code sends JSON describing it to your handler's stdin; your handler inspects it, acts, and answers through exit codes or JSON on stdout.
The event catalog is large (nearly thirty events as of July 2026, down to worktree creation and MCP elicitations), but it organizes cleanly by cadence: once per session (SessionStart, SessionEnd), once per turn (UserPromptSubmit, Stop), and on every tool call inside the agentic loop (PreToolUse, PostToolUse). Those six carry most guardrail work, and three carry this lesson:
- PreToolUse fires after Claude decides on a tool call, before it executes. It can block. This is where "never touch that file" and "no commits with failing tests" become physics.
- PostToolUse fires after a tool succeeds. It cannot block what already happened, but its output goes back to Claude, making it the feedback channel: run the typechecker after every edit and the loop hears about breakage immediately, from a machine, every time.
- Stop fires when Claude believes it is finished. Exit 2 refuses the stop and sends Claude back to work, which turns "done means verified" into an enforced definition rather than a hope.
Configuration lives in the settings files you already know (~/.claude/settings.json, .claude/settings.json, .claude/settings.local.json), under a hooks key: event name, then a matcher to filter (for tool events it matches the tool name: Edit|Write, Bash, regex if you need it), then the handlers to run. The /hooks menu shows what is configured and from where.
The contract: exit codes and JSON
Your hook talks back through the process contract, and one detail here causes more broken guardrails than everything else combined. Exit 0 means success; stdout may carry JSON for fine-grained control. Exit 2 means block, and stderr is fed to Claude as the explanation it course-corrects from. Every other exit code, including 1, is a non-blocking error: the action proceeds. The docs warn about this explicitly because 1 is the conventional Unix failure code. A guardrail script that fails with exit 1 (say, jq was missing) silently enforces nothing. If your hook exists to say no, it must say exit 2, and its error paths must be designed, not left to whatever the shell happens to return.
For richer control, exit 0 and print JSON instead. PreToolUse hooks return a permissionDecision of allow, deny, or ask, letting one hook auto-approve the routine and escalate the suspicious, plus an updatedInput that can rewrite a tool call before it runs. PostToolUse and Stop use a top-level decision: "block" with a reason. Any hook can return additionalContext to inject information into Claude's context. Pick one channel per hook: JSON is only parsed on exit 0.
Here is the complete guardrail pair this lesson's lab installs. First, the config:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/protect-paths.sh"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit *)",
"command": "bash .claude/hooks/test-gate.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/typecheck.sh"
}
]
}
]
}
}
The protected-path script. It reads the event JSON from stdin, extracts the target path with jq, and blocks matches:
#!/bin/bash
# Blocks edits to frozen files. Exit 2 = block, stderr goes to Claude.
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# jq missing or no path extracted: fail CLOSED for a guardrail.
if [ -z "$FILE_PATH" ]; then
echo "protect-paths: could not read file_path from hook input" >&2
exit 2
fi
for pattern in "parse-duration.ts" ".env" "package-lock.json"; do
case "$FILE_PATH" in
*"$pattern"*)
echo "Blocked: $FILE_PATH is protected ($pattern). Extend behavior in a new file instead." >&2
exit 2
;;
esac
done
exit 0
The test gate, scoped by the if field so it only runs when the Bash command matches git commit *:
#!/bin/bash
# Runs before any git commit. Failing tests block the commit.
if ! npm test > /tmp/test-gate-output.txt 2>&1; then
echo "Commit blocked: tests are failing. Fix them first." >&2
tail -20 /tmp/test-gate-output.txt >&2
exit 2
fi
exit 0
And the post-edit typecheck, feedback rather than a gate:
#!/bin/bash
# After every edit: typecheck, and feed errors straight back to Claude.
if ! npx tsc --noEmit > /tmp/typecheck-output.txt 2>&1; then
echo "Typecheck failing after your edit:" >&2
tail -30 /tmp/typecheck-output.txt >&2
exit 2
fi
exit 0
That last one uses exit 2 on a PostToolUse event, where nothing can be un-run; per the docs' table, the effect is that stderr is shown to Claude. The edit stands, but the loop immediately knows it broke the build and iterates. Blocking and feedback are the same mechanism pointed at different moments.
Guardrails over instructions, argued properly
Here is the position, with the receipts. An instruction and a hook are not two strengths of the same thing; they are different mechanisms with different failure modes.
CLAUDE.md: "Never modify parse-duration.ts. Always run tests before committing."
Honored when attention is fresh. Erodes as the window fills; can vanish in compaction; weighs against every other instruction on every inference. Enforcement is a probability that you tuned with phrasing.
PreToolUse hook: matches Edit and Write, exits 2 on protected paths; test gate blocks git commit while tests fail.
Enforcement is a property of the harness. Works at any context fill, survives compaction (it was never in context), applies to every session including the ones run by teammates or CI, and cannot be argued with.
The division of labor follows from the mechanics: instructions for judgment, hooks for invariants. "Prefer small functions" needs taste; it belongs in CLAUDE.md, and its occasional violation is tolerable. "Tests pass before commit" has no judgment component and its violation is never tolerable; leaving it as an instruction converts a hard requirement into a dice roll. And the pairing is symbiotic, not redundant: the CLAUDE.md rule tells Claude why the boundary exists, so it plans around it; the hook catches the residue when context pressure wins anyway. Keep both, but never let the instruction be the only line of defense on an invariant.
One honest caveat keeps this position calibrated. For a pure static block ("never read .env"), a permission deny rule from 2.1 does the same job with less machinery, and the docs note the hooks' if filter is best-effort and can fail open on unparseable commands, so permission rules remain the harder wall for allow/deny by pattern. Hooks earn their complexity the moment the guardrail needs computation: run the tests, check the typecheck, inspect the diff, consult a file. Rules answer "is this call allowed?"; hooks answer "is this call allowed right now, given the state of the world?"
Where it breaks
The first break is the exit-1 silent failure covered above: guardrail scripts must fail closed, which is why protect-paths.sh exits 2 when it cannot even parse its input.
The second is the Stop-hook loop: a Stop hook that blocks until tests pass, in a repo where tests cannot pass, would trap Claude forever. The input includes stop_hook_active: true when the hook already forced continuation, precisely so your script can yield; the harness also caps consecutive Stop blocks at 8 and then overrides. Design your Stop conditions to be reachable.
The third is security, and the docs do not soften it: command hooks run with your full user permissions, no permission prompt, on every matching event. A hook is arbitrary code execution you installed. The practical rules: never paste a hook config from a repo or blog without reading the script it invokes; quote every shell variable ("$FILE_PATH", because filenames with spaces or metacharacters are inputs an adversarial file tree controls); use absolute or project-anchored paths for scripts; and treat a plugin's hooks/ directory as part of your audit surface in 2.8. The workspace trust dialog gates project hooks for exactly this reason.
Fourth, the quiet one: hooks add latency. A typecheck after every edit is fine at two seconds and misery at forty. Scope matchers tightly, keep scripts fast, and remember timeouts default to 600 seconds, which is not the ceiling you want on a hot path; set timeout per hook when the default is absurd for the job.
Lablab-2-6Install physics in the practice repo
Goal: Implement the protected-path and test-gate hooks, prove both block for real, and watch PostToolUse feedback drive the loop.
Prereqs: the agentic-practice repo, jq installed (brew install jq or your package manager's equivalent).
- Create the three scripts and the settings JSON from the artifacts above.
chmod +x .claude/hooks/*.sh. Adjust the test command if your repo differs. Restart your session so the hooks load, and check/hookslists them. - Sanity-check the guard offline first, no Claude involved:
echo '{"tool_input":{"file_path":"/tmp/x/parse-duration.ts"}}' | bash .claude/hooks/protect-paths.sh; echo "exit=$?". Expect the block message and exit=2. Repeat with a harmless path; expect exit=0. - Now the live test that Lab 2.2 could not pass deterministically: tell Claude directly, "add a comment to the top of parse-duration.ts." It should attempt the edit, hit the hook, see your stderr message, and adjust course. Run it three times if you like; it blocks every time. That reproducibility is the whole lesson.
- Break a test on purpose (flip an assertion), then ask Claude to commit. The test gate should block with the failing output, and Claude should move to fix the test rather than commit. Restore the assertion, ask again, and watch the commit pass the gate.
- Watch feedback close the loop: ask for a change and, mid-flight, notice the typecheck hook. Introduce a deliberate wrinkle ("rename this exported function but skip updating one call site"), and watch the PostToolUse stderr pull Claude back to fix the breakage without you saying anything.
- Record in
hooks-lab.md: for each hook, what fired it, what it returned, how Claude responded. This is the evidence file for Checkpoint 2.
Verify
- Step 2's offline runs return exit=2 with a message for protected paths and exit=0 otherwise.
- Step 3 blocks on every attempt, and Claude's response references your stderr text (proof the message reached the model).
- Step 4 blocks the commit while red and permits it when green.
- Step 5 shows an edit, a typecheck failure fed back, and an unprompted corrective edit in sequence.
>Troubleshooting
- Nothing fires: hooks load at session start, so restart after editing settings; then check
/hooksshows them. If it shows nothing, your settings JSON likely has a syntax error; validate withjq . .claude/settings.json. - protect-paths blocks everything including innocent files: your
casepatterns are substring matches; a pattern like.envalso matchesenvironment.tsif you wrote it without care. Tighten patterns and re-run step 2's offline checks. - The test gate never triggers: the
iffield only sees Bash tool calls; committing through some other route (an MCP git tool, for instance) will not matchBash(git commit *). Check how the commit is actually being made in the transcript. - Typecheck hook makes every edit crawl: your repo's tsc is slow; scope the matcher to source files or use a faster incremental check. A guardrail the operator disables out of irritation protects nothing.
Knowledge check
Knowledge check
Sources
- Hooks reference (event catalog and cadences, configuration schema, matchers, if field, exit-code semantics and per-event blocking table, JSON output, permissionDecision, Stop input and 8-block cap, security disclaimer): https://code.claude.com/docs/en/hooks (fetched July 2026)
- Automate actions with hooks (first-hook walkthrough, protected-files and auto-format patterns, hook locations, troubleshooting): https://code.claude.com/docs/en/hooks-guide (fetched July 2026)