Claude Code hooks examples: 8 to paste into settings.json
Eight Claude Code hooks examples to paste into settings.json: block rm -rf, format on edit, run tests on Stop, desktop alerts, and how exit code 2 works.
Contents
All eight Claude Code hooks examples on this page go under the hooks key of ~/.claude/settings.json or a project's .claude/settings.json, and each is a shell command Claude Code runs on an event. Two send alerts (Notification, StopFailure). Three are guards that exit 2 to stop Claude: block rm -rf, protect .env, and run the tests before Claude finishes. The other three format edited files, log every Bash command and put your rules back after /compact.
Hooks 1, 3, 5, 7 and 8 are Anthropic's, from its docs; 2, 4 and 6 we wrote. Before publishing we piped each of the three scripts (4, 5 and 6) the JSON Claude Code sends, copied from the hooks reference, including the cases they must let through.
How Claude Code hooks work#
A hooks block can sit in three settings files, and /hooks lists what loaded from each.
| File | Applies to |
|---|---|
~/.claude/settings.json |
All your projects |
.claude/settings.json |
One project, and can be committed for the team |
.claude/settings.local.json |
You, in one project |
- The hook gets the event as JSON on stdin:
session_id,cwd,hook_event_name, and for tool eventstool_nameandtool_input(for Bash,tool_input.command). The scripts below read it withjq. "matcher": "Edit|Write"limits a tool hook to those two tools. Notification hooks match a type such aspermission_prompt.Stophas no matcher and fires on every reply.- A command hook gets 10 minutes by default on most events (30 seconds on
UserPromptSubmit). Thetimeoutfield, in seconds, changes it per hook.
| Exit code | What happens |
|---|---|
0 |
No objection |
2 |
Blocks on events that can block: a PreToolUse hook stops the tool call and Claude reads your stderr as the reason; a Stop hook makes Claude keep working |
| Any other | A non-blocking error; the action goes ahead |
If your settings file already has a hooks key, add each event as a sibling inside it rather than pasting a second hooks object. A hook that /hooks doesn't list was never loaded; the five usual reasons, from an array matcher to a // comment, are in Claude Code hooks not working.
1. A desktop notification when Claude needs you#
From Anthropic's hooks guide, in ~/.claude/settings.json:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
On Linux the command is notify-send 'Claude Code' 'Claude Code needs your attention'. An empty matcher fires on every notification type; permission_prompt fires only when Claude waits for an approval (after about six seconds), idle_prompt when it finished about a minute ago and you haven't typed.
On a Mac the alert comes from Script Editor, since that is what osascript posts as, and it stays silent until Script Editor has notification permission. Run osascript -e 'display notification "test"' once so Script Editor shows up in System Settings › Notifications, then allow it there.
2. A ping when you hit a usage limit#
StopFailure runs instead of Stop when a turn ends on an API error, and its matcher is the error type. This one tells you when the cause is a rate limit, so you're not left watching a stalled terminal:
{
"hooks": {
"StopFailure": [
{
"matcher": "rate_limit",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Hit a rate limit\" with title \"Claude Code stopped\"'"
}
]
}
]
}
}
The reference says StopFailure hooks have no decision control, so this one can alert or log and nothing else. Waiting for the reset, usage credits, an API key and a second account you own are compared in what to do when you reach a Claude limit.
3. Format every file Claude edits#
Anthropic's example, for .claude/settings.json in the project:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
For Python put ruff format in place of npx prettier --write, for Go gofmt -w, for Rust rustfmt. Prettier fails on a file type it has no parser for, such as a .py file in a JavaScript repo; add --ignore-unknown and it skips those instead of showing a hook error. The matcher sees only Claude's Edit and Write tools, so a file rewritten by sed -i in a Bash call isn't formatted.
4. Block rm -rf#
Save as .claude/hooks/block-rm-rf.sh and run chmod +x on it:
#!/bin/bash
# Refuse rm with both -r (or -R, --recursive) and -f (or --force).
CMD=$(jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -Eq '(^|[^[:alnum:]_-])rm[[:space:]]' \
&& echo "$CMD" | grep -Eq '[[:space:]]-[[:alpha:]]*[rR]|--recursive' \
&& echo "$CMD" | grep -Eq '[[:space:]]-[[:alpha:]]*f|--force'; then
echo "Blocked: rm -rf is not allowed here. Delete specific files instead." >&2
exit 2
fi
exit 0
Register it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh" }
]
}
]
}
}
In our test it blocked rm -rf build/, rm -fr /, sudo rm -Rf ~/x, /bin/rm -rf dist, rm -r -f tmp, rm --recursive --force tmp and cd /tmp && rm -rf x, and let through rm file.txt, rm -r olddir, npm run rm-all and git rm --cached a.txt. It reads command text, so a command that builds rm at run time, from a variable for example, gets past it. What it does well is hold in every permission mode: a hook's deny still applies under --dangerously-skip-permissions, which turns every permission prompt off.
5. Protect .env, lockfiles and .git#
Anthropic's script, as .claude/hooks/protect-files.sh:
#!/bin/bash
# protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0
Register it on PreToolUse with "matcher": "Edit|Write", the same way as the script above. It stops edits; it doesn't stop Claude reading those files. For reads, use a deny rule: our guide to allow, ask and deny rules shows how.
6. Run the tests before Claude finishes#
A Stop hook that exits 2 sends Claude back to work with your stderr as the reason. This one runs npm test only when the working tree changed, and passes the last 30 lines of a failure back. Save as .claude/hooks/tests-on-stop.sh:
#!/bin/bash
INPUT=$(cat)
# Already continuing because of this hook: let Claude stop.
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
cd "$(echo "$INPUT" | jq -r '.cwd')" || exit 0
if [ -z "$(git status --porcelain 2>/dev/null)" ]; then
exit 0
fi
if ! OUTPUT=$(npm test 2>&1); then
echo "The tests fail. Fix them before you finish:" >&2
echo "$OUTPUT" | tail -n 30 >&2
exit 2
fi
exit 0
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-on-stop.sh" }] }
]
}
}
Without the stop_hook_active check, a test that can't pass would loop. The field is true when Claude is already continuing because of a stop hook, so with the check the hook gives Claude one retry per turn. Claude Code also overrides a Stop hook after eight blocks in a row with no tool call in between, but a script shouldn't count on that. Stop fires after every reply, and so does npm test whenever the tree has changes; in a big repo, point the script at the tests for the package Claude is working in.
7. Log every command Claude runs#
Anthropic's one-liner, for ~/.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt" }
]
}
]
}
}
After an unattended run, tail -50 ~/.claude/command-log.txt shows the last 50 lines of it, newest command at the bottom.
8. Remind Claude of the rules after /compact#
Compaction summarises the conversation and can drop details. A SessionStart hook with the compact matcher adds its output back to Claude's context afterwards (Anthropic's example):
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
}
]
}
]
}
}
The echo can be any command whose output Claude should see again; git log --oneline -5 puts the last five commits back in front of it. Rules that matter from the first message belong in CLAUDE.md instead, because Claude Code loads it at the start of every session.
Read this before you add any hook#
Two sentences in the hooks reference set the rule: "Command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access."
- Read every hook you paste, these eight included. The hooks reference asks for quoted shell variables (
"$VAR", not$VAR) and absolute paths, and says to skip.env,.git/and keys. - Hooks committed in a repo's
.claude/settings.jsonwait for the workspace trust dialog in an interactive session.claude -pand SDK runs never show that dialog, so there a cloned repo's hooks run in a folder you never trusted. For a repo you didn't write, the docs suggestclaude -pwith--settings '{"disableAllHooks": true}'or--bare. - A guard that crashes lets the action through, since any exit code other than 0 or 2 is non-blocking. Pipe it a command it should refuse and check that
echo $?prints 2.
How Shuriken does notifications#
Shuriken tells you when a chat needs you without any hook, for every agent and every computer at once: a notification on your computer when a chat needs you (an approval or a question), when it finishes or fails, and when an account needs attention, such as a source that signed out. You aren't pinged for the chat you're looking at, and a question answered on one device disappears from the others. Which events notify, and why, is in a notification when a chat needs you; Remote Control's pushes and an ntfy hook for your phone are in Claude Code notifications on your phone.
The guard hooks (4 to 6) are a different job: they decide what Claude Code may do, and no notification replaces them. If you'd rather not copy notification hooks to every machine, download Shuriken.
Sources
- Automate actions with hooks (Claude Code docs) (accessed 22 October 2026)
- Hooks reference (Claude Code docs) (accessed 22 October 2026)
- Settings files and precedence (Claude Code docs) (accessed 22 October 2026)
Frequently asked questions
Where do Claude Code hooks go?
Under the "hooks" key of a settings file. ~/.claude/settings.json covers every project, .claude/settings.json is committed with a repo, and .claude/settings.local.json is yours in one project. /hooks lists what loaded.
What does exit code 2 do in a Claude Code hook?
It blocks the action on events that can block. In a PreToolUse hook it stops the tool call and shows Claude your stderr; in a Stop hook it makes Claude keep working.
Are Claude Code hooks safe?
Hooks run shell commands with your full user permissions, so read every hook before you add it. Be careful with hooks committed in someone else's repository, above all in claude -p runs.
Why doesn't my hook fire?
Usually it never loaded, its matcher matches nothing, or the script isn't executable (chmod +x). A hook missing from /hooks is outside the "hooks" key or in the wrong file; a listed one may say "bash" where the tool is called Bash. In an interactive session, hooks also wait for the folder's trust dialog.