An agent I was babysitting once tried to run git reset --hard origin/main on a branch with two hours of uncommitted work. It had decided the working tree was "messy" and wanted a clean slate. The only reason I still have that work is a PreToolUse hook that blocks any git reset --hard.
That's the whole pitch for Claude Code hooks. They are the difference between "the model usually behaves" and "the model literally cannot do the thing." Prompts are suggestions. Hooks are the law, because the harness runs them, not the model.
Why a hook beats a prompt every time
You can write "NEVER run destructive git commands" in your system prompt in all caps with three exclamation points. On Opus 4.8 that mostly works — it follows instructions closely. But "mostly" is the problem. The model's behavior is a probability distribution. A hook is a conditional. When the safety boundary actually matters — deleting data, pushing to prod, sending an email — you want the conditional, not the distribution.
There's a deeper reason from how these models reason about tools. The model emits a tool call; your harness decides what to do with it. A bash tool hands the harness an opaque command string. A hook is where you crack that string open and make a deterministic call: allow, block, or ask. The model's intent never enters into it.
The three hooks I actually run
PreToolUse — the bouncer. Fires before a tool executes. Gets the tool name and input. Return a block decision and the call never happens. This is where my git reset --hard guard lives, plus blocks on rm -rf outside a scratch dir and any curl piping to a shell.
# PreToolUse: reject destructive git on the bash tool
if echo "$TOOL_INPUT" | grep -qE 'git reset --hard|git clean -[a-z]*f|git push --force'; then
echo '{"decision":"block","reason":"Destructive git blocked. Stash or commit, then ask me."}'
exit 0
fi
The reason matters. It goes back to the model, so instead of flailing the agent reads "stash or commit, then ask me" and does that. A silent block makes the agent retry variations until it finds a phrasing your regex missed. Tell it why.
PostToolUse — the inspector. Fires after a tool runs. I use it to run a quick lint or type-check after every file edit, and feed failures straight back. The agent gets the type error two seconds after writing the bug instead of three subtasks later when nothing builds. Tightening that feedback loop did more for my agents' code quality than any prompt change.
Stop — the off switch. Fires when the agent thinks it's done. This is your last chance to say "no it isn't." I check that tests actually pass before letting a run end. If they don't, the Stop hook blocks the stop and tells the agent to fix them. It's also where the loop-detection counter from earlier lives — too many edits, halt the whole thing.
Where hooks live
Hooks are configured in settings.json, not in the model's memory or your CLAUDE.md. This trips people up constantly. "From now on, always run the linter after editing" is not a thing you tell the model — it'll forget by turn 30. It's a PostToolUse hook the harness runs every single time. Automated, repeated behavior belongs in settings.json. Preferences belong in memory. Don't mix them up.
Quick structure, no ceremony:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "~/.claude/guards/git-guard.sh"}]
}]
}
}
The matcher scopes the hook to a tool. Scope tightly. A PreToolUse hook that fires on every tool, parses every input, and shells out adds latency to the whole run. I learned that the slow way after a guard script with an unnecessary network call added a second to every single bash call in a 300-call session.
The mistake everyone makes
Blocklists. Your first instinct is to blocklist the bad commands. rm -rf, git reset, DROP TABLE. The model is more creative than your blocklist. It'll find find . -delete, or base64-encode the command, or use a tool you forgot about.
Allowlist the safe operations instead, when you can. For a read-only research agent, allow grep, glob, read, web_search and block everything else by default. It's more annoying to set up. It's also the only version that holds when the model gets clever. A blocklist protects you from the failures you imagined. An allowlist protects you from the ones you didn't.
The agents that run unattended in my setup all share one trait: I trust the hooks, not the model. The model is smart and fast and occasionally decides your two hours of work look messy. The hook is dumb and never changes its mind. When something irreversible is on the line, dumb and unchanging is exactly what you want.
Write the bouncer before you let anyone into the club.
