Lesson 4.4 — Prose to hooks¶
If it must happen every time, it's a hook, not a sentence.
TL;DR (1–2 lines): Knowledge files are advisory guidance; hooks are deterministic enforcement wired to lifecycle events. Promote any must-happen-every-time rule out of prose into a hook — and mind the fail-open trap on Cursor.
ELI5 — the sign vs the spring lock¶
A sign asking people to lock the door depends on attention; a spring lock locks itself every time — that's the difference between prose and a hook.
Two ways to keep a door locked:
- A sign — "please lock the door." People mean well; some nights they forget.
- A spring lock — locks itself every time the door closes. Nobody can forget.
A line in AGENTS.md is the sign. A hook is the spring lock. If something genuinely must happen every time, a sign is the wrong tool — no matter how firmly worded.
flowchart LR
subgraph P["PROSE — a wish"]
p1["'always run the formatter'"] --> p2["followed... usually<br/><i>(depends on attention)</i>"]
end
subgraph H["HOOK — a guarantee"]
h1["on tool.post (edit) → run formatter"] --> h2["runs. every time. deterministically.<br/><i>(no LLM judgment involved)</i>"]
end
The law of this phase¶
Guidance is probabilistic; hooks are deterministic — emphasis in prose is the tell that you're reaching for enforcement and should promote to a hook.
If it must happen every time, it's a hook, not a sentence.
This isn't opinion — it's how the agents are built. Anthropic states it directly: "Unlike CLAUDE.md instructions which are advisory, hooks are deterministic and guarantee the action happens." 1 Knowledge files (AGENTS.md, skills, rules) are guidance — the agent might follow them, usually does, but it's probabilistic. A hook is code wired to a lifecycle event; the agent's attention and mood don't enter into it.
So the moment you catch yourself wording a steering line really emphatically — "ALWAYS", "NEVER", "under no circumstances" — that's the tell. Emphasis is you trying to make probabilistic guidance behave like deterministic enforcement. It won't. Promote it to a hook.
🧠 Test Yourself:
AGENTS.mdsays in bold "ALWAYS run tests before declaring done." On a long session the agent declares done with tests failing. What does this most precisely reveal?Answer
The line is guidance — advisory and probabilistic — so on a long session context rot buries it. Firmer wording, repositioning, or trimming won't make guidance deterministic. It must be a hook (aStop-gate the agent can't skip).
The promotion test¶
Two questions decide it: must it hold every time, and can a deterministic script check it? Two yeses → hook.
flowchart TD
Q1{"Is it a hard rule that<br/>must hold EVERY time?"}
Q1 -- "no" --> G1["leave it as guidance<br/>(AGENTS.md / skill)"]
Q1 -- "yes" --> Q2{"Can a deterministic script<br/>check or do it?"}
Q2 -- "no" --> G2["genuine judgment call →<br/>guidance, accept the risk"]
Q2 -- "yes" --> HOOK["HOOK. Wire it to a lifecycle event.<br/>Stop relying on the sentence."]
Classic prose that's really a hook:
| Prose in AGENTS.md (a wish) | The hook it should be | Canonical event |
|---|---|---|
| "Always format on save" | run formatter after every edit | tool.post (Edit|Write) 1 |
"Never read .env / secrets" |
block reads on secret paths | tool.pre (read) 1 |
| "Don't finish with failing tests" | refuse to stop until make test is green |
turn.stop-gate 12 |
"Never commit to main" |
deny git commit on protected branch |
tool.pre (shell) 1 |
| "Run lint before declaring done" | run lint, block on failure | turn.stop 1 |
Left as prose, each is a coin flip on a busy session. As a hook, it's a law.
Worked example — promote a Stop-gate¶
The prose line leaves AGENTS.md and becomes a Stop hook that blocks the turn while tests fail — the guarantee moves from your wording into the runtime.
AGENTS.md says, in bold: "ALWAYS run make test before saying you're done." Watch it fail: turn 19, the desk is full (Phase 2), the agent is confident, declares victory without running tests. Guidance lost to context rot.
Promote it. The prose leaves AGENTS.md; a Stop hook takes its place:
// .claude/settings.json — exit 2 on a Stop hook blocks the turn from ending [^1]
{
"hooks": {
"Stop": [{ "command": "make test || exit 2" }]
}
}
On exit-2, a Stop hook tells Claude to keep working rather than finish — the failure is fed back and the agent iterates until tests pass 1. No emphasis, no hoping; the guarantee moved into the runtime. (Anthropic notes Claude overrides the gate after a fixed number of consecutive blocks, so the loop can't wedge forever 2.)
🧠 Test Yourself: On a
Stophook, what does exit code 2 do — and why isn't that the same as exit 1?Answer
Exit 2 is a blocking error: it stops the turn from ending and feeds the message back so the agent keeps working. Exit 1 (and other non-zero codes) are non-blocking — the action proceeds anyway. Only exit 2 enforces 1.
The two portability traps¶
Cursor fails open by default (a broken security hook is silently ignored), and events are near-portable but not identical — author canonical, render native.
If you ship hooks across agents, two facts will bite you:
| Trap | What bites you | The fix |
|---|---|---|
| Fail-open vs fail-closed | Claude & Codex fail closed on exit-2 (block sticks). Cursor fails open by default — a crashed/timed-out/invalid security hook is ignored 3 | Set failClosed: true on Cursor security hooks 3 — without it, a "never read secrets" hook is decoration |
| Events near-portable, not identical | Cursor splits tool.pre into beforeShellExecution / beforeReadFile, and lacks compact.post / session.end 3 |
Author against the canonical event; let an adapter render the native one |
A guardrail that fails open isn't a guardrail. Cursor's own docs flag failClosed: true as "recommended for security-critical" hooks — opt in explicitly 3.
🧠 Test Yourself: You ship one
.env-read-blocking hook to Claude, Codex, and Cursor. It blocks on Claude and Codex but silently lets.envreads through on Cursor. Most likely cause?Answer
Cursor fails open by default — a failing/erroring security hook is ignored unless you setfailClosed: true3. Claude and Codex fail closed, which is why it worked there. (Cursor does have pre-read events —beforeReadFile— so it's not a missing-event problem.)
Your turn (exercise)¶
Grep your steering files for ALWAYS, NEVER, must, every time. Each hit is a suspect. Pick the strongest and answer: can a deterministic script enforce it? If yes, sketch the hook — which event (tool.pre, tool.post, turn.stop?) and the one-line command — and delete the prose. You've just converted a wish into a guarantee.
← Lesson 4.3 · next → Lesson 4.5 — What the scaffolder automates