Skip to content

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.