On this page

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.

  1. 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.

  2. 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.

  3. 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.

  4. Put the remediation in the reason string

    The reason goes 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:

TokenGate
BBBRAIN_PREFLIGHT_BYPASS=1the Archon pre-flight above
BBBRAIN_AUDITED=1a commit spanning both code and tests without an independent audit
BBBRAIN_NAMELEAK_BYPASS=1the identifier scan on git pre-commit
BBBRAIN_ROADMAP_BYPASS=1the 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.

// DECISION

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
Stronger wording in CLAUDE.mdadvisory text competes with everything else in context. This system watched an entire auth session ignore its own specialist-agent rules.
Trusting the model to self-policethe same instrument cannot be both the actor and the gate
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.

// EXERCISE

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.

// CHECKPOINT — HOOKS THAT BLOCK
multiple choice · auto-checked

A Stop hook blocks. What actually happens to the session?

exact answer · auto-checked

Which lifecycle event runs too late to prevent a tool call, whatever exit code it returns?

open · self-checked

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

Lived experience

Sources

  • Hooks reference — Claude Code
    Anthropic (Claude Code official documentation)
    The exit-code contract, the full lifecycle event list, the stdin JSON schema and the JSON output schema, which together define what a blocking hook actually is
    code.claude.com
  • Automate actions with hooks — Claude Code
    Anthropic (Claude Code official documentation)
    Anthropic's own one-sentence definition of hook determinism, plus the /hooks browser and its read-only constraint, the debugging surface this chapter depends on
    code.claude.com
  • How Claude remembers your project — Claude Code
    Anthropic (Claude Code official documentation)
    The docs' explicit statement that CLAUDE.md is context rather than enforced configuration, and their instruction to escalate must-run rules into a hook
    code.claude.com
  • Settings — Claude Code
    Anthropic (Claude Code official documentation)
    Where the hooks block lives, the four settings scopes and their precedence, and the settings-versus-CLAUDE.md enforcement distinction
    code.claude.com
  • Model Context Protocol — Specification (revision 2025-11-25)
    Model Context Protocol
    The contrast case: a protocol whose own spec states it cannot enforce its security principles at the protocol level, which is precisely the job a hook takes on
    modelcontextprotocol.io
Back to guide overview