Skip to content
, ,

Claude Code Hooks Guide: Every Event, Exit Codes and 6 Recipes

12–18 minutes
Woman in an orange blazer on a park bench holding a card with the Claude logo, hook text: 33 hook events, 6 recipes

Claude Code hooks let you run your own scripts at fixed points in a session, such as before a tool runs or when Claude finishes. They turn “please always do X” into a rule that runs every time.

This guide covers every hook event, the settings.json format, matchers, exit codes, JSON output and six copy-paste recipes. It also covers what changed in v2.1.317.

TLDR

  • Hooks live in a hooks block in ~/.claude/settings.json, .claude/settings.json or .claude/settings.local.json.
  • The official reference lists 33 hook events. The ones most people use are PreToolUse, PostToolUse, Notification, Stop and SessionStart.
  • Exit 2 blocks the action on events that can block. Exit 1 does not block, which surprises many people.
  • For finer control, exit 0 and print one JSON object, such as permissionDecision: "deny".
  • v2.1.317 added onFailure: "block" for Agent SDK hook callbacks and a prompt before a tool call sends your account email over the network.

What Claude Code Hooks Are

A hook is a handler that Claude Code runs when a certain event fires. Most hooks are shell commands. Claude Code sends the event details to your script as JSON on stdin.

Your script can then do three kinds of things:

  • Side effects: format a file, write a log line or send a desktop notification.
  • Decisions: block a tool call, deny a prompt or keep Claude working.
  • Context: add text that Claude reads on its next step, such as the current git branch.

The big win is reliability. A line in CLAUDE.md is a request that Claude usually follows. A hook is code that runs every time, whatever the model decides.

Quick answer: use CLAUDE.md for advice and hooks for rules. If something must always happen, or must never happen, write a hook.

What’s New in v2.1.317

Claude Code v2.1.317 shipped in early November 2026. The official changelog lists two changes that matter for hook users and security-minded teams.

Agent SDK hooks: onFailure “block”

Before this release, an Agent SDK callback hook that crashed let the action go ahead. Your app got a warning with the tag hook_callback_failed, but a broken guard still let the tool call through.

Now you can set onFailure: "block" in TypeScript or on_failure="block" in Python on the matcher. A callback that throws, returns invalid output or returns nothing then blocks the action. The docs say this needs TypeScript Agent SDK v0.3.317+ or Python Agent SDK v0.2.177+.

// TypeScript
const options: Options = {
  hooks: {
    PreToolUse: [{ matcher: "Bash", hooks: [checkPolicy], onFailure: "block" }]
  }
};

# Python
options = ClaudeAgentOptions(
    hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[check_policy], on_failure="block")]}
)

Note: settings.json command and HTTP hooks already had "onFailure": "block" since v2.1.295. Release 2.1.317 brings the same safety net to SDK callbacks.

Confirmation before your account email leaves the machine

The same release added a check that asks you before a web or shell tool call sends your account email address to the network. It is a guard against a tool call, or a prompt hidden in a web page, quietly sending your email to an outside server.

Other recent hook changes

  • v2.1.319: PostToolUse hooks now run, with up to 10 seconds, on tool calls that finish after a new message interrupts the turn.
  • v2.1.316: fixed PowerShell hooks not blocking on exit code 2 in some cases.
  • v2.1.314: each PreCompact hook’s output is cut at 10,000 characters, and Bash matchers now also run on Monitor tool commands.
  • v2.1.311: fixed SubagentStop hooks running about twice a minute instead of once.

Where Hooks Go in settings.json

Hooks are defined in JSON settings files. The place you put them decides who they apply to.

Location Scope Shared with team?
~/.claude/settings.json All your projects No
.claude/settings.json One project Yes, commit it to the repo
.claude/settings.local.json One project No, gitignored
Managed policy settings Whole organization Yes, set by admins
Plugin hooks/hooks.json When the plugin is on Yes
Skill or subagent frontmatter While the skill or subagent is active Yes

Every hook config has three levels. You pick an event, add a matcher group to filter it, then list one or more handlers to run.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "/path/to/lint-check.sh" }
        ]
      }
    ]
  }
}

Hooks from different files merge. They do not replace each other. If two files define the exact same handler, it runs once.

New to Claude Code? Start with the install guide and the commands cheat sheet.

Set Up Your First Hook (Step by Step)

This walkthrough follows the official guide. It adds a desktop notification when Claude is waiting for you.

  1. Open your user settings. Edit ~/.claude/settings.json. Create the file if it does not exist.
  2. Add a hooks block. Paste the Notification hook from Recipe 3 below. If you already have a hooks key, add Notification next to the other events instead of replacing the object.
  3. Check the JSON. No trailing commas and no comments. Invalid JSON is the top reason hooks do not show up.
  4. Verify it loaded. Type /hooks in Claude Code. This opens a read-only browser that lists every hook and where it came from.
  5. Test it. Ask Claude to do something that needs your permission, then switch to another window. You should get a notification.
  6. Debug if needed. Start with claude --debug-file /tmp/claude.log and watch that file in another terminal.

You can also ask Claude itself to write a hook. Describe what you want in plain words, then check the result with /hooks.

Every Hook Event Explained

The official reference lists 33 events. They fall into three rhythms: once per session, once per turn, and once per tool call.

Event When it fires Can block?
SessionStart A session starts or resumes No (adds context)
Setup With --init-only, or --init/--maintenance in -p mode No
UserPromptSubmit You send a prompt, before Claude sees it Yes
UserPromptExpansion A typed command expands into a prompt Yes
PreToolUse Before a tool call runs Yes
PermissionRequest A tool call needs a permission decision Via JSON only
PermissionDenied Auto mode denies a tool call No (can allow a retry)
PostToolUse After a tool call succeeds No (feedback to Claude)
PostToolUseFailure After a tool call fails No
PostToolBatch After a batch of parallel tool calls Yes
Notification Claude Code sends a notification No
MessageDisplay Assistant text is shown on screen No (display only)
SubagentStart / SubagentStop A subagent starts or finishes Stop only
TaskCreated / TaskCompleted A task is created or marked done Yes
Stop Claude finishes responding Yes (keeps Claude working)
StopFailure The turn ends on an API error No
TeammateIdle An agent team teammate is about to go idle Yes
InstructionsLoaded A CLAUDE.md or rules file loads No
ConfigChange A settings file changes mid-session Yes (not policy settings)
CwdChanged / DirectoryAdded Working directory changes or one is added No
FileChanged A watched file changes on disk No
WorktreeCreate / WorktreeRemove A worktree is created or removed Yes (non-zero exit fails it)
PreCompact / PostCompact Before and after context compaction Pre only
PreModelSwitch / PostModelSwitch Before and after a model change Pre only
Elicitation / ElicitationResult An MCP server asks for user input Yes
SessionEnd The session ends No

Hooks from settings files also run inside subagents. The input then carries agent_id and agent_type, so your script can tell which agent called the tool.

Matchers and the if Field

The matcher field filters when a hook fires. For tool events it matches the tool name. For other events it matches something else, such as startup or compact for SessionStart.

  • "*", "" or no matcher: matches everything.
  • Only letters, digits, _, -, spaces, commas and |: exact names. Edit|Write matches either tool.
  • Any other character: treated as a JavaScript regex. mcp__memory__.* matches every tool from the memory MCP server.

Matchers are case-sensitive. A regex is not anchored, so Edit.* also matches NotebookEdit. Use ^Edit$ for an exact match.

One common trap: mcp__memory on its own matches nothing. It has only exact-match characters, so Claude Code looks for a tool with that exact name. Add __.* at the end.

Narrow a hook with “if”

The if field uses permission rule syntax on tool events. "Bash(git *)" runs the hook only for git commands, and "Edit(*.ts)" only for TypeScript files.

The docs call if best-effort. When Claude Code cannot work out which commands a Bash line runs, it runs your hook anyway. For a hard allow or deny, use permission rules as well.

Exit Codes and Blocking

For command hooks, the exit code is the simplest way to talk back to Claude Code.

Exit code Meaning What happens
0 Success Any JSON you printed is applied. The action goes ahead unless the JSON blocks it.
2 Blocking error On events that can block, the action stops. Your stderr becomes the reason Claude sees.
Any other Non-blocking error The action goes ahead and you see a “hook error” notice, unless onFailure is "block".

Warning: exit 1 does not block. It is the normal Unix failure code, but Claude Code treats it as a non-blocking error. Policy hooks must use exit 2.

What exit 2 does depends on the event. On PreToolUse it blocks the tool call. On Stop it stops Claude from finishing and keeps the conversation going. On PostToolUse the tool already ran, so Claude only sees your stderr as feedback.

Block when a hook breaks

By default, a hook that crashes, times out or has a wrong path lets the action through. Add "onFailure": "block" to a command or HTTP hook so a failure acts like exit 2.

It has no effect on Stop, SubagentStop, TaskCompleted, TeammateIdle or async hooks.

JSON Output for Fine Control

Exit codes only say “block” or “carry on”. For more control, exit 0 and print one JSON object on stdout with nothing else around it.

Universal fields

  • continue: set to false to stop Claude completely after the hook.
  • stopReason: the message shown when continue is false.
  • systemMessage: a warning shown to the user.
  • terminalSequence: an allowed escape sequence, such as a desktop notification or window title.

PreToolUse decisions

PreToolUse puts its decision inside hookSpecificOutput. The permissionDecision can be allow, deny, ask or defer. If several hooks disagree, deny wins, then defer, then ask, then allow.

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database writes are not allowed"
  }
}

A hook’s deny holds in every permission mode, even bypassPermissions. An allow cannot get past deny rules in your settings.

Other decision patterns

  • Top-level decision: UserPromptSubmit, PostToolUse, Stop and others use {"decision": "block", "reason": "..."}.
  • Add context: hookSpecificOutput.additionalContext passes a string into Claude’s context. Each string is capped at 10,000 characters.
  • Rewrite input: updatedInput on PreToolUse replaces a tool’s arguments before it runs.

6 Copy-Paste Hook Recipes

All recipes below use jq to read the JSON input. Install it with brew install jq on macOS or apt-get install jq on Debian and Ubuntu.

Recipe 1: Auto-format files after every edit

This runs Prettier on each file Claude writes or edits. Put it in .claude/settings.json.

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

Swap Prettier for black, ruff format or gofmt -w if you work in another language.

Recipe 2: Block rm -rf

Save this script as .claude/hooks/block-rm-rf.sh and run chmod +x on it.

#!/bin/bash
# Blocks recursive force deletes before they run
CMD=$(jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -Eq 'rm[[:space:]]+-[a-zA-Z]*([rR][a-zA-Z]*f|f[a-zA-Z]*[rR])'; then
  echo "Blocked: rm -rf is not allowed here. Delete specific files instead." >&2
  exit 2
fi
exit 0

Then register it on PreToolUse. The if field means the script only starts for rm commands, and onFailure blocks the call if the script is missing.

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

For a second layer, add a deny rule too: "permissions": {"deny": ["Bash(rm -rf *)"]}.

Recipe 3: Notify when Claude finishes or needs you

A Stop hook fires when Claude finishes responding. A Notification hook fires when Claude waits for input or permission. This Linux version uses both.

{
  "hooks": {
    "Stop": [
      { "hooks": [ { "type": "command", "command": "notify-send 'Claude Code' 'Task finished'" } ] }
    ],
    "Notification": [
      { "matcher": "", "hooks": [ { "type": "command", "command": "notify-send 'Claude Code' 'Claude needs your attention'" } ] }
    ]
  }
}
  • macOS: use osascript -e 'display notification \"Task finished\" with title \"Claude Code\"' as the command.
  • Linux: notify-send needs the libnotify-bin package and a desktop session.
  • Only idle alerts: set the Notification matcher to idle_prompt or permission_prompt.

Recipe 4: Log every Bash command

This writes one JSON line per command to a log file in your home folder. It is handy for audits and for spotting slow or risky commands.

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

Because it runs on PreToolUse, it also logs commands that a later hook or rule blocks. Use PostToolUse instead if you only want commands that ran.

Recipe 5: Protect .env and other secret files

This is based on the official example. Save it as .claude/hooks/protect-files.sh and make it executable.

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED=(".env" "package-lock.json" ".git/")
for p in "${PROTECTED[@]}"; do
  if [[ "$FILE_PATH" == *"$p"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$p'" >&2
    exit 2
  fi
done
exit 0

Register it with "matcher": "Read|Edit|Write" on PreToolUse, the same way as Recipe 2. Adding Read stops Claude from loading your secrets into context.

Tip: a hook only sees the tools it matches, so a cat .env run through Bash skips it. Add "Read(./.env)" to your deny list and copy Recipe 2 for Bash.

Recipe 6: Re-add key context after compaction

Compaction summarizes long chats and can drop details. A SessionStart hook with the compact matcher prints text that Claude reads again.

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

Command, HTTP, MCP, Prompt and Agent Hooks

Shell commands are the most common handler, but there are five types in total.

  • command: runs a shell command. Default timeout is 600 seconds.
  • http: POSTs the event JSON to a URL and reads the JSON response.
  • mcp_tool: calls a tool on a configured MCP server. See our best MCP servers list for ideas.
  • prompt: asks a Claude model a yes or no question about the event. Default timeout is 30 seconds.
  • agent: starts a subagent that can read files before it decides. It is marked experimental, with a 60 second default timeout.

Add "async": true to a command hook to run it in the background, for example a slow test suite. Its result arrives on the next turn. Hooks also work inside skills; see the Claude Skills guide.

Troubleshooting: Common Problems and Fixes

  • Hook never fires: run /hooks and check it is listed under the right event. Matchers are case-sensitive, so bash will not match Bash.
  • Not listed in /hooks: your JSON is probably invalid, or the file is in the wrong place. Restart the session if the file watcher missed the edit.
  • “Only hooks from managed settings run here”: your admin set allowManagedHooksOnly, so personal hooks are off.
  • Hook runs but does not block: you used exit 1. Switch to exit 2, or print a JSON deny and exit 0.
  • JSON has no effect: your shell profile may print text before the JSON. Also check that permissionDecision sits inside hookSpecificOutput.
  • “No such file or directory”: the script path is wrong. Use "$CLAUDE_PROJECT_DIR" and run chmod +x on the script.
  • Stop hook loops: check stop_hook_active in the input and exit 0 when it is true. Claude Code overrides a Stop hook after 8 blocks in a row.
  • Test a hook by hand: pipe sample JSON in, for example echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh, then check echo $?.

Frequently Asked Questions

Where do I put Claude Code hooks?

Put them in a hooks block in ~/.claude/settings.json for all projects, or .claude/settings.json for one project. Use .claude/settings.local.json for personal project hooks you do not want to commit.

Which exit code blocks a tool call?

Exit code 2 blocks on events that can block, such as PreToolUse. Exit 1 and other codes are non-blocking errors unless you set onFailure to "block".

Can a hook block Claude even in bypass permissions mode?

Yes. A PreToolUse hook that returns permissionDecision: "deny" blocks the tool in every permission mode, including bypassPermissions.

What did Claude Code v2.1.317 change for hooks?

It added onFailure: "block" for Agent SDK hook callbacks, so a crashing callback blocks the action. It also added a prompt before a web or shell call sends your account email to the network.

How do I turn off all hooks for a while?

Set "disableAllHooks": true in a settings file. It cannot turn off hooks that your organization set in managed settings.

Do hooks run inside subagents?

Yes. Hooks from settings files, managed settings and plugins also fire when a subagent calls a tool. The input includes agent_id and agent_type.

Do hooks cost extra tokens?

Command and HTTP hooks do not call a model, though any text they add as context counts as input. Prompt and agent hooks call a Claude model, so they count toward usage; see Claude Code pricing.

Sources

Free weekly email

Get new AI guides every week

One short weekly email with our newest local AI, ComfyUI and AI tool guides. No spam, no pop-ups, just the good stuff.

Free. Unsubscribe anytime in one click. We only use your email to send this newsletter. See our privacy policy.

Woman in an orange blazer on a park bench holding a card with the Claude logo, hook text: 33 hook events, 6 recipes