You already know how to write a good contract for your agent: you tell it in the AGENTS.md how you want it to work. The problem is that an LLM reads that contract and decides all over again on every turn — so the critical rule gets followed almost every time, and that "almost" is exactly the one that bites you. Hooks are the next rung: you take the rule out of the model's head and put it into the infrastructure, so it happens 100% of the time without depending on its judgment. I'll walk you through the lifecycle events where you hook in, the anatomy of the JSON, where each hook lives and why that matters, and I'll leave you three real hooks ready to paste (auto-format, blocking dangerous commands, desktop notification) drawn from Anthropic's official docs. A single master prompt to set up your first one with safety built in — because a hook runs shell with your permissions. It's the leap from "write the contract" to "make the contract run itself".
There comes a point in every AI project where you stop trusting the model's good intentions. You wrote in your AGENTS.md that it should format the code, not touch the sensitive files, run the tests. And 90% of the time it does. But the remaining 10% is exactly the one that bites you: the time it edited the production .env, the time it skipped formatting and left the commit messy, the time it ran an rm -rf on the wrong folder because "it looked temporary".
The hook moment is when you think: "what if this didn't depend on the model deciding to do it right? What if it just happened, every time, without relying on its judgment?". That's exactly the promise the official Claude Code docs put at the center of the hooks page: they give "deterministic control" over the agent's behavior, "ensuring certain actions always happen rather than relying on the LLM to choose to run them" (faithful translation of the English original). That sentence is the whole thesis of this resource.
The pain isn't an apocalypse, it's erosion. You asked the AI to do something "every time" and sometimes it doesn't, because an LLM isn't deterministic: each turn it decides all over again, and in a turn loaded with context, your reminder to format can get buried under a thousand tokens of something else. It's not that the AI is disobedient — it's that you're asking for consistency from a probabilistic system. It's like asking someone brilliant but scatterbrained to never forget to turn off the gas: most nights they turn it off, and that's exactly why you let your guard down.
.env, a package-lock.json, something inside .git/.And here's the angle that makes this an advanced resource: if you've already read about writing a good contract for your agent (the AGENTS.md), hooks are the next rung. You go from "write the contract" (declarative: you tell it what you want) to "make the contract run itself" (deterministic: the machine enforces it for you). It's the difference between a law written on paper and a cop standing on the corner.
A hook is a shell command that Claude Code runs automatically at a specific point in its lifecycle. You declare it once in a settings.json file; from then on, every time that point in the cycle happens, your command runs. It's not triggered by the model's judgment: it's triggered by the event. That's the magic — it's deterministic, not up for debate.
The official docs list many lifecycle events — there's one for almost any moment in the agent's work. Don't memorize the count or the full list: learn the idea and keep the ones you'll actually use 95% of the time. Here they are, with their real names exactly as they appear in the docs:
rm -rf or an edit to a protected file.SessionStart with the compact matcher) — around context compaction, so you don't lose what matters when memory gets compressed.Pre. If you want to react to something that already happened (format, notify, log), hook into Post.Every hook lives inside a "hooks" block in your settings.json. The structure is always the same nesting doll: the event name → a matcher (what it applies to) → a list of hooks with type: "command" and the command to run. Look at it once and you'll never forget it:
{
"hooks": {
"PostToolUse": [ // 1. the lifecycle EVENT
{
"matcher": "Edit|Write", // 2. WHICH tools it applies to
"hooks": [
{
"type": "command", // 3. it's a shell command
"command": "...your command..." // 4. WHAT to run when it happens
}
]
}
]
}
}PreToolUse, PostToolUse) it filters by tool name: "Edit|Write" means "only when it uses Edit or Write", "Bash" only shell commands. An empty matcher ("") fires every time, for everything. The pipe | separates alternatives (in recent Claude Code versions a comma works too: "Edit, Write" is equivalent).Where you put the hook defines its scope. This is key: a project hook is shared with your team (it's committed to the repo); a global one is just yours. The official table sums up where each thing goes:
.claude/settings.json and gets committed. That way the contract stops living in one person's head and starts traveling with the code. That's the real power: discipline becomes part of the repository, not a sticky note someone has to remember.Enough theory. Here are three real hooks, drawn from the official docs, ready to paste. Start with the one that hurts most today.
The classic. Every time Claude edits or writes a file, Prettier runs automatically. Never again a commit with inconsistent formatting. Goes in your project's .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}jq -r '.tool_input.file_path' extracts the edited file's path from that JSON, and xargs npx prettier --write hands it to Prettier. jq is a command-line JSON reader — install it with brew install jq on macOS or apt-get install jq on Debian/Ubuntu.This is the one that takes the fear away. A PreToolUse hook that reviews every Bash command before running it and vetoes it if it contains anything destructive. Here a technical detail the docs make crystal clear does matter: it blocks by exiting with code 2, and whatever you write to stderr reaches Claude as an explanation so it can correct itself. First the script:
#!/bin/bash # .claude/hooks/block-rm-rf.sh INPUT=$(cat) COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command') if echo "$COMMAND" | grep -q "rm -rf"; then echo "Blocked: 'rm -rf' is not allowed. Delete specific paths, not recursive-force." >&2 exit 2 # exit 2 = blocks the action; stderr reaches Claude as feedback fi exit 0 # exit 0 = no objection; normal permission flow continues
Make it executable and register it in your settings pointing to the script:
chmod +x .claude/hooks/block-rm-rf.sh
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh"
}
]
}
]
}
}grep -q "rm -rf" catches the obvious case, but it's slipped past by an rm -fr, an rm -rf with weird spacing, or an rm --recursive --force. It works as a visible net and educational feedback for the AI, but don't mistake it for armor. The docs themselves say it plainly: hook filtering is best-effort and "fails open" (if it can't parse the command, it lets it through), so for a HARD ban nobody can skip, use the permission system (permissions.deny), not a hook. The correct pattern is: hook to react and warn; permission rules for the real lock.PreToolUse that returns a block decision vetoes the tool EVEN in `bypassPermissions` or with `--dangerously-skip-permissions`. That is: even if you (or the AI) have dropped every permission barrier, your blocking hook still stands. The rule is asymmetric and designed that way on purpose: hooks can tighten the policy, never loosen it beyond what the permission rules allow. A blocking hook is a rule not even you can skip by accident.To stop babysitting the terminal. When Claude finishes or needs your permission, a desktop notification pops up and you go do something else. Goes in your global ~/.claude/settings.json (macOS example with osascript):
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}notify-send 'Claude Code' 'Claude Code needs your attention'; on Windows (PowerShell) a MessageBox from System.Windows.Forms. The JSON structure is identical — only the command line changes. A macOS trick the guide itself documents: if the notification doesn't show up, run osascript -e 'display notification "test"' once and then grant Script Editor permission in System Settings → Notifications.Hooks aren't for everything. The mental rule is simple: if something must always happen, mechanically and without judgment, it's a hook. If something requires case-by-case judgment, don't force it with a deterministic hook — let the model decide (or use the prompt/agent-type hooks the docs also support, but that's another league).
.env, delete recursively, write to .git/): that's a blocking PreToolUse — backed by a permission rule for the hard lock.PostToolUse.PostToolUse: it can't undo what already happened — it only reacts. To prevent, always PreToolUse. Cramming too much complex logic into a hook makes it fragile; keep them short and single-purpose.Here's the practical detail: the docs themselves recommend asking Claude to write the hook by describing it. But for it to come out right —and be safe— it's worth giving it a structured request, not a "give me a hook". This is THE master prompt of the resource: a single, powerful one that designs your first hook with the safety brakes on.
I want to set up my first Claude Code hook to automate a rule in my workflow. The rule I want to guarantee ALWAYS happens is: [DESCRIBE THE RULE, e.g.: "format every file you edit with prettier" / "block any command that deletes files recursively" / "prevent editing the .env or anything inside .git/" / "notify me when you finish"]. Before writing anything, help me design it well by answering this in plain language (I don't code): 1. THE CORRECT EVENT in the lifecycle for this rule. If the rule is to PREVENT something, it must be PreToolUse (happens before and can block). If it's to REACT to something already done (format, notify, log), it must be PostToolUse or Notification. Tell me which you pick and why. 2. THE EXACT MATCHER (which tools it applies to: Edit|Write, Bash, or empty for all) and why that one and not another. Keep it as NARROW as possible: a matcher that's too broad fires the hook where it shouldn't. 3. THE PLACE where the settings.json should live: - If the rule must be followed by the whole team on this repo → .claude/settings.json (gets committed). - If it's just for my machine and all my projects → ~/.claude/settings.json. - If it's local and private → .claude/settings.local.json. Tell me which and why. 4. THE FULL JSON BLOCK, ready to paste, with the hook already set up. If it needs a separate script (typical for blocks with PreToolUse), give me the full .sh script too and remind me to make it executable with chmod +x. 5. IF IT'S A BLOCKING HOOK: use it with exit 2 and a clear message to stderr explaining to me why it was blocked, so you yourself (Claude) receive the reason and correct yourself. Don't make it silent. And WARN ME if this rule also deserves a permission rule (permissions.deny) as a hard lock, because a grep in a hook is best-effort and can be skipped. 6. SECURITY, mandatory: explain to me in one sentence WHAT shell command this hook will run and confirm it does nothing destructive and sends no data anywhere. Remind me that a hook runs shell with my permissions and that I should only save commands I understand. 7. HOW I TEST IT: tell me how to verify the hook is registered (the /hooks command inside Claude Code) and a way to test that it actually fires, without breaking anything real. First show me ONLY the design (event, matcher, place), then the JSON and the script if needed, and at the end the security explanation and the test. Don't modify my settings.json yet: I want to review the block before I paste it myself.
Notice the pattern: first you design, you review the security, and you paste the block. You never let a hook appear in your config without having understood it. It's the same two-step discipline that protects any powerful automation: the machine proposes, you approve.
So you don't get tangled up, here's what you can delegate to the AI over chat and what stays your job:
.sh script for blocking hooks, with its jq, its exit 2, and its stderr message.settings.json it should live in.chmod +x the script, brew install jq if you're missing it.settings.json — after reading and understanding it. The safety net can't be code you didn't review.PreToolUse to prevent, PostToolUse/Notification to react.PreToolUse that vetoes what shouldn't get in. It's the same philosophy as a hook, applied to a whole team instead of a single terminal: making quality not a promise someone remembers, but a sensor on the belt that can't be skipped. You set the rule once; the system enforces it forever.Join 4,200+ builders. No credit card. Build your first app with AI in minutes.