← All posts

Claude Code Hooks: Setup Guide With 5 Working Examples

Hooks run your own scripts at fixed points in a Claude Code session, so rules hold even when the model forgets them. Five working configs, plus how to debug them.

November 10, 2026 8 min read Guide
Quick answer

A hook is a command Claude Code runs at a set moment in a session, such as before a tool call (PreToolUse) or after a file edit (PostToolUse). You define hooks in a hooks block in .claude/settings.json or ~/.claude/settings.json. Your script reads JSON on stdin. Exit code 0 lets the action continue. Exit code 2 blocks it and sends your stderr message back to Claude. Type /hooks in a session to confirm Claude Code loaded your hook.

What are Claude Code hooks and when do they run?

A line in CLAUDE.md such as "always run Prettier" is a request. Claude usually follows it, and sometimes does not. A hook is different: Claude Code itself runs your command, so it happens every time. The official hooks guide describes this as deterministic control instead of relying on the model to choose.

The reference lists over thirty events. These six cover most real setups:

EventFiresCan it block?
PreToolUseBefore a tool call runsYes
PostToolUseAfter a tool call succeedsNo, the tool already ran
UserPromptSubmitWhen you submit a promptYes
NotificationWhen Claude waits for permission or inputNo
StopWhen Claude finishes respondingYes, it makes Claude keep working
SessionStartWhen a session starts, resumes or compactsNo

Hooks come in five handler types: command, http, mcp_tool, prompt and agent. This guide uses command hooks, the simplest to write and test.

Where do I put hook configuration?

The scope depends on the file you pick.

FileScopeShared with the team?
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonOne projectYes, commit it
.claude/settings.local.jsonOne project, only youNo

The structure always has three levels: event, then a matcher group, then one or more handlers. Every hook below follows this shape. If your file already has a hooks key, add new events as siblings inside it. Do not replace the whole object.

How do I get a desktop notification when Claude needs me?

This is the quickest first hook. It uses the Notification event with an empty matcher, which fires for every notification type. Add it to ~/.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 use notify-send 'Claude Code' 'Claude Code needs your attention'. To limit it, set the matcher to permission_prompt or idle_prompt. On macOS, if nothing appears, allow notifications for Script Editor in System Settings.

How do I auto-format files after every edit?

Use PostToolUse with an Edit|Write matcher so the hook only runs after file-editing tools. The script extracts the path with jq and hands it to Prettier. Put this in the project's .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}

You need jq installed (brew install jq on macOS). To test it, ask Claude to add a line with single-quoted strings to a JavaScript file. With default Prettier settings the hook rewrites them to double quotes. When a hook succeeds, Claude Code shows nothing, so check the file, not the chat.

How do I block edits to protected files?

Save this as .claude/hooks/protect-files.sh. It reads the target path from stdin and exits with code 2 if the path matches a protected pattern:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

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

Make it executable with chmod +x .claude/hooks/protect-files.sh, then register it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

Claude receives the stderr text as feedback, so it knows why the edit failed and can try another approach. I ran this script against sample input: a path ending in .env exits with 2, and src/a.ts exits with 0. The pattern .env also matches names such as .environment, so tighten it if that matters.

How do I deny a dangerous shell command?

A hook can also deny with JSON instead of an exit code. Print one object to stdout and exit 0:

#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')

if echo "$COMMAND" | grep -q 'rm -rf'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Destructive command blocked by hook"
    }
  }'
else
  exit 0  # no decision, normal permission flow applies
fi

Register it on the Bash matcher. To avoid spawning the script for every shell command, add an if field that uses permission rule syntax, for example "if": "Bash(rm *)". The docs call this filter best-effort, so treat it as an optimization, not a guarantee.

The decision field has four values: allow, deny, ask and defer. Be careful with allow. It skips the interactive prompt, but deny and ask rules from your settings still apply.

How do I keep project rules after context compaction?

Compaction summarizes the conversation and can drop details. A SessionStart hook with the compact matcher re-injects them. Whatever the command prints to stdout is added to Claude's context:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Reminder: use Bun, not npm. Run bun test before committing.'"
          }
        ]
      }
    ]
  }
}

Swap the echo for git log --oneline -5 if you want recent commits instead. For instructions that should load on every session start, use CLAUDE.md, as the docs recommend.

What do exit codes and JSON output actually do?

ResultEffect
Exit 0Action continues. Stdout JSON is applied. On PreToolUse, exit 0 does not approve the call.
Exit 2Blocking error on events that can block. Stderr is the reason.
Any other codeNon-blocking error. The action proceeds and a hook error notice appears.

Two traps catch most people. First, exit 1 does not block anything. If a hook enforces a policy, it must exit 2. Second, pick one style per hook: exit 2 with stderr, or exit 0 with JSON. Exit 2 with valid JSON still blocks, and a JSON allow cannot override it.

For judgment calls there is a model-based option. A prompt hook on Stop asks a Claude model for {"ok": true} or {"ok": false, "reason": "..."}. On false, Claude keeps working and uses your reason as its next instruction.

Why is my hook not firing?

  1. Run /hooks. Confirm the hook appears under the right event. Settings edits normally reload on their own, but restart the session if the menu stays empty.
  2. Check the matcher. Matchers are case-sensitive. Edit|Write matches those two tool names exactly. Claude can also change files through Bash, which this matcher misses. For full coverage use a Stop hook that scans the working tree.
  3. Pipe sample input by hand. echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?
  4. Make the script executable and use $CLAUDE_PROJECT_DIR or an absolute path.
  5. Read the log. Start with claude --debug-file /tmp/claude.log and tail it. Press Ctrl+O to see hook results in the transcript view.

Two quieter failures. An unconditional echo in ~/.zshrc or ~/.bashrc can print text ahead of your JSON, so Claude Code reads the output as plain text and ignores the decision. And permissionDecision must sit inside hookSpecificOutput: at the top level the JSON parses, but the field is silently ignored. A Stop hook is also overridden after eight consecutive blocks, so check stop_hook_active and exit 0 when it is true.

Should I use a hook or a permission rule?

Use permission rules for plain allow and deny decisions by tool and pattern. Use a hook when you need logic: inspect the full command, check a file, call a service. One difference matters for safety. A PreToolUse hook returning deny holds in every permission mode, including bypassPermissions, because hooks fire before the mode check. Hooks also cannot loosen your rules: a hook cannot override a deny rule from settings.

For a related setup, see our guide to Claude Code permissions, and for scripted use see headless mode and CI.

FAQ

Can a hook change what Claude does, or only block it?

Both. Plain stdout from UserPromptSubmit and SessionStart hooks is added to Claude's context, and additionalContext inside hookSpecificOutput injects text on UserPromptSubmit. PostToolUse hooks cannot undo anything, because the tool already ran.

How do I turn hooks off temporarily?

Set "disableAllHooks": true in a settings file, or start a session with --settings '{"disableAllHooks": true}'. You cannot disable a single hook and keep it in the config, and managed policy hooks still run unless disabled at the managed level.

How long can a hook run?

Command hooks time out after 600 seconds by default, prompt hooks after 30. UserPromptSubmit command hooks default to 30. Override with the timeout field, in seconds.

Do hooks work in claude -p runs?

Yes. Without --bare, a claude -p run loads the hooks in the project's .claude/settings.json. Add --bare to skip them in CI.

Sources

All commands, events, exit codes and examples were checked against the official docs on November 10, 2026: Automate actions with hooks, Hooks reference and Run Claude Code programmatically. The two shell scripts were run locally against sample JSON input.

See every current command, flag, and setting in one reference.

Open the Claude Code Cheatsheet →