Claude Code Hooks: Rules the Agent Can't Forget
CLAUDE.md tells Claude Code what you'd like. Hooks make it happen, every time — blocking edits to protected files, formatting after every change, refusing to stop while tests fail. Five practical hooks, how they work, and the honest limits of what a hook can enforce.
Every instruction in CLAUDE.md is a request. "Always run the tests before finishing." "Never edit the migrations directory." "Format with Prettier." Claude follows these most of the time — and "most of the time" is fine until the one session where it isn't, which tends to be the long, unattended one where nobody's watching.
Hooks close that gap. A hook is a command that Claude Code runs itself, at a fixed point in its lifecycle, whether or not the model remembers to. The model can't skip it, forget it, or decide it doesn't apply this time.
This guide covers how hooks work, five hooks worth having, and the limits that matter if you're relying on one for safety.
Details reflect Claude Code as of September 2026. The hooks guide and reference have the full list of events and fields.
How hooks work
Hooks are configured in a settings file, under a hooks key:
.claude/settings.json— the project's hooks, committed and shared with your team.claude/settings.local.json— your own project hooks, not committed~/.claude/settings.json— your hooks for every project
Each hook attaches to an event, optionally filtered by a matcher. The events you'll use most:
| Event | Fires | Typical use |
|---|---|---|
PreToolUse |
Before a tool runs | Block or allow the call |
PostToolUse |
After a tool succeeds | Format, lint, log |
Stop |
When Claude is about to finish its turn | Check the work is actually done |
SessionStart |
When a session starts or resumes | Inject fresh context |
Notification |
When Claude is waiting for input | Alert you |
For tool events, the matcher is the tool name: Bash, Edit|Write, or a pattern for MCP tools like mcp__github__.*.
When a hook fires, Claude Code passes it a JSON description of the event on standard input — for a tool event, that includes tool_name and tool_input (the command about to run, or the file about to be edited). The hook's exit code decides what happens next:
- Exit 0 — no objection; carry on.
- Exit 2 — block. Whatever the hook wrote to standard error is shown to Claude, so it knows why and can adjust.
- Anything else — a non-blocking error; the action proceeds.
That's the whole model. A hook is a script that reads JSON and exits with a number.
Hook 1: protect files the agent shouldn't touch
Some files should never be edited by an agent: the .env with real secrets, the lockfile, already-applied database migrations. A PreToolUse hook on Edit|Write checks the target path and blocks matches.
.claude/hooks/protect-files.sh:
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
for pattern in ".env" "package-lock.json" "migrations/applied/"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH is protected. Ask the user to change it." >&2
exit 2
fi
done
exit 0
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }
]
}
]
}
}
Make the script executable (chmod +x), and jq needs to be installed. $CLAUDE_PROJECT_DIR points at the project root, so the hook works whatever directory Claude is in.
The message on stderr matters. "Blocked" alone leaves Claude guessing; "ask the user to change it" tells it what to do instead.
Hook 2: format every file after it's edited
Rather than asking Claude to remember the formatter, run it after every edit. PostToolUse on Edit|Write:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}
Every file comes out formatted, diffs stay clean, and Claude spends no turns on style. Swap in your formatter — ruff format, gofmt, rustfmt.
Hook 3: don't let it stop while the tests fail
This is the most valuable hook for unattended work. A Stop hook runs when Claude thinks it's finished. If it exits 2, the stop is blocked and the stderr becomes Claude's next instruction:
#!/bin/bash
INPUT=$(cat)
# Don't loop forever: if we already sent Claude back once, let it stop.
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! OUTPUT=$(npm test 2>&1); then
echo "Tests are failing. Fix them before finishing:" >&2
echo "$OUTPUT" | tail -40 >&2
exit 2
fi
exit 0
"Done" now means "done and the tests pass," checked by the test runner rather than claimed by the model.
Two cautions. The stop_hook_active check matters: without it, a test that can't be fixed keeps sending Claude back, and Claude Code eventually overrides a Stop hook that blocks repeatedly without progress. And keep the check fast — a ten-minute suite on every stop gets expensive. Run the relevant subset, or type-check and lint only.
Hook 4: keep dangerous commands away from production
A PreToolUse hook on Bash can refuse commands that match patterns you never want run:
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -Eiq 'drop (table|database)|prod(uction)?[-_.]db|--force'; then
echo "Blocked: this looks destructive or production-facing. Ask the user first." >&2
exit 2
fi
exit 0
Useful — but read the limits section below before relying on it. A string match is a tripwire, not a wall.
Hook 5: tell me when it needs me
The Notification event fires when Claude is waiting for your input or permission. Point it at anything that reaches you: a desktop notification, or a push to your phone through a service like ntfy or a Slack webhook.
{
"hooks": {
"Notification": [
{
"hooks": [
{ "type": "command", "command": "curl -s -d 'Claude Code needs input' ntfy.sh/your-private-topic" }
]
}
]
}
}
For long sessions on a remote machine, this turns "check back every twenty minutes" into "get pinged when it matters." (If you're running sessions remotely, How to Use Claude Code From Your Phone covers the rest of that setup.)
Beyond command hooks
Command hooks are the workhorse, but not the only kind. Claude Code also supports HTTP hooks (POST the event to a URL), and prompt- and agent-based hooks that ask a model to judge a condition — "does this change include tests?" — for checks that need judgment rather than a regex. Hooks can also return structured JSON instead of an exit code, for finer control such as sending a permission request to the user instead of blocking it outright.
Start with command hooks. They're deterministic and easy to test by piping JSON into the script yourself.
The limits: what a hook can and can't enforce
Hooks are deterministic in one sense — they always run. They are not, by themselves, a security boundary. Four things to understand:
1. Pattern-matching commands is spellable-around. Hook 4 blocks DROP TABLE. It doesn't block the same SQL in a file that gets piped to psql, or base64-encoded, or run from a script Claude wrote a minute earlier. Anthropic's own docs make the same point about permission rules. A hook that inspects intent by reading a command string will always have gaps.
2. Edit|Write doesn't see every file change. Claude can also change files by running shell commands — sed, mv, a script. Hook 1 protects against the Edit tool, not against echo > .env. To catch changes however they happen, check outcomes: a Stop hook that inspects git status, or file permissions that make the file read-only.
3. Hooks run with your permissions. A hook is arbitrary code running as you. And project hooks come from .claude/settings.json — which is committed to the repository. Cloning someone else's repo and running Claude Code in it means their hooks can run on your machine. Interactive sessions ask you to trust a folder first; scripted claude -p runs don't show that dialog. Read a repository's .claude/ directory before running an agent in it.
4. Hooks check; they don't contain. The strongest hooks verify results — tests pass, the build succeeds, no protected file changed — rather than predicting which commands are dangerous. For containment, you need the environment itself to be the boundary: an agent that literally can't reach production credentials doesn't need a hook to stop it using them.
The right mental model: hooks are how you make good behaviour automatic and accidental mistakes unlikely. Isolation is how you make catastrophic mistakes impossible. You want both. We make the case for the second in How to Stop an AI Agent From Deleting Your Production Database.
A sensible starting set
For most projects, start with:
- Format on edit (
PostToolUse) — zero risk, immediate payoff. - Protected files (
PreToolUseonEdit|Write) — secrets, lockfiles, applied migrations. - Tests before stopping (
Stop) — the one that makes unattended work trustworthy. - Notify when waiting (
Notification) — so unattended doesn't mean abandoned.
Commit the first three to .claude/settings.json so everyone on the project — human or agent — gets them. Keep notifications in your personal settings. Type /hooks in a session to see what's configured and where each hook came from.
Pair them with a short, rule-shaped CLAUDE.md (here's how to write one) and sensible permission rules (covered here), and you have an agent that follows your project's rules because it can't not.
EasySpawn runs Claude Code in an isolated workspace — its own container, non-root, resource-limited, with no route to the host — so your hooks handle the everyday rules and the environment handles containment. See how it works or join the waitlist.
Related: Running Claude Code Unattended · Running Claude Code Headless · Connecting MCP Servers to Claude Code · Why TypeScript Makes AI-Generated Code Safer · Claude Code Settings Explained
Keep reading
Connecting MCP Servers to Claude Code: Setup, Scopes, and the Security Question
MCP servers let Claude Code talk to your issue tracker, database, error monitoring, and docs. Adding one is a single command. Choosing which ones to trust is the harder part. How to connect them, where the configuration lives, and how to keep a useful integration from becoming a data leak.
Running Claude Code Agents in Parallel With Git Worktrees
Two agents in one checkout will overwrite each other's work. Git worktrees give each Claude Code session its own files and branch on the same repository. How to set it up, and the parts nobody warns you about: ports, databases, and dependencies.