Back in January, I wrote about falling in love with Claude Code. In June, I wrote about six months in, and how the job had quietly turned into full-time code review. The conclusion I kept coming back to in that post was that AI only amplifies what's already there. Good judgment, it multiplies. Missing judgment, it multiplies too, just faster than you can catch it.
What I didn't have back then was a way to turn that judgment into something that runs automatically, every time, without me having to be paying attention. That's what hooks are for.
What a hook actually is
A hook is a shell command that Claude Code runs automatically at a specific point in its lifecycle: before a tool call, after one, when a session starts or stops. You give it an event, an optional matcher (which tools it applies to), and a command. The hook gets JSON on stdin describing what's happening, and it can respond in a way that blocks the action entirely.
The part that matters: it's deterministic. A rule in your CLAUDE.md is a request. Claude usually follows it, but "usually" isn't a guarantee, especially deep into a long session when things drift. A hook isn't a request. It just runs, every time, whether Claude remembers or not.
Once that clicked, I stopped thinking of hooks as an optional extra and started thinking of them as the enforcement layer for everything I'd already learned to worry about.
The guardrails I set up first
Blocking dangerous Bash commands. This was the first one I wrote, and it's the one that made me trust auto-accept mode for the first time.
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "CMD=$(cat | jq -r '.tool_input.command'); if echo \"$CMD\" | grep -qE 'rm -rf /|DROP TABLE|chmod -R 777 /'; then echo \"Blocked: dangerous command\" >&2; exit 2; fi"
}]
}]
}
}
It reads the command Claude is about to run, checks it against a short list of patterns that should never execute unsupervised, and blocks with exit code 2 if it matches. This is the same category of risk I wrote about in the terminal post: Ctrl+C doesn't do what people expect, and neither does a stray trailing slash on rm -rf. The difference is a human catches it by habit. A hook catches it every time, including the one time you weren't watching the terminal.
Blocking secrets from being written to disk. Same shape, different target: scan the content Claude is about to write for anything that looks like an AWS key, an API token, or a private key block, and refuse the write.
{
"hooks": {
"PreToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "cat | jq -r '.tool_input.content // .tool_input.new_string // empty' | grep -qE 'AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9]{32,}|-----BEGIN.*PRIVATE KEY-----' && { echo 'Blocked: possible secret in content' >&2; exit 2; } || exit 0"
}]
}]
}
}
Cheap to write, and it closes off one of the more embarrassing ways a secret ends up committed to a repo.
Sandboxing file access to the project directory. This one I added after thinking about what "trust" actually means in an agentic tool. I don't fully trust every instruction a long session accumulates, so I don't want Claude touching anything outside the project root, full stop.
{
"hooks": {
"PreToolUse": [{
"matcher": "Read|Edit|Write",
"hooks": [{
"type": "command",
"command": "FP=$(cat | jq -r '.tool_input.file_path // empty'); REAL=$(realpath -m \"$FP\" 2>/dev/null); if [[ -n \"$REAL\" && \"$REAL\" != \"$CLAUDE_PROJECT_DIR\"* ]]; then echo \"Blocked: $FP is outside the project directory\" >&2; exit 2; fi"
}]
}]
}
}
It resolves the target path and checks it against $CLAUDE_PROJECT_DIR. Anything that escapes the project root, whether through ../../ traversal or an absolute path elsewhere on disk, gets blocked. This holds regardless of permission mode, which is exactly the point.
The automation that just runs now
Not everything is a guardrail. Some hooks exist purely to remove a step I used to do by hand.
Auto-formatting after every edit.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}
Auto-staging every file Claude touches, so my working tree is always ready for a clean git diff --cached:
{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs git add"
}]
}]
}
}
Neither of these is exciting. That's the point. I don't want to think about formatting or staging. I want to think about whether the code is actually correct, which, per my last post, is now most of the job.
Enforcing team conventions, not just personal ones
The guardrail category gets more interesting once you're not the only one using a shared setup. Two I've found genuinely useful for keeping a codebase consistent when multiple people (and multiple agents) are touching it:
Gating commits behind a passing lint:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "CMD=$(cat | jq -r '.tool_input.command'); if echo \"$CMD\" | grep -qE '^git commit'; then npx eslint . --quiet || { echo 'Blocked: lint errors present, fix before committing' >&2; exit 2; }; fi"
}]
}]
}
}
Blocking force-pushes and direct pushes to main:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "CMD=$(cat | jq -r '.tool_input.command'); BRANCH=$(git branch --show-current 2>/dev/null); if echo \"$CMD\" | grep -qE 'git push .*(-f|--force)' || { echo \"$CMD\" | grep -qE 'git push' && [[ \"$BRANCH\" == \"main\" || \"$BRANCH\" == \"master\" ]]; }; then echo \"Blocked: force-push or direct push to $BRANCH\" >&2; exit 2; fi"
}]
}]
}
}
Both of these are rules I already believed in before I ever touched Claude Code. The only thing that changed is that they used to live in my head, or in a PR template nobody fully reads, and now they live in a script that can't be skipped by accident.
What I didn't expect
I went into hooks expecting a way to stop Claude from doing something destructive. What I got was something closer to a written record of every piece of judgment I'd normally apply silently while reviewing a PR: don't touch this file, don't push here, don't commit without linting, don't let a secret slip through. Writing the hook forces you to actually name the rule instead of just knowing it.
That's the part that connects back to the six-months post. The bottleneck moved from "can you write this" to "can you tell whether this is right," and hooks are the closest thing I've found to encoding "whether this is right" into something that doesn't depend on me catching it in review.
In a nutshell
- Hooks fire on lifecycle events (
PreToolUse,PostToolUse,Stop, and others) and can block an action outright with exit code 2 -
PreToolUsehooks run before the permission check, in every mode, including auto-accept - Use them for anything that must hold every time: no exceptions, no "it usually remembers"
- CLAUDE.md and skills are requests. Hooks are enforcement.
Conclusion
Skills, plugins, and CLAUDE.md all shape what Claude knows and how it approaches a task. Hooks are the only one of the four that don't ask Claude to cooperate. They just run. After six months of learning that my job is mostly review now, that distinction is exactly what I was missing.
Top comments (0)