Claude Code Hooks Examples: Ten Hooks Worth Copying
Ten Claude Code hooks you can paste into settings.json today: protect paths, deny risky commands, format after edits, run tests before Claude stops, log commands, notify you, load context at session start and more. Each with its JSON, a short script, and what the exit code does.
9 min read
Claude Code hooks are commands you register in a settings.json file that Claude Code runs at fixed points: when a session starts, when you submit a prompt, before and after each tool call, and when Claude finishes responding. Each hook receives the event as JSON on standard input and answers with an exit code, and optionally JSON on standard output. The ten examples below are small, copyable and each does one job: two guards that block edits and commands, a formatter, a test gate, a command log, a notifier, a context loader, a secret check on prompts, a transcript backup and a settings audit. Every settings snippet here parses as JSON; test each script on your own machine before you rely on it.
The events you can hook
Anthropic’s hooks reference (opens in a new tab) lists the events. The ones most people use:
- Per session:
SessionStart(matcherstartup,resume,clear,compactorfork) andSessionEnd. - Per turn:
UserPromptSubmitbefore Claude sees your prompt,Stopwhen Claude finishes responding, andStopFailurewhen a turn ends on an API error. - Per tool call:
PreToolUse, which can block the call,PermissionRequest,PostToolUseafter success andPostToolUseFailureafter a failure. Their matcher filters on the tool name, such asBash,Edit|Writeormcp__github__.*. - Around them:
Notification,PreCompactandPostCompact,SubagentStartandSubagentStop,ConfigChange,CwdChanged,FileChanged, and a few more for tasks, worktrees, model switches and MCP elicitation.
A matcher made only of letters, digits, _, -, spaces, commas and | is an exact name or list of names; anything else is an unanchored JavaScript regular expression, so write ^Edit$ when you mean exactly Edit. Matchers are case-sensitive. For tool events, a handler’s optional if field narrows further with permission-rule syntax, such as Bash(git *).
Where hooks live in settings.json
Hooks go under the hooks key of a settings file (opens in a new tab). ~/.claude/settings.json applies to all your projects, .claude/settings.json is committed with the repository, and .claude/settings.local.json is yours alone. Managed policy settings, plugins, skills and subagents can carry hooks too, and hooks from every level are merged rather than replaced. The shape is always event, then a matcher group, then one or more handlers:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "echo edited", "timeout": 30 }
]
}
]
}
}Type /hooks in a session to see every hook and which file it came from. The menu is read-only; you edit the JSON, and changes are normally picked up without a restart. "disableAllHooks": true turns hooks off without deleting them, though it cannot turn off hooks set in managed settings. Besides command, a handler can be http, mcp_tool, prompt or agent; every example here is a command hook.
Exit codes and JSON output
- Exit 0 is success. On
SessionStartandUserPromptSubmit, plain text on stdout is added to Claude’s context; on most other events it only goes to the debug log. - Exit 2 is a blocking error. On
PreToolUseit blocks the tool call, onUserPromptSubmitit rejects the prompt, and onStopit keeps Claude working. Your stderr becomes the reason. OnPostToolUsethe tool has already run, so Claude simply sees the stderr. - Any other exit code, including 1, is a non-blocking error: the action goes ahead and the transcript shows a hook error notice. If a hook is meant to enforce a policy, exit 2.
- For finer control, exit 0 and print one JSON object.
PreToolUsereturnshookSpecificOutput.permissionDecisionofallow,deny,askordefer;Stop,PostToolUseandUserPromptSubmituse a top-leveldecision: "block"with areason;additionalContextinsidehookSpecificOutputhands Claude a note.
The scripts below use jq, as Anthropic’s hooks guide (opens in a new tab) does. Save each under .claude/hooks/, then run chmod +x on it.
Ten examples
1. Block edits to protected paths (PreToolUse)
Stops Claude from editing .env files, anything under .git/, private keys and a migrations folder. Exit 2 blocks the edit and Claude reads the reason, so it can ask you instead. The file path always arrives absolute; on Windows it arrives with backslashes, which the script turns into forward slashes before matching.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-paths.sh" }
]
}
]
}
}#!/bin/bash
file=$(jq -r '.tool_input.file_path // empty')
file="${file//\\//}" # Windows sends backslashes
case "$file" in
*/.env|*/.env.local|*/.git/*|*.pem|*/db/migrations/*)
echo "Blocked: $file is protected. Ask the user before changing it." >&2
exit 2
;;
esac
exit 02. Deny a dangerous Bash pattern (PreToolUse, JSON)
Refuses force pushes, deleting your home or root folder, and piping a download into a shell. It answers with a JSON deny instead of an exit code, so the reason goes to Claude and nothing else is decided. A deny from a hook holds even in bypassPermissions mode.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/deny-risky-bash.sh" }
]
}
]
}
}#!/bin/bash
cmd=$(jq -r '.tool_input.command // empty')
deny() {
jq -n --arg r "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "deny", permissionDecisionReason: $r}}'
exit 0
}
grep -Eq 'git push.*(--force|-f)( |$)' <<<"$cmd" && deny "Force pushes are not allowed. Push a new branch instead."
grep -Eq 'rm -rf? +(/|~|\$HOME)( |$)' <<<"$cmd" && deny "Refusing to delete the root or home folder."
grep -Eq '(curl|wget)[^|]*\| *(ba)?sh' <<<"$cmd" && deny "Download the script and show it to the user instead of piping it into a shell."
exit 0Pattern checks are easy to word around, so treat this as a second line behind permission rules (opens in a new tab), not a replacement. --force-with-lease is still allowed.
3. Format after every edit (PostToolUse)
Runs a formatter on the file Claude just wrote, chosen by extension. Nothing appears in the conversation when it works; you see it in the file.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh", "timeout": 60 }
]
}
]
}
}#!/bin/bash file=$(jq -r '.tool_input.file_path // empty') case "$file" in *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md) npx prettier --write "$file" >/dev/null 2>&1 ;; *.py) ruff format "$file" >/dev/null 2>&1 ;; *.go) gofmt -w "$file" ;; esac exit 0
4. Log every shell command (PreToolUse, async)
Appends one JSON line per Bash call to a log in your home folder, including calls that are later denied. "async": true runs it in the background so it never slows Claude down. Put it in ~/.claude/settings.json to cover every project.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"async": true,
"command": "jq -c '{time: (now | todate), session: .session_id, cwd: .cwd, command: .tool_input.command}' >> ~/.claude/bash-commands.log"
}
]
}
]
}
}5. Notify you when Claude is waiting (Notification)
Fires when Claude needs a permission answer or has been idle about a minute, and shows a desktop notification on macOS or Linux. On macOS, osascript notifications appear only once Script Editor is allowed to notify in System Settings.
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/notify.sh" }
]
}
]
}
}#!/bin/bash
msg=$(jq -r '.message // "Claude Code needs your attention"')
if command -v osascript >/dev/null; then
osascript -e 'on run argv' \
-e 'display notification (item 1 of argv) with title "Claude Code"' \
-e 'end run' "$msg"
elif command -v notify-send >/dev/null; then
notify-send "Claude Code" "$msg"
fi
exit 06. Load context at session start (SessionStart)
Prints the branch, the count of uncommitted files and the last five commits, and Claude Code adds that text to Claude’s context. The matcher includes compact, so the facts come back after the conversation is summarized. Anthropic advises writing such text as plain facts rather than commands. Keep it fast: it runs on every matching start.
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|compact",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-context.sh" }
]
}
]
}
}#!/bin/bash cd "$CLAUDE_PROJECT_DIR" 2>/dev/null || exit 0 echo "Current branch: $(git branch --show-current 2>/dev/null)" echo "Uncommitted files: $(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')" echo "Last five commits:" git log --oneline -5 2>/dev/null exit 0
7. Run the tests before Claude stops (Stop)
When Claude finishes a turn with uncommitted changes, this runs the test suite and, if it fails, sends Claude back with the last lines of output. The stop_hook_active check lets Claude stop on the second attempt, and Claude Code also ends the turn after stop hooks continue it eight times in a row. Stop fires at the end of every turn, not only when a task is done, so use a fast test command. Recording finished work on a board is a different Stop hook, covered in updating a board when work finishes.
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/test-before-stop.sh", "timeout": 300 }
]
}
]
}
}#!/bin/bash
input=$(cat)
[ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0
cd "$CLAUDE_PROJECT_DIR" || exit 0
[ -z "$(git status --porcelain)" ] && exit 0 # no uncommitted changes
if ! out=$(npm test --silent 2>&1); then
jq -n --arg r "The test suite fails. Fix it before finishing. Last lines:
$(tail -n 30 <<<"$out")" '{decision: "block", reason: $r}'
fi
exit 08. Stop secrets in prompts (UserPromptSubmit)
Rejects a prompt that looks like it contains an AWS access key, a GitHub token or a private key. A blocked prompt is erased from context, and the reason is shown to you, not to Claude.
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/prompt-secrets.sh" }
]
}
]
}
}#!/bin/bash
prompt=$(jq -r '.prompt // empty')
if grep -Eq 'AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|-----BEGIN [A-Z ]*PRIVATE KEY-----' <<<"$prompt"; then
jq -n '{decision: "block", reason: "That prompt looks like it contains a secret. Remove it and send again."}'
fi
exit 09. Back up the transcript before compaction (PreCompact)
Copies the session transcript before Claude Code summarizes it, so the full record survives. The transcript file is written asynchronously and can lag the last few messages, so treat the copy as close to complete, not exact.
{
"hooks": {
"PreCompact": [
{
"matcher": "manual|auto",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/backup-transcript.sh" }
]
}
]
}
}#!/bin/bash input=$(cat) src=$(jq -r '.transcript_path // empty' <<<"$input") trigger=$(jq -r '.trigger // "unknown"' <<<"$input") dir="$HOME/.claude/transcript-backups" mkdir -p "$dir" [ -f "$src" ] && cp "$src" "$dir/$(date +%Y%m%d-%H%M%S)-$trigger.jsonl" exit 0
10. Audit settings changes (ConfigChange)
Logs every change to a settings or skill file during a session, including someone adding a hook to the project’s .claude/settings.json. It can also block a change with exit 2, except for managed policy settings, but a blocked change shows no message to anyone, so logging is the safer default.
{
"hooks": {
"ConfigChange": [
{
"hooks": [
{
"type": "command",
"command": "jq -c '{time: (now | todate), source: .source, file: .file_path}' >> ~/.claude/config-audit.log"
}
]
}
]
}
}Test a hook before you trust it
- Pipe a fake event into the script:
echo '{"tool_input":{"file_path":"/repo/.env"}}' | .claude/hooks/protect-paths.sh; echo $?should print the Blocked line and 2. - Open
/hooksand check the hook sits under the right event and source. - Start
claude --debug-file /tmp/claude.logand trigger the hook; the log shows which hooks matched, their exit codes and output. - Watch for a hook error notice on the first run. A mistyped script path gives exit 127, which is non-blocking, so a guard with a bad path is silently off.
On Windows, command hooks run in Git Bash when it is installed, so these scripts work once jq is on your path. Without Git Bash, hooks run in PowerShell and Claude Code does not register the Bash tool at all, so a hook matching only Bash never fires; match Bash|PowerShell, or set "shell": "powershell" and port the script. More in Claude Code on Windows.
Security: hooks run as you
- Command hooks execute with your full user permissions. They can read, change or delete anything your account can. Read every hook before you add it.
- A repository’s
.claude/settings.jsonis code you run. In an interactive session, hooks wait until you accept the workspace trust dialog; inclaude -pand SDK runs there is no dialog, so committed hooks run at once. Before scripting over a repository you did not write, review.claude/or pass--settings '{"disableAllHooks": true}'. - Quote every variable, reject
..in paths, and skip.envfiles,.git/and keys, as the reference advises. - Hooks tighten, they do not loosen: a hook’s
allowcannot override a deny rule in settings. Keep hard limits in permission rules and use hooks for checks that need a script.
The same ideas exist in other agents; GitHub’s version is in GitHub Copilot hooks. If you track agent work on fenbs, the one hook worth adding beyond these is a reminder to record commits on the board, which the board-update post builds step by step. fenbs still checks the assistant’s role and scopes on every call, whatever a hook says.
Related
What Claude Code can reach without hooks: what Claude Code can read. Other guardrails: AI coding agent security controls. Connecting a board: Claude Code on fenbs and the MCP docs.