Appearance
The hook event is a choice of causal window — fire before damage for gates, after work for completion contracts, and alongside for audit, never "whichever runs soonest."
Pattern
Hook Event Selection
The shape
Three windows around every tool call.
Match the event to when it needs to fire, not to whichever one runs first.
Before
PreToolUse
UserPromptSubmit
Hard gates. Prevent damage before it happens.
→
Tool runs
(disk mutates)
The act itself — the window you're bracketing.
→
After
PostToolUse
Stop / SubagentStop
PreCompact
Audit, or verify the contract was met.
[ -f "$SESSION_DIR/active-task.json" ] || exit 0— the active-task anchor keeps enforcement dormant outside managed tasks.
Security gates fire before damage; completion contracts fire after work. "Fires earliest" is not a selection criterion.
The decision matrix
Seven events. Two families. One row per goal.
PreToolUse
Hard gates, precondition checkse.g. block rm -rf, deny .env access
exit 2 blocks
PostToolUse
Ledger append, mutation auditlog every Write/Edit to the session ledger
exit 0 only
Stop
Completion contract verificationcheck filesExpected against disk before the agent exits
exit 2 blocks
SubagentStop
Sub-agent output validationreject and re-run the sub if output is malformed
exit 2 re-runs
UserPromptSubmit
Vocabulary injection, deny-listrefuse the prompt or inline context before the turn starts
exit 2 rejects
PreCompact
Flush logs, persist ephemeral statesave transient memory before the window is summarized
exit 0 only
Notification
Desktop alert, Slack pingside-effect out-of-process; can't block
exit 0 only
Blocking events — PreToolUse, Stop, SubagentStop, UserPromptSubmit. Audit-only — PostToolUse, PreCompact, Notification. Pick the row that matches the goal; the event follows.
The two ways it goes wrong
Overreach fires too often. Underreach fires too late.
Overreach — PreToolUse for everythingfires N× too often
Goal: block writes to .env. Matcher left empty. Hook attached to every tool call.
- Read("README.md")
- Grep("TODO", src/)
- Bash("ls docs/")
- Read("package.json")
- Edit(".env") — correctly blocked
- Glob("**/*.ts")
Slow. Noisy. Wasteful. A gate with an empty matcher inspects every unrelated tool call — the agent loop grinds while the hook evaluates `.env` rules against operations that have nothing to do with `.env`.
Underreach — PostToolUse for a security gatedamage already done
Goal: prevent rm -rf /. Hook attached to PostToolUse. Fires after Bash completes.
- Bash("rm -rf /") — runs
- Filesystem destroyed
- PostToolUse hook fires
- exit 2 — "blocked"
- ...too late.
Exit 2 after the fact is a log entry, not a gate. Audit fires after; enforcement fires before. If the window is wrong, the exit code doesn't matter.
The contract the hook speaks
Exit code is the verdict. stdout is the repair.
exit 0allow
The operation proceeds. stdout is ignored.
use for
audit appends · notifications · state flushes · any observe-only hook
exit 2block + message
The operation is rejected. stdout becomes the agent's error message.
use for
precondition failures · completion contract violations · vocabulary denials
Repair instructions — make stdout actionable
weak
"validation failed"
strong
"Required symbol `validate_session_token` not found in `src/auth/middleware.py`"
The stdout of a blocking hook is the agent's next instruction. Name the missing artifact, the expected path, the precise gap — not a vague verdict.
In the wild
One event per intent. Three intents on record.
Security gate
PreToolUse
Block rm -rf and .env access before Bash runs.
tac-7 · pre_tool_use.py
Completion contract
Stop
Check every filesExpected entry against disk + ledger before the agent exits.
product-spec-stop-verify
Audit
PostToolUse
Log every tool call to logs/{session}/post_tool_use.json. Never blocks.
primer · post_tool_use.py
Each hook picks the event that matches its causal window — the security gate has to be before, the contract has to be after, the audit is independent. Swap any one and the hook stops working.
The discipline
Name the enforcement goal precisely. Then pick the row.
Gate? PreToolUse with a tight matcher. Contract? Stop. Audit? PostToolUse. "Runs soonest" is not a criterion — the window has to match the intent, or the hook either fires on nothing relevant or fires when the damage is already done.