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 (matcher startup, resume, clear, compact or fork) and SessionEnd.
  • Per turn: UserPromptSubmit before Claude sees your prompt, Stop when Claude finishes responding, and StopFailure when a turn ends on an API error.
  • Per tool call: PreToolUse, which can block the call, PermissionRequest, PostToolUse after success and PostToolUseFailure after a failure. Their matcher filters on the tool name, such as Bash, Edit|Write or mcp__github__.*.
  • Around them: Notification, PreCompact and PostCompact, SubagentStart and SubagentStop, 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:

settings.json: the shape
{
  "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 SessionStart and UserPromptSubmit, 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 PreToolUse it blocks the tool call, on UserPromptSubmit it rejects the prompt, and on Stop it keeps Claude working. Your stderr becomes the reason. On PostToolUse the 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. PreToolUse returns hookSpecificOutput.permissionDecision of allow, deny, ask or defer; Stop, PostToolUse and UserPromptSubmit use a top-level decision: "block" with a reason; additionalContext inside hookSpecificOutput hands 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.

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-paths.sh" }
        ]
      }
    ]
  }
}
.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 0

2. 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.

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/deny-risky-bash.sh" }
        ]
      }
    ]
  }
}
.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 0

Pattern 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.

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh", "timeout": 60 }
        ]
      }
    ]
  }
}
.claude/hooks/format.sh
#!/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.

~/.claude/settings.json
{
  "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.

~/.claude/settings.json
{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/notify.sh" }
        ]
      }
    ]
  }
}
~/.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 0

6. 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.

.claude/settings.json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume|compact",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-context.sh" }
        ]
      }
    ]
  }
}
.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.

.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/test-before-stop.sh", "timeout": 300 }
        ]
      }
    ]
  }
}
.claude/hooks/test-before-stop.sh
#!/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 0

8. 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.

~/.claude/settings.json
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/prompt-secrets.sh" }
        ]
      }
    ]
  }
}
~/.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 0

9. 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.

~/.claude/settings.json
{
  "hooks": {
    "PreCompact": [
      {
        "matcher": "manual|auto",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/backup-transcript.sh" }
        ]
      }
    ]
  }
}
~/.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.

~/.claude/settings.json
{
  "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

  1. 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.
  2. Open /hooks and check the hook sits under the right event and source.
  3. Start claude --debug-file /tmp/claude.log and trigger the hook; the log shows which hooks matched, their exit codes and output.
  4. 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.json is code you run. In an interactive session, hooks wait until you accept the workspace trust dialog; in claude -p and 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 .env files, .git/ and keys, as the reference advises.
  • Hooks tighten, they do not loosen: a hook’s allow cannot 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.

Questions people ask.

Where do I put Claude Code hooks?

Under the hooks key of a settings file: ~/.claude/settings.json for every project, .claude/settings.json to commit with the repository, or .claude/settings.local.json for yourself only. Plugins, skills and subagents can also carry hooks.

How do I block a tool call with a hook?

Use a PreToolUse hook with a matcher for the tool. Either exit with code 2 and write the reason to stderr, or exit 0 and print JSON with hookSpecificOutput.permissionDecision set to deny and a permissionDecisionReason. Exit code 1 does not block.

Why does my Stop hook keep Claude running forever?

Check stop_hook_active in the input and exit 0 when it is true, so Claude can stop after one retry. Claude Code also ends the turn after stop hooks have continued it eight times in a row.

Do Claude Code hooks work on Windows?

Yes. Command hooks run in Git Bash when it is installed, so bash scripts work with jq on the path. Without Git Bash they run in PowerShell, and hooks that match only the Bash tool never fire because the Bash tool is not registered.

Start with one thing.

There is nothing to set up first. Write one line and you’ve started.