Guide
Claude Code hooks: what they are, with examples that work
A hook runs a command at a set point in a Claude Code session, every time, whether or not Claude remembers your instructions. Here’s how to set one up, five examples to copy, and how to fix one that doesn’t fire.
Checked against Anthropic’s hooks guide on November 4, 2026. We ran the two blocking hooks, the log and a session-start hook in a scratch project on Claude Code 2.1.318. The notification and Prettier examples come from the guide; we didn’t run those two. Claude Code changes often, so run claude update if yours behaves differently.
What hooks are
Hooks are commands you put in a settings file. Claude Code runs them when a matching event fires. That makes them different from everything else you can tell Claude: CLAUDE.md, skills and memory are read and weighed, and a hook just runs.
So hooks suit rules with no judgment in them: format after every edit, never touch .env, notify me when you’re waiting. Most are shell commands, and there are also HTTP, MCP tool, prompt and agent types. The prompt and agent types ask a Claude model to decide.
Your first hook
This one sends a Mac notification when Claude is waiting for you. Add it to ~/.claude/settings.json, creating the file if it doesn’t exist:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs you\" with title \"Claude Code\"'"
}
]
}
]
}
} - If the file already has a
hookskey, addNotificationinside it. Don’t replace the whole object. - Type
/hooksin Claude Code. Your hook should be listed under Notification. - Ask Claude to do something that needs permission, then switch to another window.
No notification? osascript routes through the Script Editor app and fails silently without permission. Run osascript -e 'display notification "test"' once, then allow Script Editor under System Settings › Notifications. Anthropic’s guide has the Linux and Windows versions.
The events you’ll use
There are more than 30 events. These cover most real hooks:
| Event | When it fires | Good for |
|---|---|---|
| PreToolUse | Before a tool call. Can block it | Protecting files, stopping risky commands |
| PostToolUse | After a tool call succeeds. Can’t undo it | Formatting, linting, logging |
| UserPromptSubmit | When a prompt is submitted | Adding context to a message |
| SessionStart | When a session begins or resumes | Loading current state |
| Notification | When Claude Code sends a notification | Desktop alerts |
| Stop | When Claude finishes responding, not only when a task is done | Checks at the end of a turn |
| PreCompact | Before context compaction | Saving something before it’s summarized |
A matcher narrows an event. On tool events it matches the tool name, so Edit|Write means those two tools and mcp__github__.* means every tool on one MCP server. On SessionStart it matches how the session began: startup, resume, clear, compact or fork. Some events, such as Stop and UserPromptSubmit, don’t support matchers and fire every time.
Where you put the block sets its reach: ~/.claude/settings.json for you everywhere, .claude/settings.json to commit it for the team, .claude/settings.local.json for you in one project. That last file is gitignored only when Claude Code itself saves to it, so check your .gitignore if you create it by hand.
Five examples to copy
The scripts use jq to read the JSON that Claude Code sends on stdin. Install it first, with brew install jq on a Mac.
1. Protect sensitive files
Save this script, and run chmod +x on it, because hook scripts must be executable:
#!/bin/bash
# .claude/hooks/protect-files.sh
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
case "$FILE_PATH" in
*/.env|*/.env.*|*/package-lock.json|*/.git/*)
echo "Blocked: $FILE_PATH is protected. Tell me what you wanted to change." >&2
exit 2
;;
esac
exit 0 Then register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
} In our test, Claude tried to write .env, the write was refused, and Claude reported the block and stopped there. environment.md still went through. The match is on the path Claude edits, so this doesn’t stop a Bash command that writes the same file. Add a Bash check if that matters.
2. Block force pushes
#!/bin/bash
# .claude/hooks/no-force-push.sh, with the matcher set to Bash
COMMAND=$(jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -Eq 'git push.* (--force|-f)( |$)'; then
echo "Blocked: no force pushes. Open a pull request instead." >&2
exit 2
fi
exit 0 We checked the pattern against git push origin main --force and git push -f (both blocked), and against a branch called feature-f and --force-with-lease (both allowed). It’s a guardrail against a mistake, not a security boundary, since a script or alias can run the same push under another name. Pair it with branch protection on your Git host.
3. Format after every edit
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
} A successful hook shows nothing in the conversation, so confirm this one by looking at the file. Swap Prettier for your own formatter.
4. Keep a log of what changed
Use the same PostToolUse block with this command:
jq -r '.tool_input.file_path' >> "$CLAUDE_PROJECT_DIR"/edited.log 5. Load current state at the start
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "git log --oneline -5"
}
]
}
]
}
} What the command prints is added to Claude’s context. Use the compact matcher instead to bring something back after compaction. /compact vs /clear explains what compaction keeps.
Exit codes
This is the part people trip on:
- Exit 0: no objection. On
PreToolUsethat doesn’t approve the call. It still goes through the normal permission flow. - Exit 2: block, on the events that can block. Write the reason to stderr, not stdout. Some events can’t be blocked: on
SessionStart, exit 2 only shows the message. - Any other code: usually a non-blocking error. You see a “hook error” notice and the action goes ahead. So a script that crashes doesn’t protect anything, unless you set
onFailureto"block".
For finer control, exit 0 and print JSON instead. Don’t mix the two approaches in one hook. permissionDecision and additionalContext belong inside hookSpecificOutput; at the top level they’re ignored with no error.
When a hook doesn’t work
It isn’t listed in /hooks:
- The JSON is invalid. Trailing commas and comments aren’t allowed.
- The file is in the wrong place. Project hooks go in
.claude/settings.jsonat the repository’s root. - The change wasn’t picked up. Edits normally apply on their own; restart the session if they don’t.
- The menu says “Only hooks from managed settings run here”, which means your organization has turned off the others.
It’s listed but never fires:
- The matcher is case-sensitive and matches the tool name, so
editwon’t matchEdit. - You picked the wrong event for what you’re testing.
- Claude changed the file with a Bash command, so
Edit|Writenever matched.
You see “hook error”: run the script by hand with sample input and read the exit code.
echo '{"tool_name":"Edit","tool_input":{"file_path":"/repo/.env"}}' \
| ./.claude/hooks/protect-files.sh
echo $? # 2 means blocked - “command not found”: use an absolute path or
$CLAUDE_PROJECT_DIR. - “jq: command not found”: install jq.
- The script doesn’t run at all:
chmod +xit.
It runs and has no effect: something printed before your JSON, usually an echo in your shell profile, so the output doesn’t start with { and is read as plain text. A misplaced field does the same. Start with claude --debug and look in the log.
Claude won’t stop: a Stop hook that keeps blocking. Claude Code overrides it after eight blocks in a row. Check stop_hook_active in the input and exit early when it’s true.
To see what a hook did, press Ctrl+O for the transcript view.
Hooks, skills and CLAUDE.md
| What it is | Use it for | |
|---|---|---|
| Hook | A command that runs when an event fires | What must always or never happen |
| Skill | Instructions and commands Claude can use | How to do a kind of work |
| CLAUDE.md | Instructions loaded at the start of a session | Conventions and project facts |
| Permission rules | Allow and deny rules for tools | Hard limits on what can run |
If Claude keeps breaking a rule in CLAUDE.md and the rule has no exceptions, it’s a candidate for a hook. Anthropic’s guide also says to use permission rules rather than a hook’s if filter for a hard allow or deny, because that filter is best-effort.
Hooks and memory
A SessionStart hook can put text in front of Claude, but it can’t work out what the text should be. Anything you print has to come from somewhere: a file you maintain, git log, or a tool that keeps track for you. How to make Claude Code remember between sessions covers the options that don’t need a hook.
We make Cachejay, which uses hooks this way. Connecting it to Claude Code adds two hooks to ~/.claude/settings.json. SessionStart prints where you left off on the project in that folder, from your earlier sessions, calls and screen. UserPromptSubmit adds what’s on your screen when your message points at something you can see, like “fix this error”. Both print nothing when there’s nothing useful, and Cachejay also keeps the sessions so other agents can search them. Setup is on the Claude page.