Claude Code hooks: a practical guide

Updated 24 October 2026

Hooks are commands Claude Code runs at fixed points in a session: before a tool runs, after a file is edited, when Claude finishes, when it needs you. An instruction in CLAUDE.md is a request Claude can forget. A hook runs every time. This guide covers where hooks go, what they receive, how they answer back, and eight recipes you can paste today.

Hooks at a glance

QuestionAnswer
Where do they go?The "hooks" key in ~/.claude/settings.json, .claude/settings.json or .claude/settings.local.json
How are they shaped?Event → matcher group → one or more handlers
What does my script get?A JSON object on stdin
How do I block something?Exit 2 and write the reason to stderr
How do I return a decision?Exit 0 and print one JSON object to stdout
How do I see what loaded?Type /hooks in a session
How do I debug one?Start with claude --debug-file /tmp/claude.log and read the log

1. How a hook is configured

Every hook has three levels. You pick an event, such as PostToolUse. Under it you add a matcher group that narrows when it fires, such as “only for Edit and Write”. Inside that group you list one or more handlers, the things that actually run:

.claude/settings.json{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"file changed\" >> /tmp/claude-edits.log",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

A handler’s type is one of five:

A command hook without args runs through a shell: sh -c on macOS and Linux, Git Bash on Windows, or PowerShell when Git Bash isn’t installed. Add "args": [] and Claude Code runs command directly as an executable, with no shell and no quoting problems. That’s the safer choice when the path contains ${CLAUDE_PROJECT_DIR}.

2. Where hooks live

LocationApplies toShared?
~/.claude/settings.jsonEvery project on your machineNo
.claude/settings.jsonOne projectYes, commit it
.claude/settings.local.jsonOne project, just youNo, kept out of git
Managed policy settingsEveryone your organization deploys it toAdmin-controlled
Plugin hooks/hooks.jsonWhile the plugin is enabledYes, ships with the plugin
Skill or subagent frontmatterAfter the skill is invoked, or while the subagent runsYes, lives in the file

A few rules that save confusion later:

3. Every hook event

This is the full list from Claude Code’s hooks reference as of October 2026. Some of the newer events need a recent version, so run claude update if one doesn’t fire. “Can block” means exit code 2 (or a JSON decision) stops or changes what happens.

EventFires whenMatcher filters onCan block?
SessionStartA session starts or resumesstartup, resume, clear, compact, forkNo, adds context
SetupOnly with --init-only, or --init/--maintenance with -pinit, maintenanceNo
InstructionsLoadedA CLAUDE.md or .claude/rules/*.md file loadsLoad reasonNo
UserPromptSubmitYou send a prompt, before Claude sees itNoneYes, rejects the prompt
UserPromptExpansionA slash command expands into a promptCommand nameYes
MessageDisplayClaude’s reply streams to the screenNoneNo, can change what’s displayed
PreToolUseBefore any tool callTool nameYes
PermissionRequestClaude Code is about to ask you for permissionTool nameAllow or deny through JSON only
PermissionDeniedAuto mode denies a tool callTool nameNo, can allow a retry
PostToolUseA tool call succeededTool nameNo, stderr goes to Claude
PostToolUseFailureA tool call failedTool nameNo, stderr goes to Claude
PostToolBatchA batch of parallel tool calls finishedNoneYes, stops the loop
NotificationClaude Code sends a notificationpermission_prompt, idle_prompt and othersNo
SubagentStartA subagent startsAgent typeNo
SubagentStopA subagent finishesAgent typeYes, keeps it working
TaskCreatedA task is created with TaskCreateNoneYes
TaskCompletedA task is marked completeNoneYes
StopClaude finishes responding (not on Esc)NoneYes, keeps Claude working
StopFailureThe turn ends on an API error, instead of StopError type, such as rate_limitNo
TeammateIdleAn agent team teammate is about to go idleNoneYes
ConfigChangeA settings or skills file changes mid-sessionuser_settings, project_settings and othersYes, except managed policy
CwdChangedThe working directory changes, for example after cdNoneNo
DirectoryAddedYou add a directory with /add-dirHow it was addedNo
FileChangedA watched file changes on diskFile names to watch, such as .envrc|.envNo
WorktreeCreateClaude Code creates a worktree; replaces the default git behaviorNoneYes, failure stops creation
WorktreeRemoveA worktree your WorktreeCreate hook made is removedNoneYes
PreCompactBefore the context is compactedmanual, autoYes
PostCompactAfter compactionmanual, autoNo
PreModelSwitchBefore a model switch you asked forTarget model nameYes
PostModelSwitchAfter the model changesTarget model nameNo, adds context
ElicitationAn MCP server asks you for inputMCP server nameYes, declines
ElicitationResultYou answered an MCP server, before it’s sentMCP server nameYes
SessionEndThe session endsclear, resume, logout, prompt_input_exit, otherNo

Two timing details catch people out. Stop fires every time Claude finishes a reply, not only when a big task is done. And Notification with permission_prompt waits until you haven’t typed for about six seconds; PermissionRequest runs the moment Claude asks.

4. Matchers and the if field

How Claude Code reads a matcher depends on the characters in it:

Matchers are case-sensitive, so bash never matches the Bash tool. MCP tools are named mcp__<server>__<tool>. To match a whole server, write mcp__github__.*: plain mcp__github is treated as an exact name and matches nothing. Events with no matcher support, such as Stop and UserPromptSubmit, silently ignore one.

For tool events you can narrow further per handler with if, which uses permission rule syntax and checks the arguments too. The script only starts when it matches, which saves a process per tool call:

.claude/settings.json{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git push *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-push.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

if holds exactly one rule, with no && or lists. It’s only evaluated on PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied. On any other event, a handler with if never runs. The filter is best-effort, so don’t rely on it as a security boundary.

5. What your hook receives

A command hook gets one JSON object on stdin. Every event includes session_id, transcript_path, cwd and hook_event_name, and most include permission_mode. Each event adds its own fields. A PreToolUse hook for a Bash command gets something like this:

{
  "session_id": "abc123",
  "transcript_path": "/home/you/.claude/projects/.../transcript.jsonl",
  "cwd": "/home/you/my-project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test",
    "description": "Run test suite",
    "timeout": 120000,
    "run_in_background": false
  },
  "tool_use_id": "toolu_01ABC123..."
}

The fields you’ll reach for most:

Your script also gets CLAUDE_PROJECT_DIR in its environment: the folder the session started in. It doesn’t follow Claude into a worktree or after a cd; the cwd field does. Hooks run without a controlling terminal, so writing to /dev/tty fails.

6. Exit codes

Exit codeMeaningWhat happens
0SuccessClaude Code reads your stdout as JSON if it’s a single JSON object, and applies it. Plain text is logged, except on SessionStart, UserPromptSubmit, UserPromptExpansion and PostModelSwitch, where it’s added to Claude’s context
2Blocking errorOn events that can block, the action stops and your stderr becomes the reason Claude sees
Anything elseNon-blocking errorThe action goes ahead and you see a hook error notice with the first line of stderr

The one to remember: exit 1 does not block. It’s the normal Unix failure code, so scripts that “fail closed” with exit 1 let everything through. Use exit 2 for policy hooks.

A few events behave differently. PermissionRequest ignores exit 2 entirely; deny through JSON instead. On PostToolUse the tool has already run, so exit 2 just shows your stderr to Claude. On Stop, exit 2 sends Claude back to work with your stderr as the instruction. Notification, SessionEnd and the other side-effect events can’t block anything.

7. JSON output and decisions

For anything more than block or allow, exit 0 and print one JSON object, and nothing else, to stdout. Fields every event accepts:

Then each event has its own decision fields. The three you’ll use:

PreToolUse: allow, deny, ask or defer{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use the staging database, not production"
  }
}
PermissionRequest: allow or deny on your behalf{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "deny",
      "message": "Not during a release freeze"
    }
  }
}
Stop, PostToolUse, UserPromptSubmit and others{
  "decision": "block",
  "reason": "The build is failing. Fix it before you finish."
}

Things to know about decisions:

8. Timeouts, async hooks and failures

9. Security notes

10. Eight recipes

The scripts use jq to read the input: brew install jq on macOS, sudo apt-get install jq on Debian and Ubuntu. Put project scripts in .claude/hooks/ and make them executable with chmod +x. If a settings file already has a "hooks" key, add the new event inside it rather than adding a second one.

Format every file Claude edits

Runs Prettier on each file after Claude writes it. It assumes Prettier is in your project’s dependencies. --ignore-unknown stops it failing on file types it can’t format, and xargs -0 keeps paths with spaces in one piece:

.claude/settings.json{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -j '.tool_input.file_path' | xargs -0 npx prettier --write --ignore-unknown"
          }
        ]
      }
    ]
  }
}

Swap in ruff format, gofmt -w or rustfmt for other languages. A file a Bash command rewrites isn’t covered; a FileChanged hook catches those.

Block dangerous shell commands

Denies rm -rf on the root or home folder, force pushes, git reset --hard and dropping database tables, and tells Claude why. Everything else goes through the normal permission flow:

.claude/hooks/block-dangerous.sh#!/bin/bash
cmd=$(jq -r '.tool_input.command // empty')

if printf '%s' "$cmd" | grep -Eq 'rm -rf +(/|~|\$HOME)/?\*?( |$)|git push( .*)? (-f|--force)( |$)|git reset --hard|DROP (TABLE|DATABASE)'; then
  jq -n --arg reason "Blocked by block-dangerous.sh: $cmd" '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: $reason
    }
  }'
fi
exit 0
.claude/settings.json{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh",
            "args": [],
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

Pattern matching on shell text is easy to get around (bash -c, variables, scripts Claude writes first), so treat this as a seatbelt. --force-with-lease is deliberately allowed. Drop the onFailure line on versions before v2.1.295.

Protect .env files and keys

Stops Claude’s file tools from reading or editing .env, .env.local and the like, private keys, and anything in .git/. .env.example stays editable. Exit 2 sends the message to Claude, so it asks you instead of retrying:

.claude/hooks/protect-files.sh#!/bin/bash
path=$(jq -r '.tool_input.file_path // empty')
path="${path//\\//}"   # Windows backslashes to slashes
[ -z "$path" ] && exit 0
name=$(basename "$path")

case "$name" in
  .env.example|.env.sample) exit 0 ;;
  .env|.env.*|*.pem|*.key|id_rsa*|id_ed25519*)
    echo "Blocked: $name is a protected file. Ask the user to change it." >&2
    exit 2 ;;
esac

case "$path" in
  */.git/*)
    echo "Blocked: don't touch files inside .git/" >&2
    exit 2 ;;
esac
exit 0
.claude/settings.json{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

This only sees Read, Edit and Write. cat .env through Bash gets past it, so pair it with deny rules such as "permissions": { "deny": ["Read(./.env)"] }.

Get a desktop alert the moment Claude needs permission

PermissionRequest fires as soon as Claude asks, with no six-second wait. A hook that prints nothing doesn’t approve or deny, so the prompt still waits for you:

~/.claude/settings.json{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs permission\" with title \"Claude Code\"'",
            "async": true
          }
        ]
      }
    ]
  }
}

On Linux, use notify-send 'Claude Code' 'Claude Code needs permission'. More options, and why alerts go missing, are in How to get Claude Code notifications.

Run the tests before Claude says it’s done

A Stop hook that runs your tests when there are uncommitted changes. If they fail, exit 2 sends Claude back with the last 40 lines of output. The stop_hook_active check lets Claude stop on the second attempt, so a test it can’t fix doesn’t loop forever:

.claude/hooks/stop-tests.sh#!/bin/bash
input=$(cat)

# Already continuing because of this hook: let Claude stop.
[ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0

cd "$CLAUDE_PROJECT_DIR" || exit 0

# No uncommitted changes (or not a git repo): skip the test run.
[ -z "$(git status --porcelain 2>/dev/null)" ] && exit 0

if ! output=$(npm test 2>&1); then
  echo "Tests are failing. Fix them before you finish:" >&2
  printf '%s\n' "$output" | tail -n 40 >&2
  exit 2
fi
exit 0
.claude/settings.json{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/stop-tests.sh",
            "args": [],
            "timeout": 300
          }
        ]
      }
    ]
  }
}

Even without the check, Claude Code overrides a Stop hook after eight blocks in a row with no tool call in between. Replace npm test with whatever your project runs, and keep it fast: this runs at the end of every reply while changes are uncommitted.

Log every command Claude runs

Appends one JSON line per Bash command to a log file, with the time, session and folder. PreToolUse logs everything Claude tries, including commands you then deny; use PostToolUse to log only the ones that ran:

~/.claude/settings.json{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -c '{time: (now | todate), session: .session_id, cwd, command: .tool_input.command}' >> ~/.claude/bash-log.jsonl"
          }
        ]
      }
    ]
  }
}

Give Claude the git state at the start of a session

Plain text a SessionStart hook prints is added to Claude’s context, so the current branch and changed files are there before your first prompt:

~/.claude/settings.json{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "git status --short --branch 2>/dev/null | head -n 30"
          }
        ]
      }
    ]
  }
}

Play a sound when Claude finishes

~/.claude/settings.json{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "afplay /System/Library/Sounds/Glass.aiff",
            "async": true
          }
        ]
      }
    ]
  }
}

That’s macOS. Linux, Windows, WSL, different sounds per event and spoken alerts are in Play a sound when Claude Code finishes or needs you.

11. Debugging a hook

  1. Check it loaded. Type /hooks. Each hook is labeled with where it came from; select one to see its full command and file. Pick All events at the bottom to browse events with nothing configured.
  2. Run it by hand. Pipe sample JSON into the script, as in the example after this list, and check the exit code.
  3. Look at the transcript. Press Ctrl+O. A successful hook shows nothing. A blocked action shows your message. A non-blocking error shows hook error with the first line of stderr.
  4. Read the debug log. Start Claude Code with claude --debug-file /tmp/claude.log and run tail -f /tmp/claude.log in another terminal. claude --debug writes to ~/.claude/debug/<session-id>.txt instead and prints nothing to the terminal. Already in a session? Type /debug.
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | .claude/hooks/block-dangerous.sh
echo $?

Strings worth searching the log for:

Set CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose to also see how matchers were evaluated.

12. Common mistakes

See hooks at work: Agent Pets

Agent Pets is built on these same hooks. It uses Claude Code’s PermissionRequest hook, so it hears about a prompt the moment Claude asks, and lets you answer from Chrome instead of the terminal:

npx getagentpets init adds its hooks next to the ones you already have. It runs entirely on your computer, and the terminal prompt keeps working as usual.

Get Agent Pets — was $25, now $6Get Agent Pets — $25

Questions

Where do I put Claude Code hooks?

In the "hooks" key of a settings file: ~/.claude/settings.json for every project, .claude/settings.json to share them with a project, or .claude/settings.local.json for yourself in one project. Plugins ship theirs in hooks/hooks.json.

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

Add a PreToolUse hook with the matcher Bash. Either exit with code 2 and write the reason to stderr, or exit 0 and print JSON with permissionDecision set to "deny". Exit code 1 doesn’t block anything.

Why isn’t my Claude Code hook running?

Type /hooks to see what Claude Code loaded. If your hook isn’t listed, check the file path and the JSON. If it is listed, check the matcher (it’s case-sensitive), that the script is executable, and the debug log from claude --debug.

Can a hook approve permission prompts for me?

Yes. A PermissionRequest hook can return decision.behavior "allow", and a PreToolUse hook can return permissionDecision "allow". Deny rules in your settings still win, and a hook that prints nothing approves nothing.

What’s the difference between PreToolUse and PermissionRequest?

PreToolUse runs before every matching tool call, whether or not it needs approval. PermissionRequest runs only when Claude Code is about to show you a permission prompt.

Sources: Claude Code’s documentation on the hooks reference, automating actions with hooks, settings files and precedence and permissions; the jq manual.

More guides