Hooks as a Control Plane
Four hook events form the entire governance surface of Claude Code — refuse tools, gate commits, block deploys, enforce evidence. Once you see it as a control plane, the whole agent stack changes shape.
View companion repoI have 3,902 hook scripts under ~/.claude/plugins/cache/. That number jumped out at me when I ran find ~/.claude/plugins -name "*.sh" -path "*/hooks/*" | wc -l. Most of them I never wrote. They're the enforcement layer that came along with plugins I installed for unrelated reasons — Crucible, ValidationForge, Anneal, the dozen smaller plugins from the marketplace ecosystem. Each one shipped its own hooks, each hook quietly governing some part of every session that touches that plugin.
That's the moment I stopped thinking about hooks as "scripts that run before tool calls" and started thinking about them as a control plane. Plugins are packaging. Skills are workflows. Hooks are policy.
The Four Events
Claude Code dispatches hooks on four events, and that small surface is the entire governance API:
- 01PreToolUse — fires before a tool runs. Exit code 2 blocks the call. The hook gets the tool name, the parameters, and a chance to refuse.
- 02PostToolUse — fires after a tool completes. Can mutate the tool result before it returns to the model. Used for filtering, redaction, audit logging.
- 03Stop — fires when the agent declares the session complete. Exit code 2 prevents the session from ending. Used for verification gates.
- 04SessionStart — fires once at the top of a session. Sets up environment, preloads context, validates prerequisites.
Four events, registered through a hooks.json file at the plugin root:
The matcher field is regex. It scopes the hook to specific tool names. PreToolUse matched against Bash only fires on shell commands. PreToolUse matched against Edit|Write fires on file mutations. The matcher is what makes hooks composable — you can stack ten plugins, each with its own matchers, without them stepping on each other.
Crucible's Stop hook is the canonical example of governance-via-hook. Its job is to refuse session termination unless evidence/completion-gate/report.json exists and contains overall: "COMPLETE". Exit 2 keeps the session alive. There's no override flag. The agent can't /exit past it. It runs every time, and it refuses every time the evidence isn't there.
The Refusal Pattern
A hook that prints to stderr and exits 2 cancels the operation. The model sees the stderr text in its next turn, framed as a tool error. That feedback loop is what makes hooks useful as policy enforcement: the agent gets told no, gets told why, and adjusts.
Here's the bash-guard hook from claude-code-discipline-hooks. It refuses any rm -rf against a path outside the project directory:
Twenty-two lines, one absolute rule. The agent can't rm -rf /tmp/foo if the working directory isn't /tmp/foo*. It will get the stderr message, attempt something else, and the hook will pass-through the next call if it's safe. The model adjusts; the human's invariant holds.
The compounding power shows up when you stack these. I have hooks that refuse:
- 01git push --force against any branch matching main|master|prod
- 02Any tool call that mutates files inside ~/.claude/skills/ (those are user-authored, agents don't get to write there)
- 03Bash commands that source untrusted files from /tmp or /var/tmp
- 04Edits to .env files that introduce new secrets without going through the secrets-manager skill
- 05Any Write to a file path containing node_modules (drift detection between intent and action)
Each one is a tiny script. None of them know about each other. Together they form a policy mesh that survives whichever agent or skill is running.
PostToolUse and the Filter Pattern
PreToolUse can refuse. PostToolUse can rewrite. The distinction matters because some governance problems aren't about blocking actions — they're about what the agent gets to see.
The redaction hook I run on Read calls is the cleanest example:
Fifteen lines. Every file the agent reads gets passed through this filter. If a config file accidentally contains a real API key, the model never sees it. The audit log keeps the original. The model gets the redacted version. This isn't theoretical — it has caught me checking secrets into a file I didn't realize would be read by a session, three times.
PostToolUse hooks can also add context. The functional-validation skill ships a PostToolUse hook for Bash that detects when you ran a build command and appends a small note to the result: Build success ≠ functional validation. Did you exercise the feature through real UI? That's policy enforcement through nudging, not refusal. The agent reads the note, often acts on it, occasionally pushes back. Both behaviors are fine.
SessionStart: The Environment Contract
SessionStart hooks fire once. They set up the world the session runs in. The most useful pattern I've found is the prerequisite check — refuse to start a session if the environment isn't right.
Twenty-five lines. The session won't even start if the toolchain is wrong or there's leftover state from a prior crashed run. That's preferable to letting the agent boot, attempt work, and fail mid-flight. SessionStart is the place to fail fast.
What Hooks Are Not
Hooks are not skills. Skills are invoked when the user's phrasing matches a description. Hooks are invoked unconditionally on event boundaries. A skill is a routing decision; a hook is a policy.
Hooks are not plugins. A plugin is a packaging unit — it ships hooks, skills, MCP servers, agents, all bundled. The plugin is the container. The hooks are one of the things the container can hold.
Hooks are not tests. Tests check behavior. Hooks enforce constraints. The Crucible Stop hook doesn't test that the work is done — it refuses to let the session end without evidence that something checked. The hook is the enforcement; the evidence is whatever produced the proof.
The Multi-Plugin Mesh
The interesting property emerges when multiple plugins ship hooks that all match the same event. Claude Code runs them in registration order, and any single hook returning exit 2 cancels the operation. That means policies compose by union, not intersection. If Crucible's Stop hook says "no" and ValidationForge's Stop hook says "yes," the session stays open. Both have to agree before the session can end.
I checked my installed plugins. Right now, on a session with Crucible + ValidationForge + discipline-hooks + the agent-browser skill set, my Stop event fires four hooks. PreToolUse on Edit fires three. PostToolUse on Bash fires five. The total cost: about 80ms per tool call. The benefit: every session is governed by overlapping policy without me having to maintain any of it.
That's the part most people miss. Once you treat hooks as a control plane, you stop writing imperative governance into your prompts. You stop reminding the agent to "make sure to check X before doing Y." You write the check once, as a hook, and it runs every time. The prompt gets shorter. The behavior gets more consistent. The session gets cheaper because you're not paying for the model to re-derive the same policy on every run.
Building Your Own
Start with one hook. Pick the failure mode that bites you most often. Mine was git push --force main after a rebase went sideways. Twenty-line bash script, exit 2 if the branch matches main|master|prod, registered as PreToolUse on Bash. Took ten minutes. Has refused three accidental force-pushes since.
Add the second one when you hit the next failure. By the time you have five, you'll start seeing the pattern: every recurring "I should have caught that" is a hook that doesn't exist yet. Write it. Register it. Move on.
The 3,902 hooks in my plugin cache aren't there because I'm paranoid. They're there because every plugin I installed had its own list of "I should have caught that" moments, and shipping them as hooks was how the maintainers made sure I never had to remember.
“A control plane you don't have to think about is the only one that actually works.”