Skip to content AI News: Claude Duet: Anthropic's agent that messages you first →

Claude Code Hooks: How They Work, With 8 Configs to Copy

Claude Code hooks are commands that Claude Code runs by itself at fixed points in a session: before a tool call, after a file edit, when Claude needs your input, when it finishes a reply. A hook fires every time its event happens, whether or not Claude remembers to. That makes it the place for anything that must happen without exception, like formatting each edited file or refusing every write to .env.

Below: where the config goes, every event, how a hook answers back, eight configs to paste in, and what to check when one stays silent. Everything here comes from Anthropic's Claude Code documentation as of October 2026.

Hook, CLAUDE.md or skill?

All three change how Claude Code behaves. What separates them is who decides whether they apply.

HookCLAUDE.mdSkill
What runsA shell command, HTTP request, MCP tool call, prompt or subagentNothing: Claude reads itNothing: Claude reads and applies it
WhenOn a lifecycle event, like PostToolUseLoaded at the start of every sessionWhen you type /name or Claude matches its description
Who decidesClaude Code, every time the event firesClaude, which tries to follow itClaude, which interprets the steps
Context costZero unless the hook returns outputThe whole file, every requestThe description each session, the full skill when used
Good forFormatting, blocking unsafe commands, logging, notificationsBuild commands, conventions, project factsWorkflows that need judgment, reference material

Anthropic's own advice is to put guardrails in hooks. "Never edit .env" in CLAUDE.md is a request. A PreToolUse hook that blocks the edit is enforcement. For a procedure where Claude has to think through the steps, a skill fits better.

Where hooks are configured

Hooks live in a hooks block inside a Claude Code settings file. The file you pick sets who gets the hook:

LocationApplies toShared?
~/.claude/settings.jsonAll your projectsNo, it stays on your machine
.claude/settings.jsonOne projectYes, commit it with the repo
.claude/settings.local.jsonOne projectNo, gitignored when Claude Code creates it
Managed policy settingsYour whole organizationSet by admins
Plugin hooks/hooks.jsonWhile the plugin is enabledYes, ships with the plugin
Skill frontmatterThe rest of the session once the skill runsYes, in the skill file
Subagent frontmatterWhile that subagent runsYes, in the agent file

Hooks from different files add up. If the exact same handler sits in two settings files, it runs once.

The config nests three levels deep: the event name, one or more matcher groups that filter when it fires, and the handlers that run. Every example below follows this shape:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "your-command-here" }
        ]
      }
    ]
  }
}

If your settings file already has a hooks key, add the new event as another key inside it. Don't paste a second hooks object over the first.

Edits to settings files normally reach the running session on their own. Type /hooks to browse every configured hook and the file it came from, or describe the hook you want and let Claude write it.

Most hooks use "type": "command". The others are http (POST the event to a URL), mcp_tool, prompt (a Claude model makes the call) and the experimental agent.

Every hook event

The reference lists 33 events. The last column says what a hook can do about the thing that triggered it when it exits with code 2.

EventFires whenCan block?
SessionStartA session begins or resumesNo
SetupYou start with --init-only, or --init or --maintenance in -p modeNo
UserPromptSubmitYou submit a prompt, before Claude processes itYes
UserPromptExpansionA typed command expands into a promptYes
PreToolUseBefore a tool call runsYes
PermissionRequestA tool call needs a permission decisionThrough JSON only
PermissionDeniedAuto mode denies a tool callNo
PostToolUseAfter a tool call succeedsNo, Claude sees your stderr
PostToolUseFailureAfter a tool call failsNo, Claude sees your stderr
PostToolBatchA batch of parallel tool calls resolves, before the next model callYes
NotificationClaude Code sends a notificationNo
MessageDisplayAssistant text is displayedNo
SubagentStartA subagent is spawnedNo
SubagentStopA subagent finishesYes
TaskCreatedA task is being createdYes
TaskCompletedA task is being marked completeYes
StopClaude finishes respondingYes, Claude keeps working
StopFailureA turn ends on an API errorNo
TeammateIdleAn agent team teammate is about to go idleYes
InstructionsLoadedA CLAUDE.md or .claude/rules file loadsNo
ConfigChangeA configuration file changes mid-sessionYes, except policy settings
CwdChangedThe working directory changesNo
DirectoryAddedA working directory is added mid-sessionNo
FileChangedA watched file changes on diskNo
WorktreeCreateA worktree is being createdYes, any nonzero exit fails it
WorktreeRemoveA worktree a hook created is being removedYes, any nonzero exit fails it
PreCompactBefore context compactionYes
PostCompactAfter compaction completesNo
PreModelSwitchBefore a model switch you or a client asked forYes
PostModelSwitchAfter the session's model changesNo
ElicitationAn MCP server asks for your input during a tool callYes
ElicitationResultYou answer an MCP elicitation, before the reply goes backYes
SessionEndA session terminatesNo

Matchers: what a hook fires on

A matcher narrows an event. On tool events Claude Code tests it against the tool name; on others, against a value such as the notification type or how the session started. The characters in it decide how it's read:

  • "*", "" or no matcher fires on every occurrence.
  • Only letters, digits, _, -, spaces, commas and | means exact names. Bash matches only Bash; Edit|Write and Edit, Write match either tool.
  • Any other character turns it into a JavaScript regular expression that matches anywhere in the value. Edit.* also catches NotebookEdit, so write ^Edit$ when you mean one tool.

Matchers are case-sensitive. MCP tools are named mcp__<server>__<tool>, so mcp__github__.* covers every tool from a server called github. Some events, including UserPromptSubmit, Stop and CwdChanged, take no matcher, and Claude Code silently ignores one if you add it.

On tool events, an if field on a handler filters by arguments with permission-rule syntax: "if": "Bash(git *)" runs the hook only for git commands. Anthropic calls this filter best-effort, so hard allow and deny lists belong in permission rules.

How a hook answers: exit codes and JSON

Claude Code hands the event to your command as JSON on stdin. Every event carries fields like session_id, cwd and hook_event_name. Tool events add tool_name and tool_input, which holds command for Bash and file_path for Edit and Write. Your hook replies through its exit code, stdout and stderr.

Exit codeWhat happens
0Success. Claude Code applies any JSON you printed. On SessionStart, UserPromptSubmit, UserPromptExpansion and PostModelSwitch, plain-text stdout goes into Claude's context
2Blocking error on events that can block. Your stderr is the reason: Claude reads it after a blocked tool call, you see it after a blocked prompt
Anything elseNon-blocking error. The action goes ahead and the transcript shows a hook error notice with the first line of stderr

The trap: exit 1 blocks nothing. A guard script that fails with exit 1 lets the tool call through. When you mean stop, exit 2.

For finer control, exit 0 and print one JSON object to stdout, with nothing else around it. Each event family reads different fields:

  • PreToolUse returns hookSpecificOutput.permissionDecision. allow skips the permission prompt, deny cancels the call and sends permissionDecisionReason to Claude, ask shows you the prompt, and defer (with -p only) pauses the call for later. updatedInput can rewrite the tool's arguments first.
  • PostToolUse, Stop, UserPromptSubmit and several others take a top-level "decision": "block" plus a reason.
  • PermissionRequest answers with hookSpecificOutput.decision.behavior, set to allow or deny.
  • Every event accepts "continue": false with a stopReason, which halts Claude completely, and systemMessage, a warning shown to you.

To pass Claude information, return additionalContext inside hookSpecificOutput. At the top level, Claude Code drops it without an error. Write it as plain facts, like "This repo uses bun test": text that reads like a system command can trip Claude's prompt-injection defenses.

When several hooks match one event, they all run in parallel. For PreToolUse the strictest answer wins: deny, then defer, ask, allow.

Command hooks time out after 10 minutes by default (30 seconds on UserPromptSubmit). Set timeout in seconds on any handler to change it. SessionEnd hooks share a 1.5-second budget, so keep cleanup short.

8 hook configs you can copy

Each one is complete. Paste the JSON into the settings file named and merge it into your existing hooks block if there is one. The shell parts use jq to read the input, so install it first (brew install jq on macOS, apt-get install jq on Debian and Ubuntu).

1. Get a desktop notification when Claude needs you

The Notification event fires when Claude is waiting for permission or has gone idle after answering. Put this in ~/.claude/settings.json so it works in every project (macOS):

{
  "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' as the command. To be pinged only for approvals, set the matcher to permission_prompt.

If nothing pops up on a Mac, Script Editor probably lacks notification permission, and the command fails without a word. Run osascript -e 'display notification "test"' once, then allow Script Editor under System Settings > Notifications.

2. Format every file Claude edits

A PostToolUse hook on Edit|Write passes the edited path to Prettier. Add it to .claude/settings.json in the project root and swap in your own formatter command if you use another one:

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

A successful run prints nothing in the chat, so check the file to confirm it ran. Blind spot: a file rewritten by a Bash command never passes through Edit or Write. For that, Anthropic points to a FileChanged hook.

3. Block edits to protected files

This is Anthropic's own example. Save the script as .claude/hooks/protect-files.sh:

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

# Normalize Windows backslashes so the patterns 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

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

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

Ask Claude to add a comment to .env and it gets the "Blocked:" line as feedback and changes course. Skip the chmod on macOS or Linux and the hook simply never runs.

4. Stop dangerous shell commands, with a reason Claude can act on

This guard answers with JSON, so Claude gets a next step with the refusal. Save it as .claude/hooks/guard-bash.sh and chmod +x it:

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

deny() {
  jq -n --arg r "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
  exit 0
}

case "$COMMAND" in
  *"rm -rf"*|*"rm -fr"*)
    deny "Recursive force delete is blocked here. Delete specific files by path." ;;
  *"git push --force"*|*"git push -f "*)
    deny "Force push is blocked. Push to a new branch and open a pull request." ;;
  *"drop table"*|*"DROP TABLE"*)
    deny "Dropping tables is blocked. Write a migration." ;;
esac

exit 0

Register it in .claude/settings.json. The args field switches the hook to exec form, which runs the script with no shell in between, so the path needs no quoting:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-bash.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

A hook's deny holds even in bypassPermissions mode or with --dangerously-skip-permissions. Text matching is easy to dodge, though (an extra space, a variable, a script file), so keep anything that must never run in your permission deny rules too.

5. Log every command Claude runs

Add this to ~/.claude/settings.json for a running log across all projects, one line per command with a timestamp and folder:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '\"\\(now | todate) \\(.cwd) \\(.tool_input.command)\"' >> ~/.claude/command-log.txt"
          }
        ]
      }
    ]
  }
}

PostToolUse fires only after a call succeeds. Failed commands arrive as PostToolUseFailure, so add the same matcher group under that event if you want those in the log as well.

6. Load fresh context at session start

Plain text a SessionStart hook prints goes straight into Claude's context. This one tells Claude the current branch and the last five commits:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "echo \"Current branch: $(git branch --show-current)\"; echo 'Recent commits:'; git log --oneline -5"
          }
        ]
      }
    ]
  }
}

With no matcher it runs on every start, resume, /clear and compaction; set it to compact to run only after compaction. Rules that never change belong in CLAUDE.md. This hook is for facts that move, like the branch.

7. Make Claude run the tests before it stops

Exit code 2 on Stop keeps Claude working, and your stderr becomes its next instruction. Save this as .claude/hooks/require-tests.sh and make it executable:

#!/bin/bash
INPUT=$(cat)

# Claude is already continuing because of this hook: let it stop.
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

# Nothing changed since the last commit: skip the suite.
if git diff --quiet HEAD 2>/dev/null; then
  exit 0
fi

if ! OUTPUT=$(npm test 2>&1); then
  echo "Tests are failing. Fix them before you finish:" >&2
  echo "$OUTPUT" | tail -n 40 >&2
  exit 2
fi

exit 0
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/require-tests.sh",
            "args": [],
            "timeout": 300
          }
        ]
      }
    ]
  }
}

Replace npm test with your test command. Watch the loop: Stop fires after every reply, and Claude Code overrides a Stop hook that blocks eight times in a row, so the stop_hook_active check lets Claude hand back after one retry.

8. Approve the end of plan mode automatically

When a plan is ready, Claude calls ExitPlanMode and waits for your OK. This hook, in ~/.claude/settings.json, approves it for you:

{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "ExitPlanMode",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
          }
        ]
      }
    ]
  }
}

Keep that matcher exact. An empty matcher or .* here would approve every permission prompt, file writes and shell commands included.

When a hook doesn't fire

Work down this list in order.

  1. Check it loaded. Run /hooks and find your hook under its event. If it's missing, look for invalid JSON (trailing commas and comments aren't allowed) or a file in the wrong folder. Restart the session if an edit hasn't shown up after a few seconds.
  2. Compare the matcher. Matching is case-sensitive, so bash won't catch Bash. Confirm the event too: PreToolUse runs before the tool, PostToolUse after.
  3. Run the script by hand. Pipe in sample JSON and print the exit code: echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?
  4. Fix "command not found". Reference scripts by absolute path or through ${CLAUDE_PROJECT_DIR}, run chmod +x, and install jq if that's the name in the error.
  5. JSON with no effect. Usually your shell profile echoes a line before the JSON, so stdout no longer starts with {. Otherwise a field sits at the wrong level, like permissionDecision outside hookSpecificOutput.
  6. Read the debug log. Start with claude --debug (the log lands in ~/.claude/debug/<session-id>.txt) or claude --debug-file /tmp/claude.log, or run /debug mid-session. It records each hook's exit code, stdout and stderr.
  7. Open the transcript view. Ctrl+O shows blocks and hook error notices. A successful hook leaves no trace there.
  8. Ask about org policy. If /hooks says "Only hooks from managed settings run here", an admin has set allowManagedHooksOnly and your own hooks won't run.

To switch every hook off without deleting them, set "disableAllHooks": true in a settings file, or pass --settings '{"disableAllHooks": true}' for a single run. There's no switch for one hook at a time.

Security

Command hooks run with your full user permissions and can touch anything your account can, so review every hook before adding it.

  • Workspace trust. Interactive sessions hold back every settings-file hook until you accept the folder's trust dialog. A -p or SDK run treats the folder as trusted, so a repo's committed hooks run at once. Before running claude -p over code you didn't write, read its .claude/ folder, use --bare, or turn hooks off for that run.
  • Deny holds; allow has limits. A hook's deny works in every permission mode. A hook's allow can't override deny rules in your settings.
  • Defensive scripts. Anthropic's checklist: validate and sanitize input, quote shell variables ("$VAR"), reject paths containing .., use absolute paths, and stay away from .env, .git/ and keys.
  • HTTP hooks. Headers resolve only the environment variables you list in allowedEnvVars.

New to the tool? Start with how to use Claude Code, then browse our Claude Code prompts and the Claude skills directory.

Sources

Weekly

New prompts in your inbox

One email a week: the best new prompts and one short guide. Confirm by email and get the free Prompt Cheat Sheet (PDF). Unsubscribe any time. Privacy policy