Claude Code hooks are shell commands that execute automatically at specific points during a coding session. They give you deterministic control over Claude's behavior — actions that always happen, not actions you hope the LLM will choose to do.
This guide provides 10 production-ready hook implementations you can copy directly into your project. Each includes the shell script, the configuration JSON, and step-by-step setup instructions.
How Hooks Work — The 60-Second Version
Hooks live in your settings files and fire at lifecycle events:
| Event | When it fires | Can block? |
|---|---|---|
SessionStart | Session begins or resumes | No |
UserPromptSubmit | You submit a prompt | Yes |
PreToolUse | Before a tool call executes | Yes |
PostToolUse | After a tool call succeeds | No |
Stop | Claude finishes responding | Yes |
Notification | Claude needs your attention | No |
SessionEnd | Session terminates | No |
Each hook receives JSON input on stdin with context about the event. Your script reads it, takes action, and communicates back via:
- Exit 0 — allow the action (stdout becomes context for some events)
- Exit 2 — block the action (stderr becomes Claude's feedback)
- JSON on stdout — structured control (allow, deny, or escalate)
Where to put hooks
| Location | Scope |
|---|---|
~/.claude/settings.json | All your projects (personal) |
.claude/settings.json | Single project (shared with team via Git) |
.claude/settings.local.json | Single project (personal, gitignored) |
You can also use the /hooks command inside Claude Code to add hooks interactively.
Prerequisites
Most hook scripts use jq for JSON parsing. Install it:
bash# macOS brew install jq # Debian/Ubuntu sudo apt-get install jq
Hook 1: Auto-Format Code After Every Edit
Event: PostToolUse | Matcher: Edit|Write
Automatically run your formatter (Prettier, Biome, Black, gofmt) on every file Claude edits. No more format drift.
Script: .claude/hooks/auto-format.sh
bash#!/bin/bash # Auto-format files after Claude edits them INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then exit 0 fi EXTENSION="${FILE_PATH##*.}" case "$EXTENSION" in ts|tsx|js|jsx|json|css|md|html) npx prettier --write "$FILE_PATH" 2>/dev/null ;; py) black "$FILE_PATH" 2>/dev/null || ruff format "$FILE_PATH" 2>/dev/null ;; go) gofmt -w "$FILE_PATH" 2>/dev/null ;; rs) rustfmt "$FILE_PATH" 2>/dev/null ;; esac exit 0
Configuration
Add to .claude/settings.json in your project root:
json{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/auto-format.sh" } ] } ] } }
Setup
bashmkdir -p .claude/hooks # Save the script above to .claude/hooks/auto-format.sh chmod +x .claude/hooks/auto-format.sh
Hook 2: Block Destructive Commands
Event: PreToolUse | Matcher: Bash
Prevent Claude from running dangerous shell commands: rm -rf, git push --force, DROP TABLE, docker system prune, etc. Claude receives feedback explaining why the command was blocked.
Script: .claude/hooks/block-dangerous.sh
bash#!/bin/bash # Block destructive commands before they execute INPUT=$(cat) COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') BLOCKED_PATTERNS=( "rm -rf /" "rm -rf ~" "rm -rf \." "git push.*--force" "git push.*-f" "git reset --hard" "git clean -fd" "DROP TABLE" "DROP DATABASE" "TRUNCATE" "docker system prune" ":(){ :|:& };:" "mkfs\." "dd if=" "> /dev/sda" "chmod -R 777 /" "curl.*| bash" "wget.*| bash" ) for pattern in "${BLOCKED_PATTERNS[@]}"; do if echo "$COMMAND" | grep -qiE "$pattern"; then echo "Blocked: command matches dangerous pattern. Rephrase the command to be more targeted." >&2 exit 2 fi done exit 0
Configuration
Add to .claude/settings.json:
json{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh" } ] } ] } }
Hook 3: Desktop Notifications
Event: Notification | Matcher: *
Get a native desktop notification when Claude finishes working or needs your input. Essential for long-running tasks where you switch to another window.
Configuration (no script needed)
Add to ~/.claude/settings.json (user-level, applies to all projects):
macOS:
json{ "hooks": { "Notification": [ { "matcher": "", "hooks": [ { "type": "command", "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\" sound name \"Ping\"'" } ] } ] } }
Linux:
json{ "hooks": { "Notification": [ { "matcher": "", "hooks": [ { "type": "command", "command": "notify-send 'Claude Code' 'Claude Code needs your attention' --urgency=normal" } ] } ] } }
Hook 4: Run Tests Before Claude Stops
Event: Stop
The most impactful hook: run your test suite every time Claude finishes a response. If tests fail, Claude receives the error output and continues working to fix the issue — without you having to say "the tests are failing."
Script: .claude/hooks/stop-verify.sh
bash#!/bin/bash # Run tests when Claude finishes — if they fail, Claude keeps working INPUT=$(cat) # Prevent infinite loops: if this is a Stop hook re-run, let Claude stop STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false') if [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi echo "=== Running tests ===" >&2 # Run your test command — adjust for your project TEST_OUTPUT=$(pnpm test 2>&1) TEST_EXIT=$? if [ $TEST_EXIT -ne 0 ]; then echo "Tests failed. Fix the failing tests before finishing." >&2 echo "" >&2 # Show only the last 50 lines to keep feedback focused echo "$TEST_OUTPUT" | tail -50 >&2 exit 2 # Block Stop — Claude continues working fi echo "All tests passed." >&2 exit 0 # Allow Claude to stop
Configuration
Add to .claude/settings.json:
json{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-verify.sh", "timeout": 120 } ] } ] } }
Hook 5: Protect Sensitive Files
Event: PreToolUse | Matcher: Edit|Write
Block Claude from modifying sensitive files: .env, lockfiles, migration files, generated code, or anything you want to protect.
Script: .claude/hooks/protect-files.sh
bash#!/bin/bash # Protect specific files and directories from AI modification INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') if [ -z "$FILE_PATH" ]; then exit 0 fi # Add your protected patterns here PROTECTED_PATTERNS=( ".env" ".env.local" ".env.production" "package-lock.json" "pnpm-lock.yaml" "yarn.lock" ".git/" "node_modules/" "dist/" "build/" ".next/" ) for pattern in "${PROTECTED_PATTERNS[@]}"; do if [[ "$FILE_PATH" == *"$pattern"* ]]; then echo "Blocked: '$FILE_PATH' is a protected file (matches '$pattern'). Do not modify this file." >&2 exit 2 fi done exit 0
Configuration
Add to .claude/settings.json:
json{ "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh" } ] } ] } }
Hook 6: Inject Git Context on Session Start
Event: SessionStart | Matcher: startup
Automatically load recent commits, current branch, and uncommitted changes into Claude's context at the start of every session. Claude starts with awareness of your recent work.
Script: .claude/hooks/git-context.sh
bash#!/bin/bash # Inject git context at session start if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then exit 0 fi BRANCH=$(git branch --show-current 2>/dev/null) RECENT_COMMITS=$(git log --oneline -10 2>/dev/null) UNCOMMITTED=$(git diff --stat 2>/dev/null) STAGED=$(git diff --cached --stat 2>/dev/null) STASH_COUNT=$(git stash list 2>/dev/null | wc -l | tr -d ' ') echo "## Git Context" echo "**Branch:** $BRANCH" echo "**Stashes:** $STASH_COUNT" echo "" echo "### Recent commits (last 10):" echo "$RECENT_COMMITS" echo "" if [ -n "$UNCOMMITTED" ]; then echo "### Uncommitted changes:" echo "$UNCOMMITTED" echo "" fi if [ -n "$STAGED" ]; then echo "### Staged changes:" echo "$STAGED" echo "" fi exit 0
Configuration
Add to .claude/settings.json:
json{ "hooks": { "SessionStart": [ { "matcher": "startup", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/git-context.sh" } ] } ] } }
Hook 7: Log Every Bash Command
Event: PostToolUse | Matcher: Bash
Maintain an audit trail of every shell command Claude executes. Useful for security reviews, debugging, and understanding what Claude did during a long session.
Script: .claude/hooks/log-commands.sh
bash#!/bin/bash # Log every Bash command Claude runs INPUT=$(cat) COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") CWD=$(echo "$INPUT" | jq -r '.cwd // "unknown"') LOG_DIR="$HOME/.claude/logs" mkdir -p "$LOG_DIR" echo "[$TIMESTAMP] session=$SESSION_ID cwd=$CWD cmd=$COMMAND" >> "$LOG_DIR/commands.log" exit 0
Configuration
Add to ~/.claude/settings.json (user-level — logs commands across all projects):
json{ "hooks": { "PostToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "~/.claude/hooks/log-commands.sh" } ] } ] } }
Hook 8: Re-Inject Context After Compaction
Event: SessionStart | Matcher: compact
When Claude's context window fills up, compaction summarizes the conversation to free space. This can lose important details. This hook re-injects critical context after every compaction.
Script: .claude/hooks/post-compact.sh
bash#!/bin/bash # Re-inject critical context after compaction # Start with your most important reminders echo "## Post-Compaction Reminders" echo "- Always run tests before committing" echo "- Use pnpm, not npm or yarn" echo "- Never modify files in /migrations without explicit instruction" echo "- Follow the existing code patterns — do not introduce new abstractions" # Inject recent git context so Claude remembers what you were working on if git rev-parse --is-inside-work-tree > /dev/null 2>&1; then echo "" echo "### Current git state:" echo "Branch: $(git branch --show-current)" echo "" echo "Recent commits:" git log --oneline -5 echo "" DIFF=$(git diff --stat) if [ -n "$DIFF" ]; then echo "Uncommitted changes:" echo "$DIFF" fi fi exit 0
Configuration
Add to .claude/settings.json:
json{ "hooks": { "SessionStart": [ { "matcher": "compact", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-compact.sh" } ] } ] } }
Hook 9: Lint on Every File Edit
Event: PostToolUse | Matcher: Edit|Write
Run your linter on every file Claude touches and feed the results back. Unlike formatting (Hook 1), this hook reports lint warnings to Claude so it can fix issues immediately.
Script: .claude/hooks/lint-check.sh
bash#!/bin/bash # Lint files after edits and report issues to Claude INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then exit 0 fi EXTENSION="${FILE_PATH##*.}" LINT_OUTPUT="" LINT_EXIT=0 case "$EXTENSION" in ts|tsx|js|jsx) # Try Biome first, then ESLint LINT_OUTPUT=$(npx biome check "$FILE_PATH" 2>&1) LINT_EXIT=$? if [ $LINT_EXIT -ne 0 ] && ! command -v biome &>/dev/null; then LINT_OUTPUT=$(npx eslint "$FILE_PATH" --no-error-on-unmatched-pattern 2>&1) LINT_EXIT=$? fi ;; py) LINT_OUTPUT=$(ruff check "$FILE_PATH" 2>&1) LINT_EXIT=$? ;; go) LINT_OUTPUT=$(golangci-lint run "$FILE_PATH" 2>&1) LINT_EXIT=$? ;; esac if [ $LINT_EXIT -ne 0 ] && [ -n "$LINT_OUTPUT" ]; then echo "Lint issues found in $FILE_PATH:" >&2 echo "$LINT_OUTPUT" | head -30 >&2 echo "" >&2 echo "Please fix these lint issues." >&2 fi # Always exit 0 — the edit already happened, we are just providing feedback exit 0
Configuration
Add to .claude/settings.json:
json{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/lint-check.sh" } ] } ] } }
Hook 10: Auto-Verify With an Agent Hook
Event: Stop | Type: agent
Instead of a shell script, use an agent hook that spawns a subagent with tool access to verify your code. The agent can read files, run commands, and make informed decisions about whether Claude should keep working.
Configuration
Add to .claude/settings.json:
json{ "hooks": { "Stop": [ { "hooks": [ { "type": "agent", "prompt": "Check if the changes Claude just made are complete and correct. Run the test suite with 'pnpm test'. If any tests fail, respond with {\"ok\": false, \"reason\": \"description of failures\"}. If all tests pass, respond with {\"ok\": true}. Also check if there are any lint errors with 'pnpm lint'. $ARGUMENTS", "timeout": 120 } ] } ] } }
Agent hooks are more powerful than shell script hooks because the agent can:
- Run multiple commands and check results sequentially
- Read files to verify changes look correct
- Search the codebase for related issues
- Make judgment calls about whether something is done
The tradeoff is they are slower (30-60 seconds vs. instant) and cost API credits. Use them for critical verification steps, not routine formatting.
Combining Multiple Hooks
You can use multiple hooks for the same event. They run in parallel. Here is a complete configuration that combines several hooks from this guide:
json{ "hooks": { "SessionStart": [ { "matcher": "startup", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/git-context.sh" } ] }, { "matcher": "compact", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-compact.sh" } ] } ], "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh" } ] }, { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh" } ] } ], "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/auto-format.sh" } ] }, { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/log-commands.sh" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-verify.sh", "timeout": 120 } ] } ], "Notification": [ { "matcher": "", "hooks": [ { "type": "command", "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\" sound name \"Ping\"'" } ] } ] } }
Troubleshooting
Hook not firing
- Run
/hooksin Claude Code to verify the hook appears under the correct event - Check that the matcher pattern matches exactly (matchers are case-sensitive regex)
- Verify the script is executable:
chmod +x .claude/hooks/my-script.sh
JSON validation failed
If your shell profile (~/.zshrc) prints text on startup, it can corrupt hook JSON output. Wrap echo statements in an interactive check:
bash# In ~/.zshrc if [[ $- == *i* ]]; then echo "Shell ready" # Only prints in interactive shells fi
Stop hook runs forever
Always check stop_hook_active at the top of your Stop hook scripts to prevent infinite loops:
bashINPUT=$(cat) if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then exit 0 # Let Claude stop fi
Debugging
- Toggle verbose mode with
Ctrl+Oto see hook output in the transcript - Run
claude --debugfor full execution details - Test scripts manually:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
Quick Reference
| Hook | Event | Matcher | Purpose |
|---|---|---|---|
| Auto-format | PostToolUse | `Edit | Write` |
| Block dangerous | PreToolUse | Bash | Prevent destructive commands |
| Notifications | Notification | * | Desktop alerts when Claude needs input |
| Test on Stop | Stop | — | Run tests before Claude finishes |
| Protect files | PreToolUse | `Edit | Write` |
| Git context | SessionStart | startup | Load recent git state into context |
| Log commands | PostToolUse | Bash | Audit trail of all commands |
| Post-compact | SessionStart | compact | Re-inject context after compaction |
| Lint check | PostToolUse | `Edit | Write` |
| Agent verify | Stop | — | AI-powered test + lint verification |
All scripts are available as a starter kit. Create the .claude/hooks/ directory in your project and add the scripts you need. Start with hooks 2 (block dangerous), 3 (notifications), and 4 (test on stop) — they provide the highest immediate value.