Back to blog

[ ARTICLE · 2026 · CLAUDE CODE ]

AI Intern→ AI Senior

The 15 Claude Code hooks that turn an unreliable agent into a senior with a checklist. Three lifecycle moments, one policy layer, and one honest argument about why prompts alone will never be enough.

▶ Watch the video version or keep reading Source on GitHub

Last week I watched Claude Code try to delete my entire src/ directory. A hook stopped it in the quarter-second before it would have. That is the whole reason this article exists.

Here is the thing most people get wrong about Claude Code. They treat it like an unreliable intern. They’re half right. The model is fast, eager, and occasionally confident about things it shouldn’t be, exactly like a junior engineer on their first week. But the conclusion (“therefore it can’t be trusted with a real codebase”) doesn’t hold once you do the one thing almost nobody does.

You give the intern a senior’s checklist.

That checklist is a set of small scripts called hooks. Claude Code fires them automatically at three moments in its lifecycle, and the hooks get to inspect, block, observe, or nudge whatever is about to happen. Most developers who use hooks at all use exactly one: Prettier-on-save. That is a formatter. A formatter is the boring one percent of what hooks can actually do. The other ninety-nine percent is policy.

This article is a tour of the fifteen hooks running on my machine right now. Three of them I’ll explain in depth with the actual code. The other twelve you can scan in the grid at the bottom. Everything is copy-pasteable; link to the repo is in the top strip and at the end.

[ 01 · THE MENTAL MODEL ]

Three moments. Three kinds of guardrail.

Claude Code runs in a loop. It picks a tool (read a file, run a shell command, edit code), runs the tool, and eventually it decides it’s done and wants to stop. Hooks fire at three of those moments. Hover or tap each tile to see what lives there.

Tap a tile to jump to its hooks in the fleet below.

If you’ve worked in a regulated industry before, this maps cleanly to the preventive · detective · corrective controls framework. Five bouncers at the door. Seven inspectors on the line. Three checks before you clock out. That’s the whole system.

Hooks aren’t formatters. They are the policy layer that makes a non-deterministic agent safe to run unattended.

[ 02 · UNDER THE HOOD ]

How a hook actually works.

Before the examples, the mechanics, because everything else makes sense once you’ve seen this once.

A hook is a small script. Shell, Python, anything that can read stdin and write stdout. It lives in .claude/hooks/ and you register it in .claude/settings.json against the lifecycle event you want to catch. When that event fires, Claude Code spawns your script and hands it the full context as JSON on stdin. Your script reads the JSON, decides what should happen, and talks back. Claude Code reads the response and acts on it.

[ Step 1 ]

Claude Code fires an event

Before a tool runs, after a tool runs, or when Claude wants to stop. Payload goes out as JSON on stdin.

[ Step 2 ]

Your script runs

Reads stdin, inspects the context, decides. No LLM required; most hooks are regex + exit code.

[ Step 3 ]

Claude Code obeys

Reads the exit code or the JSON you wrote to stdout, and proceeds, blocks, or reads your note.

Registering a hook is three levels of nesting in settings.json: the event, a matcher that narrows which tool or context fires it, and the handler (the actual command Claude Code runs).

// .claude/settings.json "hooks": { "PreToolUse": [{ // 1. event "matcher": "Bash", // 2. matcher — which tool "hooks": [{ "type": "command", // 3. handler — what runs "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guards/bash-guard.sh" }] }] }

And the script talks back in one of four ways. Pick whichever fits the situation:

exit 0

All good. Claude Code proceeds with whatever it was about to do. This is the silent pass; most of your hook runs end here.

exit 2 + stderr

Block. The text you wrote to stderr becomes the reason Claude sees. Quick and dirty; bash-guard.sh uses this for every rule.

permissionDecision: "deny"

Structured block. Write a JSON object to stdout with a permissionDecisionReason and Claude reads a properly formatted refusal. Used when the reason is long or formatted.

additionalContext

Non-blocking nudge. Write a JSON object with hookSpecificOutput.additionalContext and that string is injected into Claude’s next turn like a sticky note. This is how read-counter.py says “stop exploring.”

That’s the whole protocol. An event fires, the hook receives JSON, the hook returns a decision, Claude Code obeys it. Every one of the fifteen hooks below is a variation on that single loop: different events, different matchers, different response shapes, but the same plumbing underneath.

The hook events table is much bigger than three (there’s SessionStart, UserPromptSubmit, FileChanged, PreCompact, and about twenty others), but in practice, PreToolUse, PostToolUse, and Stop are where you’ll spend ninety percent of your time. That’s what the rest of this article is about.

[ 03 · DEEP DIVE ]

The bouncer: bash-guard.sh

HOOK 01 / 15 .claude/hooks/guards/bash-guard.sh Blocks dangerous shell commands before they run.

This hook fires before Claude runs any shell command. It reads the command off stdin as JSON, pattern-matches against a handful of dangerous shapes, and, if any of them match, prints a reason to stderr and exits with code 2. Exit code 2 is the magic number: it tells Claude Code the tool call is denied. Claude sees the reason and either asks you for permission or reconsiders its approach.

7# Rule: dangerous-rm. Block rm -rf on critical directories 8if echo "$COMMAND" | grep -qE 'rm[[:space:]]+(-rf|-fr)[[:space:]]' && \ 9 echo "$COMMAND" | grep -qE '(node_modules|src|backend|prisma)'; then 10 echo "BLOCKED: rm -rf targets a critical directory." >&2 11 exit 2 12fi

A dozen rule families ship in the default file, in three loose tiers: destructive ops, secret-egress, and project-specific opinions. The destructive-ops tier, sampled above, catches rm -rf on anything that looks like source, alongside system-dir deletes, env-var-expanded paths, bulk deletion, download-then-execute, and ANSI-C obfuscation. The secret-egress tier catches a credential file leaving the project root in any of four shapes: pipe, redirect, network tool, copy tool. The project-specific tier catches destructive Prisma commands (prisma migrate reset, prisma db push, and any prisma migrate dev that forgot the --create-only flag), and enforces silent test variants, because Claude has a habit of running the full verbose test suite and burning thirty thousand tokens on colored output nobody reads, so it’s physically prevented from doing that.

Try it yourself. Pick a command on the left and see what the hook does.

[ Live simulator ] Click a command. See whether the hook blocks it.

Notice what the hook doesn’t do: it doesn’t think. There is no LLM in the loop. A couple hundred lines of bash and a few dozen grep -qE calls catch every variant that has ever burned me. The policy is deterministic, fast, and auditable: three words Claude Code on its own can’t promise.

[ 04 · DEEP DIVE ]

The nudge: read-counter.py

HOOK 02 / 15 .claude/hooks/paralysis/read-counter.py Stops Claude when it’s exploring instead of working.

This is the hook that made me realize what the whole system actually is. It doesn’t block anything dangerous. Nothing on disk is at risk. Claude is just over-researching: reading the same codebase in circles instead of writing a single line.

Agents have an exploration failure mode that looks exactly like diligence. They’ll read twenty files before touching one. It feels productive. It isn’t.

19# --- Configuration --- 20STREAK_THRESHOLD = 5 21STALE_TIMEOUT_S = 30 * 60 # streak resets after 30 min 22READ_TOOLS = {"Read", "Grep", "Glob"} 23WRITE_TOOLS = {"Write", "Edit", "Bash", "MultiEdit", "NotebookEdit"} 24 28WARNING_MESSAGE = """PARALYSIS GUARD: You've made {streak}+ read-only calls 29without writing anything. 30STOP exploring. You likely have enough context. Either: 311. Write code now (tests or implementation) 322. State what's blocking you and ask the user for clarification..."""

When Claude makes five Read / Grep / Glob calls in a row without a single Write, Edit, or Bash in between, the hook injects the WARNING_MESSAGE into Claude’s context on the next turn. It doesn’t block the read. It just leaves a sticky note. The counter resets the moment Claude writes, edits, or runs a command.

Press “Read” five times below and watch what happens.

[ Paralysis counter ] Read 5x without a write → hook fires.
0
Waiting for Claude’s next move…

Every senior developer has a voice in their head that says “you’ve been researching for an hour, just start writing.” That voice is a hook. It fires at a threshold. It doesn’t block you. It just reminds you.

Every rule you enforce in your head is a hook you haven’t written yet.

[ 05 · DEEP DIVE ]

The checklist: pre-commit-validation.py

HOOK 03 / 15 .claude/hooks/validation/pre-commit-validation.py Five hundred lines. Four checks. One trigger.

The biggest hook in the fleet, and the most production-real. It only fires when Claude tries to run a shell command containing git commit. When it does, it runs four checks in sequence against the staged files. Critical errors block with a structured permissionDecision: deny response that Claude actually reads and fixes. Warnings are shown but allowed through.

Click each tab to see what it catches.

Python syntax via py_compile

Every staged .py file is compiled in a subprocess before the commit is allowed to run. Broken syntax (the kind of thing Claude occasionally produces when it mid-edits a function and loses its place) gets caught here, before git ever fires.

Blocks on failure

Merge conflict markers

Greps staged files for <<<<<<< / ======= / >>>>>>>. Agents resolving conflicts occasionally commit the markers themselves. This catches it before the PR.

Blocks on failure

Debug code leftovers

Regex-matches breakpoint(), pdb.set_trace(), console.log, debugger, and // TODO: remove. Warns instead of blocks; sometimes you want to commit a log line on purpose, and the hook doesn’t own that decision.

Warns but allows

Unusually large files

Anything over 500 KB in staged files gets flagged. Almost always a binary, a bundle, or a screenshot that shouldn’t be committed.

Warns but allows

Five hundred lines of Python sounds like a lot until you read it. There’s no framework, no abstraction layer. Four subprocess.run calls and some regex. What it encodes is a decade of “I shipped that by accident” mistakes, turned into a gate that Claude cannot forget to pass.

[ 06 · THE FLEET ]

All 15 hooks on one screen.

Three deep-dived. Twelve more. Each card names the hook, what it does in one sentence, and how it behaves when it fires. The colored dot is the hook’s signal level: red blocks, amber warns, green quiet-success, blue checks, gray observational, purple cross-AI.

Before a tool runs Bouncers 5 hooks
bash-guard.sh
Blocks destructive ops (rm/git on system or project paths), secret-egress (credential file leaving the project root), shell obfuscation (ANSI-C escapes, IFS expansions), and project-specific opinions (Prisma destructive, test:silent enforcement).
Blocks with exit 2
file-guard.sh
Blocks edits to schema.prisma, forces hexagonal imports, enforces I-prefix on interfaces, catches console.log.
Blocks with exit 2
pre-commit-validation.py
Before git commit: syntax, merge markers, debug code, large files. Critical errors block, warnings pass.
Blocks on critical errors
command-logger.py
Appends every Bash command to a JSONL log with timestamp + cwd. Purely observational; never blocks.
Non-blocking logging
sensitive-file-guard.py
Blocks edits to .env*, credentials, lock files, and anything matching project-configured secret patterns. Case-insensitive, walks nested directories (catches submodule .git/), and accepts a $HOOK_SENSITIVE_FILES env-var override.
Blocks with exit 2
After a tool ran Inspectors 7 hooks
lint-on-write.py
After any TS/TSX edit in backend/ or mobile-app/, runs npx prettier --write. Non-blocking.
Non-blocking format
ts-typecheck-on-write.py
Runs tsc --noEmit on backend TS. Injects type errors as context. MD5 cache skips unchanged files.
Injects errors as context
read-counter.py
5+ consecutive Read / Grep / Glob without a write → injects “Stop exploring. Write code.”
Injects context after 5 reads
analytics-reminder.sh
On mobile-app screen edits, returns a block reminder asking Claude to check analytics event coverage.
Prompts for analytics
review-plan-gemini.sh
Fires on ExitPlanMode. Sends the plan to Gemini CLI for a second opinion, writes the review into the plan file.
Cross-AI validation
test-after-edit.py
After edits to *.test.* or *.spec.*, runs that file’s tests and injects results as context.
Non-blocking test run
test-before-pr.sh
Fires on gh pr create. Diffs against origin/main, runs the test suite + build/typecheck only for scopes that changed (e.g. backend/ or mobile-app/), blocks PR creation if any scope fails.
Blocks PR on failure
When Claude wants to stop Final gate 3 hooks
stop-guard.sh
Once per 24h, forces Claude to verify claims: ls each file, run tests, tsc --noEmit. No evidence, no stop.
Blocks stop until verified
cost-tracker.py
Estimates USD cost per model from token counts, appends a JSONL row to ~/.claude/metrics/costs.jsonl.
Non-blocking logging
auto-commit-on-stop.sh
Creates a WIP commit if git status is dirty so work isn’t lost when a session ends unexpectedly.
Non-blocking auto-commit

[ 07 · CLOSE ]

The single idea.

The intern-vs-senior frame isn’t a metaphor. It’s a description of what these scripts are doing, line by line. A senior developer has internalized a hundred rules they enforce without thinking: don’t delete src, don’t commit console.log, don’t push a migration without a review, stop reading and start writing, verify before claiming done. The intern hasn’t internalized those rules yet. Hooks are how you hand them over.

Every rule you enforce in your head is a hook you haven’t written yet. The fifteen above are a starting fleet, not an ending one. What matters is that you have some policy layer between the non-deterministic agent and your real codebase. Prompts alone won’t get you there. Prompts are what the intern hears once. Hooks are what the intern cannot forget.

An intern with a checklist beats a senior without one. Hooks are the checklist.

[ LINES TO STEAL ]

Ideas worth re-using.

An intern with a checklist beats a senior without one. Hooks are the checklist.

Every rule you enforce in your head is a hook you haven’t written yet.

Hooks aren’t formatters. They are the policy layer that makes a non-deterministic agent safe to run unattended.

Exploration is not progress. Agents have a failure mode that looks exactly like diligence.

[ RESOURCES ]

Everything linked in the article.

[ WHAT NEXT ]

Steal the fleet.

Every hook above is in the repo: the shell scripts, the Python files, the settings.json that wires them up. Clone it, pick the three we deep-dived today, drop them into your own .claude/hooks/. You’re already past ninety percent of Claude Code setups in the wild. Next article: writing a new hook from scratch, the one I wish I’d built six months ago.