Skip to content
AI Dev Toolkit.
Esc
  • AI Token CounterCount tokens for GPT, Claude, Gemini, DeepSeek, Qwen and more.Tool
  • LLM API Cost CalculatorEstimate per-request, daily and monthly API costs.Tool
  • AI Model ComparisonCompare prices, context windows and features across models.Tool
  • AI Model Pricing PagesSpecs, real costs and cheaper alternatives for popular models.Tool
  • Context Window CheckerSee whether your text fits each model’s context window.Tool
  • Subscription vs API CalculatorFind out whether a chat plan or the API is cheaper for you.Tool
  • GPU / VRAM CalculatorCheck how much VRAM a local model needs and which GPUs fit.Tool
  • Prompt Caching CalculatorEstimate savings from prompt caching.Tool

Guide · Coding agents

Claude Code hooks: a practical guide with examples

Claude Code hooks are commands that Claude Code runs automatically at fixed points: before a tool call, after a file edit, when Claude stops, when a session starts. You define them under hooks in a settings.json file. Each hook receives the event as JSON on stdin, and exit code 2 blocks the action, so a hook can enforce a rule every time.

By Tahir NazirUpdated 15 min read

On this page
  1. What are Claude Code hooks?
  2. Where do hooks go? The settings.json format
  3. Every Claude Code hook event, and when it fires
  4. Exit codes and JSON output: how a hook talks back
  5. 7 Claude Code hook examples you can copy
  6. Are Claude Code hooks safe?
  7. Claude Code hooks on Windows
  8. Debugging: why isn’t my hook running?
  9. Questions people ask

What are Claude Code hooks?

A hook is a handler that Claude Code runs when a lifecycle event fires. Most are shell commands, but a handler can also be an HTTP endpoint (http), a tool on an MCP server (mcp_tool), a one-shot question to a Claude model (prompt) or a subagent that checks files first (agent, still experimental).

The point is certainty. An instruction in CLAUDE.md is advice Claude usually follows; a hook runs every time its event fires, whatever Claude decides. Use CLAUDE.md for conventions, permission rules for plain allow and deny lists, and hooks for anything that needs a script.

Where do hooks go? The settings.json format

Where a hook can live
FileApplies toShared?
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonThis projectYes, commit it
.claude/settings.local.jsonThis projectNo, gitignored
Managed policy settingsThe organisationSet by admins
Plugin hooks/hooks.jsonWhile the plugin is enabledWith the plugin
Skill frontmatterRest of the session once the skill is invokedWith the skill
Subagent frontmatterWhile that subagent runsWith the subagent

Hooks from every level merge rather than override each other. The same handler defined in two settings files runs once.

Inside the file, hooks nest three levels deep: the event, a matcher group that filters it, and one or more handlers. Every matching handler runs, in parallel:

The shape of the hooks key
{
  "hooks": {
    "PreToolUse": [                    <- the event
      {
        "matcher": "Edit|Write",       <- which tools, sources or types
        "hooks": [                     <- one or more handlers
          { "type": "command", "command": "<shell command>" }
        ]
      }
    ],
    "Stop": [ ... ]                    <- more events sit beside it
  }
}

Claude Code picks up edits to settings files mid-session. Run /hooks to check: it’s a read-only list of every configured hook and the file it came from.

How matchers work

  • Plain names: Bash matches only Bash; Edit|Write and Edit, Write match either. Matchers are case-sensitive.
  • Anything with other characters is an unanchored JavaScript regex: Edit.* also matches NotebookEdit, so write ^Edit$ for an exact match.
  • MCP tools are named mcp__<server>__<tool>. Match a whole server with mcp__github__.*; the .* is required.
  • **"*", "" or no matcher** matches everything. Events without matcher support ignore the field.
  • `if` narrows one handler on tool events with permission-rule syntax, such as "if": "Bash(git push *)". It’s best-effort, so don’t rely on it for security.

Every Claude Code hook event, and when it fires

There are 33 hook events. SessionStart and SessionEnd fire once per session, UserPromptSubmit and Stop once per turn, and PreToolUse and PostToolUse around every tool call.

Where each of the 33 events fires. Events with a lime dot can stop or deny what happens next; the rest observe, add context or run side effects.
All Claude Code hook events
EventFires whenA hook can
Setup--init-only, or -p with --init or --maintenanceOne-time CI setup
SessionStartA session starts, resumes, clears or compactsAdd context; set env vars
UserPromptSubmitYou submit a promptBlock it; add context
UserPromptExpansionA typed command expandsBlock it; add context
PreToolUseBefore each tool callAllow, deny or ask; edit the input
PermissionRequestA permission prompt is dueAllow or deny it
PostToolUseA tool call succeedsFeed back; replace the output
PostToolUseFailureA tool call failsAdd context
PostToolBatchA batch of parallel calls endsAdd context; stop the loop
PermissionDeniedAuto mode denies a callLet Claude retry
SubagentStartA subagent startsAdd context to it
SubagentStopA subagent finishesKeep it working
TaskCreatedA task is createdCancel it
TaskCompletedA task is marked doneRefuse it
ElicitationAn MCP server asks you for inputAnswer or decline
ElicitationResultYou answer that requestChange or block it
StopClaude finishes respondingKeep Claude working
StopFailureThe turn ends on an API errorLog or alert
TeammateIdleA teammate is about to go idleKeep it working
PreCompactBefore compactionBlock it
PostCompactAfter compactionLog the summary
SessionEndThe session endsClean up (1.5-second budget)
NotificationClaude Code sends a notificationAlert you elsewhere
MessageDisplayReply text streams to the screenChange what you see
InstructionsLoadedA CLAUDE.md or rules file loadsAudit only
ConfigChangeA settings or skill file changesBlock the change
CwdChangedThe working directory changesReload env vars
FileChangedA watched file changes on diskReact; can’t block
DirectoryAdded/add-dir adds a directoryPrepare it
WorktreeCreateA worktree is createdReplace the git default
WorktreeRemoveThat worktree is removedClean up, or fail the removal
PreModelSwitchBefore a model switch you asked forBlock or confirm it
PostModelSwitchAfter the model changesAdd model-specific context

From the hooks reference, checked 2026-10-11 against Claude Code 2.1.293. `SessionStart` accepts only `command` and `mcp_tool` handlers (`mcp_tool` ones are skipped at launch); `Setup` runs only `command` handlers.

Exit codes and JSON output: how a hook talks back

  • Exit 0: success. Claude Code reads any JSON on stdout. Plain text goes to the debug log, except on UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch, where Claude gets it as context.
  • Exit 2: a blocking error, with stderr as the reason. On PreToolUse the call is stopped and Claude reads why. Events that can’t block just show the message: to Claude after PostToolUse, to you after SessionStart.
  • Anything else: a non-blocking error. The action goes ahead and the transcript shows a “hook error” notice.

For finer control, exit 0 and print one JSON object, and nothing else, to stdout. PreToolUse puts its decision inside hookSpecificOutput:

PreToolUse JSON output
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use the staging database, not production"
  }
}
  • `permissionDecision`: allow, deny, ask, or defer in -p mode. When hooks disagree, deny wins. An allow skips the prompt but never overrides a deny rule.
  • `updatedInput` rewrites the tool’s arguments. It replaces the whole input, so include unchanged fields.
  • `additionalContext` gives Claude a note beside the result. At the top level instead of inside hookSpecificOutput, it’s silently ignored.
  • Other events such as PostToolUse and Stop use a top-level "decision": "block" plus reason; PermissionRequest uses decision.behavior.
  • Universal fields: "continue": false stops Claude, systemMessage warns you and terminalSequence sends a desktop notification, though some events ignore the first two.

Injected context fills Claude’s context window, and each string is capped at 10,000 characters. Paste a sample into the token counter to see what a hook adds per turn.

7 Claude Code hook examples you can copy

The Bash scripts need jq. Save them under .claude/hooks/ and chmod +x them on macOS and Linux. Merge the JSON into a single hooks object, one key per event.

1. Format every file Claude edits

Prettier runs after each Edit or Write; --ignore-unknown skips file types it can’t format instead of failing:

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write --ignore-unknown \"$(jq -r '.tool_input.file_path')\""
          }
        ]
      }
    ]
  }
}

Don’t pipe the path through xargs, as some examples do: xargs strips backslashes, so on Windows C:\Users\…\src\a.js reached Prettier as C:Users…srca.js and failed with “No files matching the pattern were found”. The quoted form worked.

2. Block edits to protected files

This script exits 2 when Claude tries to edit .env files, anything in .git/ or secrets/, or the lockfile, and also when jq is missing, so it fails closed instead of letting every edit through. Claude reads the message and changes course:

.claude/hooks/protect-files.sh
#!/bin/bash
# .claude/hooks/protect-files.sh: block edits to protected paths. Exit 2 = block.
command -v jq >/dev/null || { echo "protect-files: jq is not installed" >&2; exit 2; }
input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
file="${file//\\//}"   # Windows paths arrive with backslashes
shopt -s nocasematch     # .ENV and .env are the same file on Windows and macOS

case "$file" in
  */.env.example) ;;   # the template is fine to edit
  */.git/*|*/secrets/*|*/.env|*/.env.*|*/package-lock.json)
    echo "Blocked: $file is protected. Ask the user to change it by hand." >&2
    exit 2 ;;
esac
exit 0
.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

Given a Windows path to .env, it printed Blocked: C:/project/.env is protected. and exited 2; .env.example and src/index.ts exited 0. It only sees Edit and Write: a shell command like echo KEY=1 >> .env bypasses it. To catch those, the docs suggest also matching Bash|PowerShell and checking git status --porcelain, or using a FileChanged hook.

3. Run the tests after edits

With "async": true the tests run in the background while Claude keeps working, and the result reaches Claude on its next turn:

.claude/hooks/run-tests.sh
#!/bin/bash
# .claude/hooks/run-tests.sh: run the tests after a source edit, tell Claude the result.
input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
case "$file" in
  *.ts|*.tsx|*.js|*.jsx) ;;
  *) exit 0 ;;   # not a source file: nothing to do
esac

if out=$(npm test --silent 2>&1); then
  msg="Tests passed after editing $file."
else
  msg="Tests failed after editing $file. Last lines:
$(tail -n 30 <<<"$out")"
fi
jq -nc --arg msg "$msg" \
  '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}'
.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh",
            "async": true
          }
        ]
      }
    ]
  }
}

Only the last 30 lines of a failure go back. An async hook can’t block; to stop Claude finishing until tests pass, run the check from a Stop hook that exits 2.

4. Get a notification when Claude is done

Stop fires whenever Claude finishes responding, and Notification with permission_prompt when an approval has waited about six seconds. The script returns a terminalSequence for Claude Code to emit: OSC 9 suits Windows Terminal, iTerm2, WezTerm and ConEmu; Ghostty, Warp and urxvt use OSC 777.

~/.claude/hooks/notify.sh
#!/bin/bash
# ~/.claude/hooks/notify.sh: ask the terminal for a desktop notification (OSC 9).
seq=$(printf '\033]9;%s\007' "${1:-Claude Code}")
jq -nc --arg seq "$seq" '{terminalSequence: $seq}'
~/.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$HOME\"/.claude/hooks/notify.sh 'Claude Code finished'"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "permission_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "\"$HOME\"/.claude/hooks/notify.sh 'Claude Code needs your approval'"
          }
        ]
      }
    ]
  }
}

5. Keep an audit log of shell commands

Every command Claude tries to run, allowed or not, is appended to a JSON Lines file. It matches PowerShell too, because on Windows Claude may use the PowerShell tool:

~/.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|PowerShell",
        "hooks": [
          {
            "type": "command",
            "command": "jq -c '{time: (now | todate), session: .session_id, cwd, tool: .tool_name, command: .tool_input.command}' >> ~/.claude/command-audit.jsonl"
          }
        ]
      }
    ]
  }
}
~/.claude/command-audit.jsonl (two sample events)
{"time":"2026-10-11T09:36:29Z","session":"abc123","cwd":"C:\\proj","tool":"Bash","command":"npm test"}
{"time":"2026-10-11T09:36:30Z","session":"abc123","cwd":"C:\\proj","tool":"PowerShell","command":"Get-ChildItem"}

6. Inject git context at session start

A SessionStart hook’s plain stdout becomes context. With no matcher it also runs after /clear, a resume and compaction, so the branch and recent commits stay current. Keep it fast: Claude’s first reply waits for it.

.claude/hooks/session-context.sh
#!/bin/bash
# .claude/hooks/session-context.sh: plain stdout becomes context for Claude.
echo "Branch: $(git branch --show-current)"
echo "Uncommitted changes:"
git status --short | head -n 20
echo "Recent commits:"
git log --oneline -5
.claude/settings.json
{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-context.sh"
          }
        ]
      }
    ]
  }
}

7. Ask before every git push

No script needed. The if field narrows the handler to git push, and the JSON answer ask forces a confirmation prompt:

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git push *)",
            "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"git push: check the branch and remote first\"}}'"
          }
        ]
      }
    ]
  }
}

Are Claude Code hooks safe?

Hooks are as safe as the commands in them. Command hooks run with your full user permissions, so treat one in someone else’s repository like a script you’re about to run.

  • Trust. Interactive sessions hold back settings-file hooks until you accept the workspace trust dialog. -p and SDK runs have no dialog, so hooks committed to a cloned repository run at once.
  • Off switch. claude --settings '{"disableAllHooks": true}' turns off all but managed hooks for one run. To drop a single hook, delete its entry.
  • Scripts. Quote variables ("$file"), use $CLAUDE_PROJECT_DIR instead of relative paths, and skip .env, .git/ and keys.
  • One-way power. A hook’s deny blocks a call even in bypassPermissions mode, but its allow can’t override a deny rule. Admins can set allowManagedHooksOnly.

Claude Code hooks on Windows

Hooks run in Git Bash when it’s installed, otherwise in PowerShell (this error means Claude Code found neither). Watch for:

  • Backslash paths. file_path arrives as C:\project\src\index.ts even in Git Bash. Normalise it, as example 2 does, before matching /src/ or /.env.
  • No jq. Git Bash lacks it: we got jq: command not found until winget install jqlang.jq.
  • `Bash|PowerShell` matchers. Where the PowerShell tool is enabled, Claude routes shell commands through it, and a Bash-only hook never fires.
  • `$env:CLAUDE_PROJECT_DIR` in PowerShell hooks; bare $CLAUDE_PROJECT_DIR is an empty local variable there. Git Bash runs #!/bin/bash scripts without chmod.

Without Git Bash, write the hook in PowerShell. This version of example 2 needs no jq and gave the same results in Windows PowerShell 5.1. Exec form (args) runs it with no shell quoting:

.claude/hooks/protect-files.ps1
# .claude/hooks/protect-files.ps1: the same rule without Git Bash or jq. Exit 2 = block.
$hook = [Console]::In.ReadToEnd() | ConvertFrom-Json
$file = $hook.tool_input.file_path -replace '\\', '/'
if ($file -match '/\.env\.example$') { exit 0 }
if ($file -match '/(\.git|secrets)/|/\.env(\..+)?$|/package-lock\.json$') {
  [Console]::Error.WriteLine("Blocked: $file is protected. Ask the user to change it by hand.")
  exit 2
}
exit 0
.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "powershell.exe",
            "args": [
              "-NoProfile",
              "-ExecutionPolicy",
              "Bypass",
              "-File",
              "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.ps1"
            ]
          }
        ]
      }
    ]
  }
}

Debugging: why isn’t my hook running?

First test the script alone: echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?. Then use Claude Code’s tools: /hooks shows whether it loaded, Ctrl+O shows blocks and “hook error” notices in the transcript, and claude --debug logs every run to ~/.claude/debug/<session-id>.txt (not the terminal). --debug-file <path> picks the path; /debug starts logging mid-session.

Common causes
SymptomLikely causeFix
Not in /hooksInvalid JSON or wrong fileNo comments or trailing commas; check the path
Never firesMatcher spelling or caseBash, not bash
Doesn’t blockScript exits 1Exit 2, or exit 0 with a JSON decision
JSON ignoredShell profile prints firstFind Hook output does not start with { in the log
Field ignoredField at the wrong levelNest it in hookSpecificOutput
Claude never stopsStop hook always blocksExit 0 when stop_hook_active is true

Hooks that call MCP tools need the server configured first: the MCP config generator writes the entry and MCP servers in Claude Code covers connecting it. Checks that should run in their own context belong in Claude Code subagents, which can carry hooks too.

FAQ

Questions people ask

Where do I put Claude Code hooks?

Add a hooks object to a settings file: ~/.claude/settings.json for every project on your machine, .claude/settings.json in a repository to share hooks with your team, or .claude/settings.local.json for personal hooks in one project. Hooks from all levels merge. Run /hooks inside Claude Code to see which loaded and where each came from.

What is the difference between hooks and CLAUDE.md?

CLAUDE.md is context: Claude reads it and usually follows it, but nothing enforces it. A hook is code that Claude Code runs every time its event fires, whatever Claude decides. Use CLAUDE.md for conventions and explanations, and hooks for what must always happen, like formatting after an edit, or must never happen, like editing a secrets file.

How do I block a command with a Claude Code hook?

Add a PreToolUse hook with the matcher Bash (plus PowerShell on Windows). Read the command from tool_input.command on stdin; to block it, write the reason to stderr and exit with code 2, and Claude sees the reason. Or exit 0 and print JSON with permissionDecision set to deny. Exit code 1 does not block.

Do Claude Code hooks run inside subagents?

Yes. Hooks from settings files, managed policy and plugins also fire for a subagent’s tool calls, and the input carries agent_id and agent_type so a script knows which agent called. A subagent’s own frontmatter can define hooks that run only while it’s active; a Stop hook there becomes SubagentStop.

Can a hook make Claude keep working instead of stopping?

Yes. A Stop hook that exits 2, or prints {"decision": "block", "reason": "…"}, sends Claude back to work with the reason as its next instruction. Check stop_hook_active in the input to avoid a loop; Claude Code ends the turn anyway after eight blocks in a row. The built-in /goal command does this without configuration.

Do Claude Code hooks work on Windows?

Yes. Command hooks run in Git Bash when it’s installed, otherwise in PowerShell, and "shell": "powershell" picks PowerShell for one hook. File paths arrive with backslashes, Git Bash has no jq until you install it, and shell hooks should match Bash|PowerShell, because Claude may run commands through the PowerShell tool.

Try it

Tools from this guide

Keep reading