You can ask Claude in CLAUDE.md never to delete your home directory. It will almost always listen. A hook makes it certain.
Instructions are advice, hooks are rules
Everything in CLAUDE.md is guidance the model weighs up. A hook is code that Claude Code itself runs at a fixed moment: before a tool runs, after a file is edited, when a session starts. The model can't skip it or talk its way past it. That makes hooks the right tool for anything that must always happen, or must never happen.
Where hooks are configured
Hooks live in the same settings files as everything else:
| File | Scope |
|---|---|
~/.claude/settings.json |
You, in every project |
.claude/settings.json |
This project, shared with the team through git |
.claude/settings.local.json |
You, in this project only (not committed) |
The shape is always event, then a matcher, then the commands to run:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 ~/.claude/hooks/block-rm.py" }
]
}
]
}
}For tool events the matcher is the tool name. It accepts alternatives and regular expressions, such as Edit|Write, and it's case-sensitive. An empty matcher matches everything.
The events worth knowing first
There are over thirty events. These cover most real use:
| Event | Runs when |
|---|---|
SessionStart |
A session starts or resumes |
UserPromptSubmit |
You send a prompt, before Claude reads it |
PreToolUse |
Before a tool runs. Can block it |
PostToolUse |
After a tool succeeds |
Stop |
Claude finishes responding |
PreCompact |
Before the conversation is compacted |
How a hook talks back
Claude Code sends the hook a JSON description of the event on stdin. For tool events that includes tool_name and tool_input, so a Bash hook can read the exact command at tool_input.command.
The hook answers with its exit code:
- 0 means carry on.
- 2 means block. Whatever the hook printed to stderr is passed back as the reason, so Claude can adjust.
- Anything else is a non-blocking error: the action continues and you see a notice.
Example: block a destructive delete
Save this as ~/.claude/hooks/block-rm.py and register it with the PreToolUse configuration above:
#!/usr/bin/env python3
import json
import re
import sys
event = json.load(sys.stdin)
command = event.get("tool_input", {}).get("command", "")
# rm with a recursive flag, aimed at / or ~ themselves.
DANGEROUS = re.compile(r"\brm\s+-\w*[rR]\w*\s+(/|~)(\s|$)")
if DANGEROUS.search(command):
print("Blocked: recursive delete of / or ~. Delete a specific path instead.", file=sys.stderr)
sys.exit(2)
sys.exit(0)Test it without Claude by piping in a fake event:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf ~"}}' | python3 ~/.claude/hooks/block-rm.py
echo $? # 2 means it would have been blockedExample: format every file Claude edits
A PostToolUse hook on Edit|Write can run your formatter on the file that just changed, so you never review unformatted code:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}Before you add a hook
- Hooks run as you. They have your permissions, so read any hook script before installing it, the same as any script from the internet.
- Keep them fast. A
PreToolUsehook runs before every matching tool call. Command hooks time out after 10 minutes by default, but you want them to finish in well under a second. - Allowing isn't a bypass. A hook can block a tool, but a hook that approves one doesn't override your permission rules.
Questions
Should a rule go in a hook or in CLAUDE.md?
If breaking it would be costly, such as deleting data, leaking secrets or pushing to the wrong branch, use a hook. If it's a preference about style or approach, CLAUDE.md is enough.
Can my whole team use the same hooks?
Yes. Put them in the project's .claude/settings.json and commit it, with the hook scripts in the repository.
What's the difference between exit code 1 and exit code 2?
Exit code 2 blocks the action and sends your message to Claude. Exit code 1, like any other non-zero code, is treated as a hook error: the action still goes ahead.