Lesson 7.2 — Hooks, deep¶
A hook is a tripwire wired to the event — not a sign asking the agent to behave.
TL;DR: A hook is a deterministic command the harness runs on a lifecycle event — not the model 1. On the blocking events, exit code 2 stops the action; that's what turns an advisory rule ("always run tests") into an enforced one 14.
ELI5: the tripwire vs the sign¶
A sign relies on the hiker reading and obeying it; a tripwire is wired to the gate — cross the line and it fires, every time.
A prompt that says "please don't commit secrets" is a sign on the trail: it works only if the agent reads it and chooses to obey (and it's competing with everything else in the context window). A hook is a tripwire: it's wired to the event, not to the agent's goodwill — when the event fires, the command runs, no judgment call, whether or not anyone "read the sign."
flowchart LR
SIGN["prose rule in CLAUDE.md<br/>'always run the formatter'"] -->|"model may or may not attend"| MAYBE["happens… sometimes"]
HOOK["hook on PostToolUse(Edit)"] -->|"harness runs it, every time"| ALWAYS["happens, deterministically"]
What a hook is — and why deterministic wins¶
The harness, not the LLM, decides to run a hook; the model can't skip it, forget it, or decide it isn't needed 1.
This is the payoff of prose → hooks (Phase 4.4): a rule written as advisory prose is probabilistic — followed only when the model attends to it. The same rule as a hook is deterministic — it executes 100% of the time the event fires, outside the model's reasoning. It's 12-factor Factor 8, "own your control flow": keep authority in code, and interrupt the agent "ESPECIALLY between the moment of tool selection and the moment of tool invocation" 4 — which is exactly where a PreToolUse hook sits.
🧠 Test Yourself: "Never commit secrets" written in
CLAUDE.mdvs. the same rule as a hook — what actually changes?
The lifecycle events¶
Hooks bind to events; only pre-events can block — and the exit code is the contract 1.
The high-value core (the current Claude Code set is much larger — ~30 events — but these carry most lessons) 1:
| Event | Fires | Can block? |
|---|---|---|
SessionStart |
session begins/resumes | — (can inject context) |
UserPromptSubmit |
before the agent sees your prompt | ✅ |
PreToolUse |
before a tool runs | ✅ (the key one) |
PostToolUse |
after a tool succeeds | ❌ (react only) |
Stop / SubagentStop |
the agent is about to finish | ✅ (force it to continue) |
PreCompact |
before context compaction | ✅ |
Every hook gets a JSON object on stdin (session_id, cwd, hook_event_name, and for tool events tool_name/tool_input) 1. The exit code is the contract: 0 = success (stdout may carry JSON or injected context); 2 = block — stdout is ignored and stderr is fed back to the agent as the reason 1. The effect of a block is event-specific: on PreToolUse it cancels the tool call; on Stop it prevents stopping and pushes the agent to keep working 1.
Handler patterns → which event¶
Match the goal to the event — and remember only pre-events can stop something 1.
| Goal | Event | How |
|---|---|---|
Block a dangerous command (rm -rf, force-push) |
PreToolUse (matcher Bash) |
inspect tool_input.command; exit 2 to deny |
| Auto-format after an edit | PostToolUse (matcher Edit\|Write) |
run the formatter on tool_input.file_path |
| Gate finishing until tests pass | Stop |
run tests; exit 2 to force the agent to continue |
| Secret-scan before a write/commit | PreToolUse (+ the git/CI layer below) |
scan; exit 2 to deny |
| Reduce approval fatigue | PreToolUse |
auto-allow known-safe calls deterministically |
🧠 Test Yourself: You want to auto-run the linter after every file edit. Which event — and can it block a bad edit?
Answer
PostToolUseonEdit|Write. It fires after the edit, so it can react (format/lint/log) but cannot block it — the edit already happened. To prevent something, hook the matchingPreevent 1.
Agent-agnostic — and the fail-open trap¶
The concept is converging across harnesses, but the default failure mode is what bites you 123.
| Claude Code 1 | Codex 2 | Cursor 3 | |
|---|---|---|---|
| Naming | PascalCase | PascalCase | camelCase |
| Block mechanism | exit 2 / deny |
exit 2 / deny |
exit 2 / deny |
| On hook crash/timeout | blocks | blocks | fails OPEN — action proceeds |
⚠️ Cursor hooks fail open by default — the docs state a crashed/timed-out hook "allow[s] the action through (fail-open)." A security hook that errors silently stops protecting you unless you set
failClosed: true3. Never assume Claude/Codex's exit-2-blocks behavior carries over.
Innermost → outermost: the layers¶
Agent-session hooks are the inner gate; git pre-commit and CI are the outer gates that catch what escapes 567.
flowchart LR
A["PreToolUse / Stop<br/>(this session)"] --> B["git pre-commit<br/>(this clone — bypassable with --no-verify)"]
B --> C["CI on PR<br/>(whole repo — enforced, non-bypassable)"]
A harness hook protects one agent session. The git pre-commit hook aborts a commit on non-zero exit 6, and the pre-commit framework manages such checks via .pre-commit-config.yaml 5. But a contributor can --no-verify past a local hook — so CI is the enforced backstop: a workflow on pull_request that no human or agent can skip 7. Run the same check at multiple layers.
Worked example — our skill-mirror gate¶
This repo's own rule, promoted from prose to a deterministic gate at two layers.
The rule "keep .agents/skills/ and .claude/skills/ byte-identical" started as prose in CLAUDE.md — advisory, forgettable. It's now tests/check-skill-mirror.sh (POSIX sh; exits non-zero on any drift), wired into both outer gates:
# .pre-commit-config.yaml — fast local feedback when either skills tree changes
- repo: local
hooks:
- id: skill-mirror-parity
entry: tests/check-skill-mirror.sh
language: script
files: ^\.(agents|claude)/skills/
# .github/workflows/ci.yml — the enforced backstop, runs on every PR
- name: Skill mirror parity (.agents/skills == .claude/skills)
run: sh tests/check-skill-mirror.sh
That's the lesson in miniature: a forgettable prose rule became a tripwire — local pre-commit for speed 56, CI for enforcement 7 — exactly Factor 8's "own your control flow" 4.
Your turn (exercise)¶
Take a rule you keep re-typing to your agent ("run the linter after editing", "don't touch main"). Decide the event (a Pre event to block, a Post event to react), write a small script that reads the stdin JSON and exits 2 to stop, and wire it as a hook. Test that it actually fires. Then ask the real question: should it also be a git/CI gate, so a human — or a different agent — can't bypass it?
← Lesson 7.1 · Phase 7 home · next → Lesson 7.3 — MCP, deep
-
Hooks reference — Anthropic (Claude Code docs) ↩↩↩↩↩↩↩↩↩↩↩↩↩
-
Codex hooks — OpenAI ↩↩
-
Cursor hooks — Cursor (note: fail-open by default) ↩↩↩
-
12-Factor Agents — Factor 8: Own your control flow — humanlayer ↩↩↩↩
-
pre-commit — a framework for managing pre-commit hooks — pre-commit.com ↩↩↩
-
Git Hooks (Pro Git §8.3) — git-scm.com ↩↩↩
-
Understanding GitHub Actions — GitHub ↩↩↩