An instruction in CLAUDE.md is a request. Claude reads it and usually follows it. A hook is different. It is a command that Claude Code runs itself at a fixed point in the session, every time, whether or not the model remembers to. When you need something to happen without exception, such as formatting every edited file or refusing every rm, a hook is the tool.
What a hook is
A hook is a shell command (or an HTTP call, an MCP tool call, a prompt or a subagent) tied to an event. When the event fires, Claude Code runs the command and passes it a JSON description of what is happening on stdin. The command can let the action go ahead, block it, or add text for Claude to read.
The events you will use most:
| Event | When it fires |
|---|---|
SessionStart | A session starts, resumes, clears or compacts |
UserPromptSubmit | You send a prompt, before Claude sees it |
PreToolUse | Before a tool call runs. It can block the call |
PostToolUse | After a tool call succeeds |
Stop | Claude finishes its answer |
PreCompact | Before the conversation is compacted |
There are many more, including SubagentStop, Notification, FileChanged and SessionEnd. The full list is in Anthropic's hooks reference.
Where hooks go
Hooks live in a settings file or a plugin. Where you put one decides who it applies to:
| Location | Applies to |
|---|---|
~/.claude/settings.json | All your projects, on your machine only |
.claude/settings.json | This project, committed for the team |
.claude/settings.local.json | This project, just for you |
A plugin's hooks/hooks.json | Anyone with the plugin enabled |
Hooks from different files add up. A project hook does not replace your personal ones. Type /hooks in a session to see everything that is active.
Exit codes decide what happens
Your hook's exit code is its answer:
- Exit 0 means go ahead. If your hook printed a JSON object, Claude Code applies its fields.
- Exit 2 means block. On events that can block, such as
PreToolUseandUserPromptSubmit, the action stops, and whatever you wrote to stderr becomes the reason Claude sees. - Any other exit code is a non-blocking error. You get a notice in the transcript and the action goes ahead anyway.
That third rule catches people out. A safety hook that crashes with exit code 1 does not block anything. If a hook is meant to stop something, make sure its failure path exits 2.
Hook 1: format every file Claude edits
This runs Prettier after every edit. Put it in .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
The matcher limits the hook to the edit and write tools. The command reads the JSON input with jq, takes the edited file's path and formats it. You need jq installed.
Hook 2: block rm commands
Save this as .claude/hooks/no-rm.sh and make it executable:
#!/bin/bash
input=$(cat)
command=$(jq -r '.tool_input.command' <<<"$input")
if [[ "$command" == rm* ]]; then
echo "Blocked: rm commands are not allowed" >&2
exit 2
fi
exit 0
Then register it on the Bash tool:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-rm.sh" }
]
}
]
}
}
When Claude tries rm -rf build, the call never runs. Claude gets the stderr line as the tool's error, so it knows why and can try another way.
Hook 3: keep Claude out of your .env files
The same pattern protects files. This one blocks edits to anything that looks like an env file:
#!/bin/bash
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in
*.env|*.env.*|*/.env)
echo "Blocked: $file holds secrets. Ask before changing it." >&2
exit 2
;;
esac
exit 0
Register it under PreToolUse with the matcher Edit|Write. A rule in CLAUDE.md that says "never touch .env" is good to have too, because it tells Claude why. The hook is what makes it certain.
Hook 4: load context when a session starts
SessionStart is the one event where plain stdout becomes context Claude reads. That makes it the natural place to load anything that changes between sessions, such as the issues you have open or notes saved from the last session.
Here is a short version of the hook the FixMyPrompt plugin uses. It tells Claude which folder it is in and asks it to look up the saved memories for that folder before it starts work:
// hooks/session-start.mjs
import path from "node:path";
const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const name = path.basename(path.resolve(dir));
const additionalContext =
`This folder is ${name}. Before you start work, call the fmp mem_index tool ` +
`with project "${name}" and use what it lists.`;
process.stdout.write(
JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext } }) + "\n",
);
process.exit(0);
And the plugin's hooks/hooks.json:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"",
"timeout": 10
}
]
}
]
}
}
A few details are worth copying. The hook never exits with an error, because a broken context loader should not stop a session from starting. It has a timeout, because SessionStart runs on every session and Claude's first answer waits for it. And it fires after a compact as well as at startup, so the context comes back after a long session is summarized.
The full hook is in the fmp-plugins repo if you want to read it. It is the piece that makes FixMyPrompt Memories work in Claude Code: whatever the agent solved last time is listed at the start of the next session, so it does not solve it again.
When to use a hook and when to use CLAUDE.md
Use CLAUDE.md for guidance, including the reasons behind your rules. Claude reads it and applies judgment.
Use a hook when an exception would cost you: secrets, destructive commands, formatting that must be consistent, or context that has to be fresh every session.
Most projects want both. If your CLAUDE.md has grown long or contradicts itself, the free CLAUDE.md checker shows what it costs every session and which guardrails it is missing.