SKILL 27 / ORCHESTRATION · DEEP-DIVE

Hooks and Deterministic Automation

33 events × 5 hook types × 3 exit codes · Read one official plugin's hook registry · Don't pin down a judgment call

2026-09-28 · BigCat

Last issue ended owing a debt: "don't send an agent to do a deterministic job." The official definition of hooks has exactly one load-bearing clause — deterministic control: certain actions always happen rather than relying on the LLM to choose to run them. Models forget, skip, and decide "not this time." Hooks don't. We start from a Prettier config you can paste today, take apart the hook registry of the official security-guidance plugin, then name six community tools.

Prerequisite → Skill 26 Subagents vs Skills (isolation vs injection)

// WHY THIS MATTERS

There is one question behind "should this be a hook": does it require judgment? If yes, give it to the model (prompt, skill, subagent). If no — same every time, and one miss hurts — pin it to a hook. "I wrote in CLAUDE.md that formatting must run after every edit" is not automation, it is prayer: it lands in the model's context, not in the execution path. Same order as always: configs you can copy first, mechanism second, named installables third, criteria in the back half.

// 01

What it takes off your plate: removing "every time" from the model's free will

Claim: a hook does not buy capability, it buys inevitability. The same rule in CLAUDE.md means "probably done"; in a hook it means "always happened." The difference lives in the 3% that got skipped.

First thing that runs: auto-format after every edit

The official guide gives this verbatim. Drop it into .claude/settings.json at the project root and Prettier runs every time Claude finishes an Edit or Write:

// .claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
        ]
      }
    ]
  }
}

Event data arrives as JSON on stdin, which is why every official Bash example reaches for jq (brew install jq / apt-get install jq). When not to write this one: if the file can also be rewritten by a Bash command, the Edit|Write matcher misses it — the docs point you at a FileChanged hook watching the filename instead.

Second: block what must not be touched

Save this as .claude/hooks/protect-files.sh (remember chmod +x) and register it on PreToolUse as "$CLAUDE_PROJECT_DIR"/.claude/hooks/protect-files.sh:

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# On a match, exit 2: the action is blocked, stderr goes back as feedback
case "$FILE_PATH" in
  *.env|*/secrets/*) echo "Protected path, writes are denied" >&2; exit 2 ;;
esac
exit 0

Third: skip the writing, install the official one

/plugin install security-guidance@claude-plugins-official
# If the marketplace is not found, add it first:
/plugin marketplace add anthropics/claude-plugins-official

security-guidance is the one official plugin built entirely on hooks; once installed there is nothing to invoke. What it takes off your plate: injection, unsafe deserialization, dangerouslySetInnerHTML and friends move from "caught at PR review" to "caught as the line is written." When not to install it: it adds a model call on every turn that changed files and a deeper agentic review on every commit (Claude Opus 4.7 by default) — that is real money; outside a git repository the last two layers silently skip; cloud sessions do not load plugins at all. If the cost bites, keep only the free layer: ENABLE_CODE_SECURITY_REVIEW=0 turns off every model-backed review and leaves the pure string-match per-edit check.

// 02

Mechanism: 33 events × 5 types × 3 exit codes

Claim: the entire expressive power of hooks lives in three tables. Learn them and you need no tutorial.

Events: there is more than Pre/Post ToolUse

The event table in the official reference now lists 33 entries. The "13 lifecycle events" you see in community write-ups is stale. A few that get overlooked and shouldn't:

Event What it is for (selected) ──────────────────────────────────────────────────────────── PreToolUse Before a tool runs; can block (exit 2) PostToolUse After success; cannot undo (it already happened) PostToolUseFailure After failure — the hook point for retry/fallback UserPromptSubmit Before your prompt reaches the model; edit or block SessionStart matcher: startup|resume|clear|compact|fork PreCompact Back up the transcript before compaction FileChanged matcher is literal filenames (.envrc|.env), any writer CwdChanged Reload env vars on directory change (CLAUDE_ENV_FILE) ConfigChange Config was edited; audit it or block it Stop / SubagentStop End of a turn / subagent; can say "not yet" SessionEnd All SessionEnd hooks share a 1.5-second budget

Types: five of them, and the last two are anti-deterministic

Exit codes: only three cases

0 = success; stdout is parsed as JSON (and treated as plain text if it isn't, which some events inject into Claude's context). 2 = block, with stderr as the reason — but only on blockable events (PreToolUse, UserPromptSubmit, Stop, SubagentStop, PreModelSwitch and a few more); SessionStart and friends cannot be blocked, so exit 2 there merely shows stderr to you. Anything else = non-blocking error: the action proceeds and a hook error notice appears in the transcript.

Where the config lives decides who is governed

Location Scope Shareable ────────────────────────────────────────────────────────────────────── ~/.claude/settings.json All your projects No .claude/settings.json One project Yes, commit it .claude/settings.local.json One project No (gitignored) managed policy settings Whole organization Admin-controlled a plugin's hooks/hooks.json While plugin enabled Ships with plugin skill frontmatter Rest of session In the skill file subagent frontmatter While that agent runs In the agent file

/hooks browses every configured hook grouped by event — but it is read-only; adding and removing means editing the JSON. The global off switch is "disableAllHooks": true, and administrators have allowManagedHooksOnly, which disables your own hooks and runs only the managed ones.

Copy a real registry

security-guidance splits itself into five registrations. This table is worth memorizing as a template (the source is on GitHub: .claude-plugin/ + hooks/ + tests/):

SessionStart → bootstrap the Python environment UserPromptSubmit → capture the working-tree baseline PostToolUse on Edit/Write/NotebookEdit → pattern match, zero model calls Stop → background review of the turn's diff PostToolUse on Bash (git commit/push) → deeper agentic review

Three things to steal outright: put the expensive layer last (free string matching per edit, paid model review on Stop and commit); baseline first, verdict later (UserPromptSubmit snapshots, Stop diffs); and the reviewer is not the instance that wrote the code (a separate call with a fresh context).

$CLAUDE_PROJECT_DIR does not follow you into a worktree. It always points at the project root where the session started, so "$CLAUDE_PROJECT_DIR"/.claude/hooks/x.sh keeps running the script in the main checkout — which is correct. To learn where Claude is actually working, read the cwd field in the hook's input JSON; that one does follow worktrees and cd.
// 03

Real usage and limits: six things you can install or read

Claim: the annoying part of writing hooks was never the logic, it is re-writing stdin parsing, exit-code semantics and event dispatch in every script. Each of these kills one slab of that boilerplate.

① GowayLee/cchooks · Python SDK (132★)

pip install cchooks   # or: uv add cchooks
from cchooks import create_context, PreToolUseContext
c = create_context()
assert isinstance(c, PreToolUseContext)
if c.tool_name == "Write" and ".env" in c.tool_input.get("file_path", ""):
    c.output.exit_deny("Nope! .env files are protected")
else:
    c.output.exit_success()

Kills: hand-rolled json.load(sys.stdin) and memorized exit-code semantics; each event becomes a typed context object. Covers 9 events. When not to install: if your hook is a one-liner jq | xargs, a dependency is not worth it — and 9 events is far short of the current 33, so anything exotic goes back to raw parsing. (Per the GitHub repo page, 2026-09.)

② syou6162/cchook · YAML instead of JSON (34★)

go install github.com/syou6162/cchook@latest
PostToolUse:
  - matcher: "Write|Edit"
    conditions:
      - type: file_extension
        value: ".go"
    actions:
      - type: command
        command: "gofmt -w {.tool_input.file_path}"

Kills: the escaping hell inside settings.json; conditions (by extension, by pattern) become declarative, and it ships a dry-run mode. When not to install: the moment a team shares config, a non-official format is one more thing newcomers cannot read — and the star count says it is still niche. (Per the GitHub repo page, 2026-09.)

③ johnlindquist/claude-hooks · TypeScript (393★)

npx claude-hooks   # generates .claude/hooks/index.ts; execution needs Bun

Kills: guessing at payload shapes — you get typed payloads with autocomplete, all events dispatched from one index.ts. When not to install: it binds your runtime to Bun, so on CI or a teammate's machine without Bun the hook fails silently — and a failing hook is non-blocking by default, so without debug logging you never find out. (Per the GitHub repo page, 2026-09.)

④ disler/claude-code-hooks-mastery · the teaching repo (3.9k★)

This repo implements every event of its day as an Astral uv single-file Python script, including transcript backups before PreCompact and intercepting subagent completion. Kills: the question "what does this event's JSON actually look like" — reading it beats grepping the docs. When not to install: it is a reference, not a product; the README claims 13 events while the official reference is now at 33, and copying the whole .claude directory drags in its output styles and status line too. (Per the GitHub repo page, 2026-09.)

⑤ disler/claude-code-hooks-multi-agent-observability · a dashboard (1.5k★)

It POSTs every event over HTTP to a Bun server that broadcasts over WebSocket to a Vue 3 client, so several concurrent agents become visible in real time. Kills: the blindness of "five agents are running and one is stuck." When not to install: it needs uv, Bun and a long-running server — overkill for one person in one session, and the official http hook type now POSTs to an endpoint natively, so wiring your own log service may be lighter than adopting this stack. (Per the GitHub repo page, 2026-09.)

⑥ pre-commit/pre-commit · the half that isn't inside Claude (15.6k★)

pre-commit (pip install pre-commit → pre-commit install, configured in .pre-commit-config.yaml) governs the moment of committing. It is not a substitute for Claude's hooks, it is the next leg of the relay: Claude hooks only cover what Claude does, and commits you type in your own shell are invisible to them (the docs say even a ! shell escape does not count). When not to install: solo work where every change goes through Claude — then you are running the same linters twice.

Three criteria for choosing among the above

  1. Does it need judgment? No → a command hook. Yes, but the event data is enough → prompt. Only by reading the code → agent (knowing it is experimental).
  2. What does one miss cost? Merely ugly (unformatted) → fix it in PostToolUse. An incident (a committed secret) → block it in PreToolUse with exit 2, because PostToolUse cannot undo anything.
  3. Governing Claude or governing everyone? Claude only → Claude hooks. Every commit → pre-commit or CI. Both → write both, but have the Claude side call the same script; never maintain two copies of one rule.
// 04

Costs and alternatives: hooks throw nondeterminism out the front door, then let it back in

Claim: the biggest risk with hooks is not writing one wrong, it is believing one is running. They are unsandboxed, non-blocking by default, and announce their failures only in the debug log.

Five line items

The most ironic cost: the prompt and agent hook types invite back exactly the nondeterminism you just evicted. They are useful — judgment calls should be judged by a model — but be clear-eyed: a Stop hook where Haiku returns ok: true/false is not a deterministic guarantee, it is one more model question asked somewhere else. If you need "this always happens," only command delivers it.

What to do instead of a hook

  1. The permission system. When the rule is "must never touch this" rather than "log it if touched," allow/deny rules are the hard boundary.
  2. pre-commit or CI. When the thing to govern is everyone's commits rather than Claude's edits, the hook is at the wrong layer.
  3. CLAUDE.md or a skill. When the rule needs understanding and give ("prefer tests for new code"), pinning it down turns it into noise — an alert that always fires is no alert.
  4. Nothing. If it happens once a month, writing the hook costs more than a decade of doing it by hand.

// ONE-PAGE SHOPPING CARD

Install these three first: ① /plugin install security-guidance@claude-plugins-official — the one official plugin built entirely on hooks, zero invocation needed; if the cost bites, ENABLE_CODE_SECURITY_REVIEW=0 keeps the free layer. ② the Prettier PostToolUse config above (plus jq) — five lines of JSON that can go into .claude/settings.json today. ③ pre-commit (.pre-commit-config.yaml) — covers the half Claude's hooks cannot see: the commits you type yourself.

Read next, as needed: Hooks reference (all 33 events, five type schemas, exit-code semantics) · security-guidance source (how to launch a separate model call from a hook and feed the result back) · cchooks (Python, kills stdin boilerplate) · cchook (YAML config plus dry-run) · claude-hooks (typed TypeScript, needs Bun) · claude-code-hooks-mastery (what each event's JSON looks like, used as a dictionary) · multi-agent observability dashboard (live flow across concurrent agents).

Explicitly don't: use an if condition as a security boundary (that is the permission system's job) · expect Edit|Write to catch files written by Bash · use PostToolUse to stop what belonged in PreToolUse · install a plugin without checking which hooks it registers · do more than 1.5 seconds of work in SessionEnd · treat prompt/agent hooks as a determinism guarantee.

// GLOSSARY

hook
A user-defined action that runs automatically at a point in Claude Code's lifecycle. The keyword in the official definition is deterministic control — it does not depend on the model choosing to act.
matcher
Decides which instances of an event fire the hook. On tool events it is a tool name or regex (Edit|Write, mcp__.*__write.*); on SessionStart it is startup|resume|clear|compact|fork; on FileChanged it is literal filenames, not a regex.
exit 2
Exiting with code 2 blocks the action, with stderr as the reason. Only meaningful on blockable events (PreToolUse, UserPromptSubmit, Stop and others).
additionalContext
A field in a hook's JSON output that pushes text into Claude's view as a system reminder. One of the main channels by which a hook influences the model.
$CLAUDE_PROJECT_DIR vs cwd
The former is the project root where the session started and never moves, even inside a worktree — use it to locate scripts. The latter arrives in the input JSON, follows worktrees and cd — use it to know where work is happening.
prompt hook / agent hook
Hook types that ask a model (Haiku by default) or a subagent for a verdict, returning {"ok": bool, "reason": str}. The agent type is marked experimental.
disableAllHooks
The master off switch in settings. Its administrator-side counterpart is allowManagedHooksOnly: run only managed hooks and disable the user's own.

// THREE THINGS THAT DON'T ADD UP

If hooks exist so we stop depending on model judgment, why did the docs add prompt and agent types that depend on it?
Because the scarce determinism was never in the judgment, it was in the trigger. "Check whether the tasks are really finished every time Claude stops talking" — the check needs judgment, but checking every time does not. The hook guarantees the second part: fixed moment, fixed position, never skipped because the model felt it was unnecessary. Whether that moment runs gofmt or asks Haiku is a separate question. So these two types are not a betrayal of the design; they split "always happens" from "always correct" — it is the users who tend to read them as one thing.
security-guidance runs its review from a Stop hook. Can't that keep interrupting the model forever?
There are governors: the end-of-turn review covers at most 30 changed files and re-prompts at most three times in a row before yielding back to you; commit and push reviews are capped at 20 per rolling hour. This is the generic hazard of any "not yet" mechanism — Claude Code itself ends the turn with a warning when a Stop hook blocks too many times consecutively. Any hook that can tell the model to continue must carry its own iteration cap, or what you built is not automation but a perpetual motion machine assigning itself work.
The docs say hooks are unsandboxed and should be treated like dotfiles, yet plugins, skills and subagents can all ship them. Isn't that contradictory?
The contradiction is real, and it is the prelude to what Skill 30 is about. Read the division of labor this way: the distribution mechanism is responsible for making hooks visible (/hooks lists every configured hook with its source file), and the reviewing is left to you. "Security is a shared responsibility" is not boilerplate disclaiming here — in this design the only party who can judge whether a given hook should run is the one who knows what is in their own project. Which makes running /hooks before installing a plugin, and again afterwards to see what appeared, the one gate in this system that genuinely belongs to you.

// FURTHER READING