A Claude Code hook is a command Claude Code runs at a fixed lifecycle point, so a ping, a formatter, or a block happens whether or not the model chooses it. The documented notification is a Notification command in ~/.claude/settings.json: osascript on macOS, notify-send on Linux, and a PowerShell MessageBox on Windows. Confirm it in the read-only /hooks menu. That file is this machine only, and cloud sessions do not read it. A shared Prettier hook belongs in the project’s .claude/settings.json on PostToolUse with matcher Edit|Write; a successful run shows nothing in the chat, and it does not see a file a Bash command rewrites. To stop an edit, Anthropic’s protect-files.sh writes the reason to stderr and exits 2 before Edit or Write. To stop a destructive shell command, block-rm.sh exits 0 and prints permissionDecision deny. Exit 2 blocks PreToolUse even if JSON says allow. Exit 0 alone does not approve the call. PermissionRequest and Notification ignore exit 2. A command hook that cannot start, or a command, HTTP, or MCP tool hook that times out on PreToolUse, leaves the tool call in the normal permission flow, and the command itself runs with your user account’s file permissions.

A hook runs whether or not the model chooses it

A Claude Code hook is a shell command attached to a moment in Claude Code’s lifecycle. Claude Code runs that command every time the moment arrives, so the notification, the formatter, or the block happens whether or not the model chooses to run it. Anthropic’s hooks guide is the recipe book. The hooks reference holds the event schemas, the exit-code table, and the JSON fields.

Five handler types exist: command, http, mcp_tool, prompt, and agent. The three recipes below use command. Prompt hooks send the hook input to a Claude model — Haiku by default in the guide — when the decision needs judgment. Agent hooks are experimental, may change, and the guide says production workflows should prefer command hooks.

Anthropic also links a Bash command validator on GitHub as a complete reference implementation.

Automate actions with hooks

Hooks reference

Bash command validator example

Start with the documented desktop notification

Open ~/.claude/settings.json and add a Notification hook. Create the file if it is missing. Anthropic’s first example is an osascript display notification on macOS, with an empty matcher and “type”: “command”.

If a hooks key is already in the file, add Notification beside the other event keys. Each event name is one key inside that single hooks object. Replacing the object removes every hook already configured in that file.

Type /hooks to confirm the registration. The menu lists each event and a count. Selecting the hook shows the matcher, type, source file, and command. The menu is read-only. Adding, changing, or removing a hook means editing the JSON, or asking Claude to edit it.

To try it, press Esc, then Shift+Tab until the status bar shows manual mode on. Ask for something that needs permission, then leave the terminal. On macOS, osascript goes through Script Editor. If Script Editor has no notification permission, the command fails silently and macOS will not prompt you to grant it. Run osascript -e ‘display notification “test”’ once, then allow notifications for Script Editor under System Settings.

~/.claude/settings.json

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

Linux and Windows use the same Notification event

The same Notification hook, empty matcher, and command type go in ~/.claude/settings.json on Linux and Windows. Only the command string changes. Anthropic’s Linux command is notify-send. notify-send needs a desktop notification daemon, which headless servers, SSH sessions, and most containers do not have. Test with notify-send ‘Claude Code’ ‘test’. If the command is missing on Debian or Ubuntu, the package is libnotify-bin.

Anthropic’s Windows command opens a PowerShell MessageBox. That is a dialog, so it can sit behind the terminal. Test the command in PowerShell first. Inside WSL, powershell.exe must be on PATH through Windows interop. A Notification hook cannot block: its exit code and stderr are ignored.

Anthropic’s Notification command for each platform. Put the command in the same command field as the macOS example.

| Platform | Command |

| — | — |

| macOS | osascript -e ‘display notification “Claude Code needs your attention” with title “Claude Code”’ |

| Linux | notify-send ‘Claude Code’ ‘Claude Code needs your attention’ |

| Windows | powershell.exe -Command “[System.Reflection.Assembly]::LoadWithPartialName(‘System.Windows.Forms’); [System.Windows.Forms.MessageBox]::Show(‘Claude Code needs your attention’, ‘Claude Code’)” |

Match the notification you actually want

An empty matcher fires for every notification type. permission_prompt fires when Claude needs approval for a tool or for a sandboxed command’s network request. idle_prompt fires about 60 seconds after Claude finishes responding if you have not typed. Elicitation dialogs use the same about-six-second gate as permission_prompt.

In a terminal, the permission_prompt timer starts when the prompt appears and waits about six seconds. Each keystroke defers it. In Claude Desktop, the VS Code extension, and other hosts that answer permission requests through the Agent SDK, permission_prompt is about six seconds after the ask, and typing does not defer it. If you answer sooner, or a PermissionRequest hook answers sooner, that Notification hook does not run. For the permission moment itself, use PermissionRequest.

agent_needs_input fires when a background session starts waiting on your input while agent view is open, or when the current session asks a teammate’s terminal-setup question and you have not typed for about six seconds. agent_completed fires when a background session finishes or fails, and only while agent view is open. quota_auto_resume_fired fires when Claude Code continues a task after a claude.ai usage limit paused it, at the reset or sooner when something you do during the wait makes usage available again. quota_auto_resume_stale fires when that limit reset while the computer slept for more than about 30 minutes; Claude Code waits for Enter. After a shorter sleep it continues and fires quota_auto_resume_fired instead. quota_auto_resume_disabled fires when Claude Code ends the wait without continuing the task. It does not fire when you press Esc or Ctrl+C, or pick Don’t continue automatically.

agent_needs_input and agent_completed require Claude Code v2.1.198 or later. The teammate terminal-setup case of agent_needs_input requires v2.1.248 or later. The three quota_auto_resume matchers require v2.1.234 or later. In a terminal, permission_prompt for a sandboxed command’s network request requires v2.1.246 or later.

Put a personal ping in the user file and a shared hook in the project file

~/.claude/settings.json applies to every project on this machine and is not shareable. .claude/settings.json applies to one project and can be committed. .claude/settings.local.json applies to one project and is gitignored when Claude Code saves a setting there. Hook entries merge across user, project, and local settings: each file adds its own hooks. A project formatter does not remove a user notification hook. The same handler defined in more than one settings file runs once. A plugin or skill copy of that handler stays separate. Replacing the hooks object inside one file still drops the other events configured in that file.

Cloud sessions do not read your local ~/.claude/settings.json, so a personal notification hook in that file stays on this machine.

Command hooks run with your user account’s permissions. They can modify, delete, or access any file that account can access. Review and test a command before you commit it in a shared project file. In an interactive session, Claude Code holds hooks from every settings file, including ~/.claude/settings.json, until the workspace trust dialog is accepted. In a claude -p or SDK session, Claude Code treats the folder as trusted, so hooks committed in .claude/settings.json run.

Where Anthropic says a hook setting applies.

| File | Scope | Shareable |

| — | — | — |

| ~/.claude/settings.json | Every project on this machine | No |

| .claude/settings.json | One project | Yes, can be committed |

| .claude/settings.local.json | One project | No, gitignored when Claude Code saves a setting there |

Format a file after Edit or Write

Anthropic’s format-on-edit recipe is a PostToolUse hook with matcher Edit|Write in the project’s .claude/settings.json. The command reads tool_input.file_path with jq and passes it to npx prettier –write. Add PostToolUse beside any event keys already in that file.

The Bash examples on the hooks guide use jq. Install it with brew install jq on macOS, or apt-get install jq on Debian and Ubuntu. When the hook succeeds, Claude Code shows nothing in the conversation. Anthropic’s check is to ask Claude to add a line of single-quoted strings to a JavaScript file, then open the file: with Prettier’s default settings, the hook rewrites them to double quotes.

PostToolUse runs after the tool has succeeded, so it cannot undo the edit. Edit|Write also misses a file that a Bash command rewrites. Anthropic points to a FileChanged hook when a specific file should be reformatted no matter what wrote it. A FileChanged matcher such as .envrc|.env is a list of literal filenames, split on |, and FileChanged cannot block the write. For every change in a turn, the guide points to a Stop scan, or to a Bash|PowerShell matcher whose script lists modified and untracked files with git status –porcelain. On Windows, where the PowerShell tool is the primary shell, a hook that matches only Bash never fires.

.claude/settings.json

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

Block an edit with exit code 2

Anthropic’s protect-files example is a PreToolUse hook on Edit|Write. The script reads tool_input.file_path, treats .env, package-lock.json, and .git/ as protected substrings, writes a Blocked message to stderr, and exits 2. Claude Code then blocks the Edit or Write and passes that message to Claude. Exit 0 at the end of the script reports no decision, so the normal permission flow still applies. It does not approve the edit.

On Windows, Write, Edit, and Read deliver file_path with backslashes. The script’s replacement turns those into forward slashes before the pattern check. A forward-slash comparison against a backslash path would otherwise let the edit through.

Save the script as .claude/hooks/protect-files.sh. On macOS and Linux, run chmod +x .claude/hooks/protect-files.sh. Register it from the project .claude/settings.json with the path quoted, because this command is shell form. Anthropic’s test is to ask Claude to add a comment to .env. The edit is blocked before it runs.

Exit 2 on PreToolUse still blocks if the script also prints JSON that says allow. Use stderr plus exit 2, or exit 0 plus JSON, and pick one style per hook.

.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 the file guard before Edit or Write

Add this PreToolUse group to the project’s .claude/settings.json, beside any event keys already there. $CLAUDE_PROJECT_DIR is the project root where the session started. The quotes matter in shell form so a path with spaces survives.

A command hook that cannot start — a missing file or a script that is not executable — does not block the edit. The shell exits with a code such as 127, the transcript shows a non-blocking hook error, and the tool call proceeds. A command, HTTP, or MCP tool hook that reaches its timeout on PreToolUse also leaves the call in the normal permission flow. Those hooks default to 600 seconds. A hard block still belongs in the permission system, because a guard that fails to start or times out leaves the gate open. An Agent SDK callback hook that exceeds its timeout on PreToolUse is the exception the reference states: that callback blocks the tool call.

.claude/settings.json

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

Deny a destructive shell command with JSON

To stop a command before it runs, use PreToolUse on Bash. Anthropic’s reference example saves block-rm.sh in the project, makes it executable with chmod +x, and registers it with an if rule of Bash(rm *). The script prints a permissionDecision of deny when the command contains rm -rf. Otherwise it exits 0, which leaves the normal permission flow in place and does not approve the command.

The if field holds one permission rule and is best-effort. When Claude Code cannot tell which commands a Bash input runs, it runs the hook regardless of the pattern. The reference says to use the permission system, rather than a hook, for a hard allow or deny. A permissionDecision of deny that the hook does return still blocks the tool in bypassPermissions mode and with –dangerously-skip-permissions. A decision of allow does not loosen deny rules from settings.

On PreToolUse, deny cancels the call and sends permissionDecisionReason to Claude, ask shows the normal prompt, and allow skips the interactive prompt while deny and ask rules still apply. defer exists for non-interactive claude -p runs. Sibling PreToolUse hooks still run after one of them returns deny. Merged decisions use the order deny, defer, ask, allow.

This registration sets args to an empty array, which is exec form: Claude Code spawns the script directly, with no shell. Omit args when the command needs a pipe or &&, as the Prettier hook does. On Windows, where PowerShell is the primary shell, match Bash|PowerShell as well. A Bash-only hook never fires there. The reference includes a separate PowerShell handler for Remove-Item.

.claude/settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

The script that prints deny

Save this as .claude/hooks/block-rm.sh and chmod +x it on macOS and Linux. It uses jq, which must be on PATH. For rm -rf it prints JSON and exits 0, so the JSON is what denies the call. For a safer command such as rm file.txt it exits 0 with no output. Stay on exit 0 for this style. Adding exit 2 would block even if a later edit of the JSON said allow.

PreToolUse also skips EndConversation, and it does not see files you reference with @. Claude Code inserts those contents while building the prompt, so a Read hook never fires for them.

.claude/hooks/block-rm.sh

#!/bin/bash
# .claude/hooks/block-rm.sh
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
fi

Exit codes differ by event

Exit 0 means the hook reports no objection through its exit code. On PreToolUse that leaves the normal permission flow in place. Exit 0 is also the exit to use when the hook prints a JSON decision. Exit 2 blocks events that can block, and the reason is the JSON decision’s reason when that decision blocks, otherwise stderr. PermissionRequest ignores exit 2; a deny has to come from hookSpecificOutput.decision.behavior. A Notification hook ignores its exit code and stderr. PostToolUse cannot undo a tool that already ran; exit 2 there shows stderr to Claude after the fact.

For most events, exit 2 is the only exit code that blocks by itself. Without valid decision JSON, exit 1 is a non-blocking error and the action proceeds. WorktreeCreate is an exception: any nonzero exit fails worktree creation. If you mix exit 2 with JSON on an event that can block, exit 2 still blocks, and a permissionDecision of allow cannot override it. Before Claude Code v2.1.214, exit 2 combined with stdout JSON that failed schema validation did not block. The current reference says that combination still blocks, and Claude Code uses stderr as the reason.

If a shell profile prints text before the JSON, the stdout no longer starts with {, Claude Code treats it as plain text, and on exit 0 the decision is ignored. The guide’s fix is to echo only in interactive shells. A command hook that cannot start, and a command, HTTP, or MCP tool hook that times out on PreToolUse, leaves the tool call in the normal permission flow.

  • Exit 0 on PreToolUse leaves the normal permission flow in place.
  • Exit 0 with JSON is structured control: permissionDecision on PreToolUse, decision.behavior on PermissionRequest, or top-level decision block on events such as Stop and PostToolUse.
  • Exit 2 blocks PreToolUse even when stdout JSON says allow.
  • Exit 2 on PostToolUse shows stderr to Claude after the tool has already run.
  • PermissionRequest and Notification ignore exit 2.
  • Exit 1 without valid decision JSON does not block.

What Anthropic’s hooks reference says exit 2 and JSON do for the events in these recipes.

| Event | What exit 2 does | JSON field that decides |

| — | — | — |

| PreToolUse | Blocks the tool call | hookSpecificOutput.permissionDecision: allow, deny, ask, or defer |

| PostToolUse | Shows stderr to Claude; the tool has already run | Top-level decision set to block |

| PermissionRequest | Ignored; the permission flow continues | hookSpecificOutput.decision.behavior: allow or deny |

| Notification | Ignored | None. This event has no decision control |

| Stop | Keeps Claude working instead of ending the turn | Top-level decision set to block |

Three cadences cover most of a session

The hooks reference groups the common events into three cadences. SessionStart and SessionEnd run per session. UserPromptSubmit, Stop, and StopFailure run per turn. PreToolUse and PostToolUse run on tool calls inside the agentic loop, except EndConversation, which skips both. A formatter belongs on PostToolUse. A block belongs on PreToolUse. A desktop ping belongs on Notification, which fires when Claude Code sends a notification, including the delayed permission_prompt described above.

Matching hooks for the same event run to completion in parallel. One deny does not stop a sibling logger. When several PreToolUse hooks return updatedInput for the same tool, the last one to finish takes effect, and that order is not deterministic.

Add Anthropic’s Notification command to ~/.claude/settings.json and confirm it under /hooks. On Linux use notify-send; on Windows use the PowerShell MessageBox command. Install jq, then add the PostToolUse Edit|Write Prettier command to the project’s .claude/settings.json. Save protect-files.sh, chmod +x it on macOS or Linux, and register it on PreToolUse with the quoted $CLAUDE_PROJECT_DIR path. Save block-rm.sh the same way and register it with if set to Bash(rm *) and args set to an empty array so it prints permissionDecision deny. Keep a hard deny in the permission system as well, because a guard script that fails to start or times out does not block the tool call.

Sources