Claude Code

Claude Code hooks, with examples that stop real mistakes

What Claude Code hooks are, every event they can run on, how to set one up, and working examples that block a bad command or check work before Claude stops.

Orchestrated by Jon Phillips

Researched and written by Claude

Published 13 min read

On this page

A Claude Code hook is a command that Claude Code runs on its own at a set point in a session: before a tool runs, after a file is edited, when Claude finishes responding. You set hooks in a settings file, and they run every time their event fires, whether Claude remembers your instructions or not. A hook can block the action it's attached to, so it's how you turn "please don't do that" into "that can't happen."

Most guides to Claude Code hooks list the nine events that existed when hooks first shipped, and none of them agree on how many there are now. As of September 30, 2026 (Claude Code 2.1.286), Anthropic's hooks reference lists 33. This guide covers all of them, the two rules that make a hook silently do nothing, and six working examples you can paste in today. Every event, field and quote below comes from Anthropic's documentation, linked where it's used.

I run several Claude Code sessions on client sites every day, and hooks are what keep them from committing to the wrong branch or stopping with the tests red. These are the ones I'd set up first.

What are Claude Code hooks?

Claude Code has a few ways to shape what Claude does. CLAUDE.md and skills are instructions that Claude reads and, usually, follows. A hook is different: it's code that Claude Code runs, outside the model. Anthropic's features overview puts the rule simply: "If a rule must hold every time, make it a hook."

A hook has three parts:

  • An event: when it runs, such as PreToolUse, before any tool call.
  • A matcher: which occurrences it runs on, such as only the Bash tool.
  • A handler: what runs. Usually a shell command, but a hook can also call a URL, an MCP tool, or ask a model a yes-or-no question.

The command receives the event's details as JSON on standard input, and answers with an exit code and, if it needs to, JSON on standard output.

Your first hook in two minutes

Start with one that can't break anything: a desktop notification when Claude needs you. Add this to ~/.claude/settings.json (this is Anthropic's own macOS example from the hooks guide):

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

If your settings file already has content, add the "hooks" key alongside what's there. Then type /hooks in Claude Code to confirm it's listed. The /hooks menu is read-only: to change a hook, edit the JSON or ask Claude to.

If no notification appears on a Mac, Script Editor probably lacks permission to show them. Run osascript -e 'display notification "test"' once, then turn on Allow Notifications for Script Editor in System Settings > Notifications.

To be told only when Claude has gone quiet, not on every permission prompt, change the matcher to "idle_prompt", which fires when "Claude finished responding about 60 seconds ago and you haven't typed since."

Where do hooks go?

In any settings file, and in a few other places (hooks reference):

LocationApplies toShared with your team?
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonOne projectYes, commit it
.claude/settings.local.jsonOne projectNo, kept out of git
Managed policy settingsYour whole organizationSet by an admin
A plugin's hooks/hooks.jsonWherever the plugin is enabledYes, with the plugin
A skill's or subagent's frontmatterWhile that skill or subagent is in useYes, with the file

Hooks from different files add up rather than replace each other: "user, project, and local settings add their own hooks without removing managed ones." So a hook in your personal settings still runs in a project that has its own hooks.

Which events can a hook run on?

All 33, as listed on September 30, 2026. The right-hand column says whether a hook that exits with code 2 stops the action (hooks reference).

EventWhen it runsExit 2 blocks it?
SessionStartA session begins or resumesNo
SetupClaude Code starts with --init-only, or --init or --maintenance in -p modeNo
UserPromptSubmitYou submit a prompt, before Claude sees itYes
UserPromptExpansionA command you typed expands into a promptYes
PreToolUseBefore a tool call runsYes
PermissionRequestA tool call needs a permission decisionNo, use its JSON decision
PermissionDeniedAuto mode denies a tool callNo
PostToolUseAfter a tool call succeedsNo, the tool already ran
PostToolUseFailureAfter a tool call failsNo
PostToolBatchAfter a batch of parallel tool calls, before the next model callYes
NotificationClaude Code sends a notificationNo
MessageDisplayWhile Claude's reply is displayedNo
SubagentStartA subagent startsNo
SubagentStopA subagent finishesYes, it keeps working
TaskCreatedA task is being createdYes
TaskCompletedA task is being marked completeYes
StopClaude finishes respondingYes, Claude keeps working
StopFailureThe turn ends on an API errorNo
TeammateIdleAn agent-team teammate is about to go idleYes
InstructionsLoadedA CLAUDE.md or .claude/rules/*.md file is loadedNo
ConfigChangeA configuration file changes during a sessionYes
CwdChangedThe working directory changesNo
DirectoryAddedA directory is added with /add-dirNo
FileChangedA file you've told it to watch changesNo
WorktreeCreateA worktree is being createdYes, any non-zero exit
WorktreeRemoveA worktree is being removedYes, any non-zero exit
PreCompactBefore the conversation is compactedYes
PostCompactAfter compaction completesNo
PreModelSwitchBefore a model switch you asked forYes
PostModelSwitchAfter the model changesNo
ElicitationAn MCP server asks you for inputYes
ElicitationResultAfter you answer an MCP server's requestYes
SessionEndA session endsNo

The list keeps growing: DirectoryAdded arrived in version 2.1.219 in July, and PreModelSwitch and PostModelSwitch in 2.1.251 in August (changelog). Check the reference before relying on a count, including this one.

Most useful hooks use five of them. PreToolUse guards what Claude is about to do, PostToolUse tidies up after an edit, Stop checks the work before Claude hands it back, SessionStart loads context, and Notification tells you when you're needed.

How a hook is set up

Every hook follows the same shape: an event, a list of matcher groups, and a list of handlers in each:

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

The matcher is not always a regular expression. How it's read depends on the characters in it:

  • "*", "" or no matcher at all matches everything.
  • Only letters, digits, _, -, spaces, , and | means exact names: Bash matches only the Bash tool, and Edit|Write matches either.
  • Anything else makes it a JavaScript regular expression, matched anywhere in the name. Edit.* matches NotebookEdit too; write ^Edit$ for an exact match.

That catches people with MCP tools, which are named mcp__<server>__<tool>. A matcher of mcp__memory contains only exact-match characters, so it's compared as an exact string "and matches no tool." Write mcp__memory__.* to match every tool from that server.

The if field filters by arguments. A matcher sees only the tool's name; if uses permission rule syntax, like "Bash(git commit *)" or "Edit(*.ts)", to run a hook only for particular commands or files. It works on tool events only, and each handler takes one rule. Claude Code checks each part of a compound command, so Bash(git *) matches npm test && git push as well as a plain git push.

$CLAUDE_PROJECT_DIR is the project root where the session started. Use it to point at scripts, so a hook still finds them after Claude changes directory. One catch: if Claude moves into a worktree, $CLAUDE_PROJECT_DIR stays at the original root, while the cwd field in the hook's input follows Claude.

There are five handler types: command, http, mcp_tool, prompt and agent. Start with command. The docs mark agent hooks as experimental.

Exit codes: the rule that makes hooks silently fail

A command hook answers with its exit code:

  • Exit 0: no objection. The action goes ahead.
  • Exit 2: block. On events that can block, the action stops and whatever the script printed to standard error goes to Claude as the reason.
  • Any other code: a non-blocking error. The action goes ahead anyway.

That last one is the trap. Anthropic's reference: "Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use exit 2." A guard script that fails with exit 1 looks like it works and guards nothing.

For finer control, a hook can exit 0 and print JSON instead. A PreToolUse hook can answer allow, deny, ask or defer, and when several hooks disagree, "precedence is deny > defer > ask > allow." The second trap lives here: the decision has to sit inside hookSpecificOutput.

json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use the staging database, not production."
  }
}

Put permissionDecision at the top level and "Claude Code ignores the misplaced fields without reporting an error." Run claude --debug and search the log for Hook JSON output had unrecognized keys to catch it.

Six hooks that stop real mistakes

Save scripts in .claude/hooks/, make each one executable with chmod +x, and test it by piping sample JSON into it before you trust it. The scripts use jq to read their input.

1. Keep Claude out of secrets and lockfiles

Anthropic's own example. Save this script as .claude/hooks/protect-files.sh in your project:

.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

And register it in .claude/settings.json:

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

This covers Claude's file tools, not a shell command that writes the same file. To keep Claude from reading .env at all, add a deny rule in your permission settings as well.

2. No commits on main

When several sessions share a repository, one commit on main can land in the middle of someone else's work. This one is mine, built from the documented pieces. Save it as .claude/hooks/no-commit-on-main.sh in your project:

.claude/hooks/no-commit-on-main.sh
#!/bin/bash
# Blocks git commit on main or master.
INPUT=$(cat)
DIR=$(echo "$INPUT" | jq -r '.cwd')
BRANCH=$(git -C "$DIR" branch --show-current 2>/dev/null)

if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
  echo "Blocked: don't commit on $BRANCH. Create a branch or a worktree first." >&2
  exit 2
fi
exit 0
.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git commit *)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-commit-on-main.sh"
          }
        ]
      }
    ]
  }
}

The if rule means the script runs only for git commit, not for every shell command. It reads the branch from Claude's working directory, so a command that changes directory first (cd other && git commit) can slip past it. Anthropic calls if "best-effort," so pair this with a git pre-commit hook, which git runs whoever makes the commit.

3. Tests pass before Claude says it's done

A Stop hook runs when Claude finishes responding, and exit 2 sends it back to work with your message. This one is mine too. It runs the tests only when files have changed, and it uses the stop_hook_active check Anthropic recommends so it can't loop. Save it as .claude/hooks/tests-before-stop.sh in your project:

.claude/hooks/tests-before-stop.sh
#!/bin/bash
# Sends Claude back to work when files changed and the tests fail.
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0  # Claude already went back once; let it stop
fi

cd "$CLAUDE_PROJECT_DIR" || exit 0
if [ -z "$(git status --porcelain 2>/dev/null)" ]; then
  exit 0  # nothing changed
fi

if ! OUTPUT=$(npm test --silent 2>&1); then
  echo "The tests fail. Fix them before you finish:" >&2
  echo "$OUTPUT" | tail -n 20 >&2
  exit 2
fi
exit 0
.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-before-stop.sh"
          }
        ]
      }
    ]
  }
}

Swap npm test for your project's test command. Keep it fast, since it runs at the end of every reply that follows a change. Without the stop_hook_active check, Claude Code steps in "after it blocks eight times in a row without progress."

4. Format every file Claude edits

Anthropic's example, for projects that use Prettier:

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

A PostToolUse hook can't undo an edit, only act after it, so it suits cleanup, not guarding.

5. Restore context after compaction

When a long conversation is compacted, details get lost. A SessionStart hook with the compact matcher runs right after, and anything it prints is added to Claude's context. Anthropic's example uses a reminder; I use the current state of the repository:

json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "git status --short --branch && git log --oneline -5"
          }
        ]
      }
    ]
  }
}

6. Know when Claude needs you

The notification hook from the start of this guide, with the idle_prompt matcher, so it fires only when Claude has been waiting a minute.

Hooks and permissions

Hooks and permission rules work together, and the hooks guide spells out how:

  • A hook's deny always wins. When a hook denies a tool call, the tool is blocked "even in bypassPermissions mode or with --dangerously-skip-permissions." Changing the permission mode doesn't get around it.
  • A hook's allow doesn't. "Hooks can tighten restrictions but not loosen them past what permission rules allow." A hook can't approve something a deny rule forbids.
  • For a hard rule on a command, prefer a permission rule. The if filter is best-effort, and Anthropic says to "use the permission system rather than a hook to enforce a hard allow or deny." Use hooks for what permission rules can't express: checking the branch, running the tests, reading a file's contents.

Are Claude Code hooks safe?

A hook is a program running as you. Anthropic's warning: "Command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration."

Two situations deserve care:

  • A repository you cloned can bring its own hooks in .claude/settings.json. In an interactive session, Claude Code holds back every hook, even your own, until you accept the folder's trust dialog. Read the repository's .claude/ folder before you accept.
  • Scripted runs skip that dialog. With claude -p, Claude Code "never shows the dialog and treats the folder as trusted, so hooks committed in a repository's .claude/settings.json run in a folder you've never trusted." Before scripting Claude Code over someone else's repository, review its settings, or pass --settings '{"disableAllHooks": true}' to turn hooks off for that run, whatever the repository's settings say.

Plugins can carry hooks as well, so check a plugin's hooks/hooks.json before installing it. The Claude Code skills guide has the full checklist.

Hooks vs skills and CLAUDE.md

RunsUse it for
CLAUDE.mdRead at the start of every sessionRules and context Claude should always have
SkillLoaded when relevant, or when you type /nameA procedure Claude follows when the task calls for it
HookEvery time its event fires, as codeAnything that must happen without exception

A rule in CLAUDE.md that says "never commit to main" is a request. The hook above is a guarantee. Start with instructions, and promote a rule to a hook the second time Claude breaks it. Skills can also carry their own hooks in their frontmatter, active for the rest of the session once the skill is used. The Claude Code skills guide covers skills in full.

If you run more than one agent on a repository, the Agent Setup Pack takes the commit guard further: each agent gets its own git worktree, a detector names the other sessions working there, and git itself refuses a commit in the shared checkout. It works across Claude Code, Codex and Gemini CLI. The verification skill is free if you'd like to try one of our skills first.

Troubleshooting Claude Code hooks

The hook never runs. Run /hooks and check it's listed under the right event. Matchers are case-sensitive, and mcp__server without __.* matches nothing. Check the settings file is valid JSON: no trailing commas, no comments. File edits are normally picked up automatically; if not, restart the session.

It runs but doesn't block. Check the exit code: only 2 blocks. Test the script directly:

bash
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?

Its JSON is ignored. Look for decision fields at the top level instead of inside hookSpecificOutput. Or something printed before the JSON, often an echo in your shell profile: hooks run in a non-interactive shell, so wrap each such line in if [[ $- == *i* ]]; then ... fi to keep it out of hooks.

"command not found". Point at scripts with $CLAUDE_PROJECT_DIR or an absolute path, make them executable, and install jq if the script uses it.

Claude won't stop. A Stop hook that always exits 2 keeps sending Claude back. Check stop_hook_active as in example 3.

You need to see everything. claude --debug-file /tmp/claude.log writes each hook's matches, exit codes and output to that file.

Frequently asked questions

What are hooks in Claude Code?

Commands that Claude Code runs automatically at points in a session, such as before a tool call or when Claude finishes. They're set in settings files and can block the action they're attached to.

How many hook events are there?

33 on September 30, 2026, per Anthropic's hooks reference. New ones arrive every few months.

Can I turn hooks off?

Set "disableAllHooks": true in a settings file. There is no switch for a single hook: remove it from the file. Hooks set by your organization's managed settings keep running unless turned off there.

Do hooks work in claude -p and CI?

Yes, and they run without a trust prompt, which is why you should review a repository's hooks before scripting Claude Code over it.

Does Codex have hooks?

Codex has its own lifecycle hooks, which it manages with a /hooks command. This guide covers Claude Code's.

Every claim checked against its source on

From Heavy Artillery Skills

Agent Setup Pack

Give Claude Code, Codex and Gemini CLI one set of rules and check what each loads, run several agents on one repo, and keep API keys out of the chat.

More guides