Chapter 3 of 9. A rule the model can decline is not a rule. Prerequisite: Chapter 2. You should already have a CLAUDE.md, and you should already have watched the model read it and then do something else.
Chapter 2 ended on a sentence from Anthropic's own memory documentation: "Claude treats them as context, not enforced configuration. To block an action regardless of what Claude decides, use a PreToolUse hook instead." The same page repeats the escalation as an instruction. If a rule must run at a fixed point, "write it as a hook instead."
Fine. So you go and search for a Claude Code Stop hook example, and you find the reference page plus a great many echo statements. The reference is correct and it is where you should start. What it will not tell you is the shape a hook takes once it has survived a real session: the infinite loop it can cause, the exit code that quietly does nothing, the bypass you will want at 2am, and the way a hook can silently stop being wired while its tests still pass.
Version stamp for everything below: Claude Code 2.1.209, checked 2026-07-14. The lifecycle event list in particular has grown well past the five events most tutorials name, so verify against the reference before copying anything from a post older than this one.
Exit 2, or your hook is decoration
Anthropic's hooks guide gives the definition the rest of this course leans on. Hooks "provide deterministic control over Claude Code's behavior, ensuring certain actions always happen rather than relying on the LLM to choose to run them."
Compare that with the MCP specification, which is candid that "MCP itself cannot enforce these security principles at the protocol level" and hands enforcement to the host. A protocol negotiates; a hook is a process with an exit code that the host obeys.
A hook is not a file with frontmatter, and that trips people who arrive from skills and subagents. It is a JSON entry in a settings file, keyed by lifecycle event, holding a matcher and a list of commands:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/fcp-render-verify.sh" }
]
}
]
}
}
Now the part that decides whether your hook enforces anything at all. From the hooks reference, verbatim:
"For most hook events, only exit code 2 blocks the action. Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use
exit 2."
The other half of the contract is that you pick one signalling mechanism per hook. Again from the reference: "You must choose one approach per hook, not both: either use exit codes alone for signaling, or exit 0 and print JSON for structured control. Claude Code only processes JSON on exit 0." Exit 2 sends stderr back to the model as an error and ignores stdout entirely. Exit 0 with a JSON body on stdout gives you the structured route, including permissionDecision: "deny" on PreToolUse and decision: "block" with a reason on Stop.
Be precise about which events stop something. PreToolUse and Stop prevent the action outright on exit 2, one before the tool call and one before the turn ends. PostToolUse cannot prevent a tool call, since by the time it runs the tool has already run, and exit 2 there is a non-blocking error that shows its stderr to Claude. What PostToolUse can still do is hand the model a top-level decision: "block" with a reason via JSON, the same field Stop uses. Blocking after the fact and preventing the fact are separate jobs, and the reference lists which events can do each.
A Claude Code Stop hook example that actually blocks
Here is the case. Editing a video timeline file, pushing it into the editor, seeing a clean import with zero warnings, and declaring victory. On 2026-07-12 that produced two silent failures on this machine within a day of each other: a timeline where every clip had been transformed off-screen, so the viewer was pure black, and a guessed effect identifier that imported without complaint and rendered the transition black. Neither is a correctness bug the model can see. The file is valid. The import is clean. Only a rendered frame tells the truth.
No CLAUDE.md line will hold that rule, because the model has every reason to believe it has finished. So it became a Stop gate, and the gate is two scripts.
First, a PostToolUse marker. It never blocks. All it does is leave a note that this session touched the dangerous surface:
#!/usr/bin/env bash
# PostToolUse(Write|Edit|Bash) — mark the session when an FCPXML push happens.
#
# A push is NOT only a Write of an .fcpxml: the exporter writes the file itself, so the
# real signals are (a) Write/Edit of an .fcpxml, and (b) a Bash command that runs the
# exporter or opens an .fcpxml into Final Cut. Both are covered here.
# Silent, never blocks. The Stop hook reads the marker.
set -uo pipefail
input="$(cat)"
sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)"
tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)"
hit=""
case "$tool" in
Write|Edit)
f="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
case "$f" in
"$HOME"/.claude/*|*/docs/*|*/probes/*) exit 0 ;; # docs/hooks/fixtures are not pushes
*.fcpxml) hit="$f" ;;
esac
;;
esac
[ -n "$hit" ] || exit 0
dir="${TMPDIR:-/tmp}/claude-fcp"
mkdir -p "$dir" 2>/dev/null || exit 0
printf '%s\n' "$hit" >> "$dir/${sid}.marker" 2>/dev/null || true
exit 0
Two details worth stealing. Every hook reads a JSON payload on stdin, and the reference documents the schema: session_id, tool_name, tool_input and more. Then find the first arm of the inner case, the one that exits early on any path under "$HOME"/.claude/. Complying with this gate means writing files, so a gate that fires on its own remediation can never be satisfied.
Now read that script's header against its body. The header claims Write|Edit|Bash, and it argues the case: the exporter writes the .fcpxml itself, so a Bash command that runs the exporter is a push too, and "Both are covered here." Only one is. The settings block registers the hook on Write|Edit, the case statement handles Write|Edit, and the header is aspirational. Trust the case statement over the prose sitting above it, and expect this class of drift in your own hooks within about a month.
Second, the Stop gate. This is the one that blocks:
#!/usr/bin/env bash
set -uo pipefail
input="$(cat)"
sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)"
dir="${TMPDIR:-/tmp}/claude-fcp"
m="$(ls "$dir"/*.marker 2>/dev/null | head -1)"
[ -n "$m" ] || exit 0
stamp="$dir/last-render.stamp"
if [ -f "$stamp" ] && [ "$stamp" -nt "$m" ]; then
rm -f "$dir"/*.marker # rendered after the push — verified
exit 0
fi
files="$(cat "$dir"/*.marker 2>/dev/null | sort -u | head -5 | sed 's|^| - |')"
rm -f "$dir"/*.marker # one-shot: clear before emitting so we never loop
jq -n --arg files "$files" '
{
systemMessage: "FCPXML pushed this session — render-to-verify gate fired.",
decision: "block",
reason: ("FCPXML WAS PUSHED THIS SESSION BUT NO FRAME WAS RENDERED BACK OUT OF FINAL CUT.\n\nA clean import is NOT proof.\n\nFiles pushed this session:\n\($files)\n\nIf a frame was already rendered and compared, say so briefly and stop.")
}'
exit 0
The header comment is cut and the reason string is trimmed for length. Everything else is the file.
The satisfaction condition is a timestamp comparison. A separate tool touches last-render.stamp when it saves a frame out of the editor. Where that stamp is newer than the marker, a frame was rendered after the push, and the gate clears itself on the way out. So the hook checks for an artefact that can only exist if the work was verified, and checks that it is newer than the thing it verifies.
- Trigger on an artefact
Pick something that cannot exist unless the rule was followed: a rendered frame, a staged diff, an exit code from a test run. Name the artefact, not the intention. If your gate's condition is "did Claude say it did the thing", you have written a checklist, and Chapter 2 covered what those are worth.
- Mark in PostToolUse, gate in Stop
PostToolUse cannot prevent a tool call, so it is free to be chatty and cheap. Give it one job: write a marker when the risky surface is touched. Keep the Stop hook stupid, so that when it fires at 2am you can read it in ten seconds.
- Make it one-shot
Delete the marker before you emit the block. Always. Test this deliberately by triggering the gate and then refusing to comply, and confirm the session can still end.
- Put the remediation in the reason string
The
reasongoes straight back into the model's context. A numbered remediation gets followed. "Verification required" gets acknowledged and ignored.
Blocking a command before it runs: the PreToolUse gate
Stop gates catch what already happened. PreToolUse catches the command before the harness runs it. Reach for it when the cost lands the instant the process spawns.
The lived case is in the quota article: three Archon worktrees running at once, two of which had never been consciously approved, each burning the same rolling five-hour window. Automated runs were already held behind an approval gate. Nothing stood in front of the manual path, and the session that typed the command had already decided by the time it typed it.
The fix was not a smarter scheduler. It was a speed bump. Below the module docstring and the imports, this is the whole file:
_ARCHON_RE = re.compile(r"\barchon\b(?:\s+\S+){0,10}?\s+workflow\s+(?:run|resume)\b")
def main() -> int:
# One-shot manual bypass via env var (works when set in the parent
# shell or claude config — not from an inline `VAR=1 cmd` prefix
# because hooks spawn before the shell evaluates that assignment).
if os.environ.get("BBBRAIN_PREFLIGHT_BYPASS") == "1":
return 0
# Fail open on any input parsing error — a broken hook must never
# block work.
try:
payload = json.load(sys.stdin)
except Exception:
return 0
cmd = (payload.get("tool_input") or {}).get("command", "") or ""
# Second bypass path: the literal string `BBBRAIN_PREFLIGHT_BYPASS=1`
# in the command itself. This is what makes `BBBRAIN_PREFLIGHT_BYPASS=1
# archon workflow run ...` work — the literal IS present in the cmd
# string the hook receives, even though the env-var assignment doesn't
# actually reach this Python process.
if "BBBRAIN_PREFLIGHT_BYPASS=1" in cmd:
return 0
if not _ARCHON_RE.search(cmd):
return 0 # not an archon spawn — silent pass
short = cmd[:160].replace("\n", " ")
print("", file=sys.stderr)
print("═══ ARCHON PRE-FLIGHT ═══", file=sys.stderr)
print(f" About to launch: {short}", file=sys.stderr)
print("", file=sys.stderr)
print(" Review your current session availability before proceeding.", file=sys.stderr)
print(" To proceed: re-run with BBBRAIN_PREFLIGHT_BYPASS=1 in env.", file=sys.stderr)
print(" To abort: don't re-run.", file=sys.stderr)
print("═════════════════════════", file=sys.stderr)
return 2
The hook reads no token count, no quota, and no concurrency state. It pattern-matches a command shape and returns 2. That {0,10} bound on the intervening tokens sits there as ReDoS hardening. Because the match runs against the raw command string, the wrapped form gets caught for free: an osascript -e '...do script "archon workflow run ..."' invocation contains the substring, so the hook never has to parse AppleScript. The measurable outcome the article claims is deliberately modest. No concurrent triple-run since it shipped.
Every gate here fails open
Look again at the except Exception: return 0 in that hook. Every blocking gate in this estate fails open on a parsing error, deliberately, without a single exception.
The git-side sibling that requires a roadmap tick writes the rationale down: "the cost of a missed tick is recoverable; the cost of an unbypassable hook isn't."
The bypasses follow one convention, and the namespace is greppable on purpose:
| Token | Gate |
|---|---|
BBBRAIN_PREFLIGHT_BYPASS=1 | the Archon pre-flight above |
BBBRAIN_AUDITED=1 | a commit spanning both code and tests without an independent audit |
BBBRAIN_NAMELEAK_BYPASS=1 | the identifier scan on git pre-commit |
BBBRAIN_ROADMAP_BYPASS=1 | the roadmap tick, whose use is logged to stderr |
One quirk will find you within a day of writing your first Bash gate, and the source comment printed above spells it out. A hook process spawns before the shell evaluates a VAR=1 cmd prefix, so that assignment never reaches the hook's environment. Hence the pre-flight greps the raw command text for its own bypass token as well as reading os.environ. Skip that second path and you leave your users with a bypass that looks natural and cannot work.
A hook can stop being wired, and nothing will tell you
The honest part. Two of the hooks this system is proudest of, the Archon pre-flight above and the code-plus-tests audit gate, exist on disk, carry 51 test functions between them, are documented in the constitution as live global PreToolUse hooks, and are not registered in ~/.claude/settings.json. Today they are files without an event.
grep -c preflight ~/.claude/settings.json
# 0
Nothing detected this. The tests still pass; they exercise the script, not the wiring. Prose does not get compiled, so the constitution goes on calling both hooks active. For the only inspection surface there is, the hooks guide offers this: "Type /hooks to open the hooks browser. You'll see a list of all available hook events, with a count next to each event that has hooks configured." Note the constraint that ships with it, quoted whole. The menu "is read-only. To add, modify, or remove hooks, edit your settings JSON directly or ask Claude to make the change."
Run /hooks after any settings edit, and trust the count beside each event over anything a document claims about it. So the enforcement layer has exactly the property it was built to fix. It is advisory about itself.
Why hooks, not more CLAUDE.md rules?
The call
Non-negotiables live in hooks that exit 2 and block. CLAUDE.md carries context and preference; enforcement is deterministic code.
Rejected
What it costs
Every hook is code to maintain, and a hook can silently unwire (the section above exists because one did). Blocking gates also add friction to legitimate edge cases, which is what bypass tokens are for.
Revisit when
When a rule stops earning its friction: a gate nobody has tripped in months is a candidate for demotion back to advisory text.
The transferable rule
The gotcha entry that started this whole estate is dated 2026-04-18, has picked up one amendment since, and ends on the two lines that matter. It went in after Claude Code spent an entire authentication session using a general-purpose agent instead of the specialists the workflow demands:
# SessionStart hook prints 9-step workflow. Stop hook prints quality checklist.
# Lesson: Agents are voluntary. Hooks are not. Make the non-negotiables hooks.
Take the second line, then complicate it with everything above. A hook is only non-negotiable if it exits 2 or denies via JSON, if its trigger is an artefact sitting on disk, if it deletes its own trigger before it blocks, if it fails open when it breaks, and if it is actually in the settings file you think it is in.
So the rule is narrower than "make the non-negotiables hooks". A hook encodes the physical evidence that a rule was followed. Write the check that a machine can run without the model's cooperation, or accept that you have written a suggestion. The next chapter is what happens when the check itself is wrong.
Build a marker and Stop gate for your own unverified surface
Find the surface in your own workflow where a clean-looking result is not proof: tests edited but never run, or a migration written but never applied. Build the chapter's two-script pattern for it. A PostToolUse marker silently records that the session touched the risky surface, and a Stop gate blocks the turn from ending until a verification artefact exists that is newer than the marker.
Expected behaviour
- A PostToolUse marker script that writes a per-session marker on the risky surface and exits 0 on every path, including the paths its own remediation will touch
- A Stop gate that clears itself when a newer verification artefact exists, using a timestamp comparison against the marker
- The marker is deleted before the block is emitted, so the gate is one-shot by construction
- The reason string carries numbered remediation steps rather than a bare demand for verification
- Both entries visible in the /hooks browser after registration, checked rather than assumed
PROVE IT Trigger the gate and refuse to comply once: the block must fire with your remediation text, and the session must still be able to end afterwards. Then comply, and show the gate clearing itself on the artefact rather than on your say-so.
A Stop hook blocks. What actually happens to the session?
Which lifecycle event runs too late to prevent a tool call, whatever exit code it returns?
Why does the Stop gate delete its own marker before emitting the block, and what happens to a gate that does not?
Show answer
Blocking on Stop does not end the session; it prevents Claude from stopping and the conversation continues. A gate that re-evaluates the same still-true condition on the next Stop blocks again, forever, and the session can never end. Deleting the marker before emitting gives the gate exactly one shot, so even if the model ignores it, the session remains able to finish.
↺ re-read: “A Claude Code Stop hook example that actually blocks”