Guide

Claude Code hooks: what they are, with examples that work

A hook runs a command at a set point in a Claude Code session, every time, whether or not Claude remembers your instructions. Here’s how to set one up, five examples to copy, and how to fix one that doesn’t fire.

Checked against Anthropic’s hooks guide on November 4, 2026. We ran the two blocking hooks, the log and a session-start hook in a scratch project on Claude Code 2.1.318. The notification and Prettier examples come from the guide; we didn’t run those two. Claude Code changes often, so run claude update if yours behaves differently.

What hooks are

Hooks are commands you put in a settings file. Claude Code runs them when a matching event fires. That makes them different from everything else you can tell Claude: CLAUDE.md, skills and memory are read and weighed, and a hook just runs.

So hooks suit rules with no judgment in them: format after every edit, never touch .env, notify me when you’re waiting. Most are shell commands, and there are also HTTP, MCP tool, prompt and agent types. The prompt and agent types ask a Claude model to decide.

Your first hook

This one sends a Mac notification when Claude is waiting for you. Add it to ~/.claude/settings.json, creating the file if it doesn’t exist:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs you\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}
  1. If the file already has a hooks key, add Notification inside it. Don’t replace the whole object.
  2. Type /hooks in Claude Code. Your hook should be listed under Notification.
  3. Ask Claude to do something that needs permission, then switch to another window.

No notification? osascript routes through the Script Editor app and fails silently without permission. Run osascript -e 'display notification "test"' once, then allow Script Editor under System Settings › Notifications. Anthropic’s guide has the Linux and Windows versions.

The events you’ll use

There are more than 30 events. These cover most real hooks:

The Claude Code hook events used most
EventWhen it firesGood for
PreToolUseBefore a tool call. Can block itProtecting files, stopping risky commands
PostToolUseAfter a tool call succeeds. Can’t undo itFormatting, linting, logging
UserPromptSubmitWhen a prompt is submittedAdding context to a message
SessionStartWhen a session begins or resumesLoading current state
NotificationWhen Claude Code sends a notificationDesktop alerts
StopWhen Claude finishes responding, not only when a task is doneChecks at the end of a turn
PreCompactBefore context compactionSaving something before it’s summarized

A matcher narrows an event. On tool events it matches the tool name, so Edit|Write means those two tools and mcp__github__.* means every tool on one MCP server. On SessionStart it matches how the session began: startup, resume, clear, compact or fork. Some events, such as Stop and UserPromptSubmit, don’t support matchers and fire every time.

Where you put the block sets its reach: ~/.claude/settings.json for you everywhere, .claude/settings.json to commit it for the team, .claude/settings.local.json for you in one project. That last file is gitignored only when Claude Code itself saves to it, so check your .gitignore if you create it by hand.

Five examples to copy

The scripts use jq to read the JSON that Claude Code sends on stdin. Install it first, with brew install jq on a Mac.

1. Protect sensitive files

Save this script, and run chmod +x on it, because hook scripts must be executable:

#!/bin/bash
# .claude/hooks/protect-files.sh
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
case "$FILE_PATH" in
  */.env|*/.env.*|*/package-lock.json|*/.git/*)
    echo "Blocked: $FILE_PATH is protected. Tell me what you wanted to change." >&2
    exit 2
    ;;
esac
exit 0

Then register it in .claude/settings.json:

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

In our test, Claude tried to write .env, the write was refused, and Claude reported the block and stopped there. environment.md still went through. The match is on the path Claude edits, so this doesn’t stop a Bash command that writes the same file. Add a Bash check if that matters.

2. Block force pushes

#!/bin/bash
# .claude/hooks/no-force-push.sh, with the matcher set to Bash
COMMAND=$(jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -Eq 'git push.* (--force|-f)( |$)'; then
  echo "Blocked: no force pushes. Open a pull request instead." >&2
  exit 2
fi
exit 0

We checked the pattern against git push origin main --force and git push -f (both blocked), and against a branch called feature-f and --force-with-lease (both allowed). It’s a guardrail against a mistake, not a security boundary, since a script or alias can run the same push under another name. Pair it with branch protection on your Git host.

3. Format after every edit

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

A successful hook shows nothing in the conversation, so confirm this one by looking at the file. Swap Prettier for your own formatter.

4. Keep a log of what changed

Use the same PostToolUse block with this command:

jq -r '.tool_input.file_path' >> "$CLAUDE_PROJECT_DIR"/edited.log

5. Load current state at the start

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "git log --oneline -5"
          }
        ]
      }
    ]
  }
}

What the command prints is added to Claude’s context. Use the compact matcher instead to bring something back after compaction. /compact vs /clear explains what compaction keeps.

Exit codes

This is the part people trip on:

  • Exit 0: no objection. On PreToolUse that doesn’t approve the call. It still goes through the normal permission flow.
  • Exit 2: block, on the events that can block. Write the reason to stderr, not stdout. Some events can’t be blocked: on SessionStart, exit 2 only shows the message.
  • Any other code: usually a non-blocking error. You see a “hook error” notice and the action goes ahead. So a script that crashes doesn’t protect anything, unless you set onFailure to "block".

For finer control, exit 0 and print JSON instead. Don’t mix the two approaches in one hook. permissionDecision and additionalContext belong inside hookSpecificOutput; at the top level they’re ignored with no error.

When a hook doesn’t work

It isn’t listed in /hooks:

  • The JSON is invalid. Trailing commas and comments aren’t allowed.
  • The file is in the wrong place. Project hooks go in .claude/settings.json at the repository’s root.
  • The change wasn’t picked up. Edits normally apply on their own; restart the session if they don’t.
  • The menu says “Only hooks from managed settings run here”, which means your organization has turned off the others.

It’s listed but never fires:

  • The matcher is case-sensitive and matches the tool name, so edit won’t match Edit.
  • You picked the wrong event for what you’re testing.
  • Claude changed the file with a Bash command, so Edit|Write never matched.

You see “hook error”: run the script by hand with sample input and read the exit code.

echo '{"tool_name":"Edit","tool_input":{"file_path":"/repo/.env"}}' \
  | ./.claude/hooks/protect-files.sh
echo $?   # 2 means blocked
  • “command not found”: use an absolute path or $CLAUDE_PROJECT_DIR.
  • “jq: command not found”: install jq.
  • The script doesn’t run at all: chmod +x it.

It runs and has no effect: something printed before your JSON, usually an echo in your shell profile, so the output doesn’t start with { and is read as plain text. A misplaced field does the same. Start with claude --debug and look in the log.

Claude won’t stop: a Stop hook that keeps blocking. Claude Code overrides it after eight blocks in a row. Check stop_hook_active in the input and exit early when it’s true.

To see what a hook did, press Ctrl+O for the transcript view.

Hooks, skills and CLAUDE.md

Hooks, skills, CLAUDE.md and permission rules
What it isUse it for
HookA command that runs when an event firesWhat must always or never happen
SkillInstructions and commands Claude can useHow to do a kind of work
CLAUDE.mdInstructions loaded at the start of a sessionConventions and project facts
Permission rulesAllow and deny rules for toolsHard limits on what can run

If Claude keeps breaking a rule in CLAUDE.md and the rule has no exceptions, it’s a candidate for a hook. Anthropic’s guide also says to use permission rules rather than a hook’s if filter for a hard allow or deny, because that filter is best-effort.

Hooks and memory

A SessionStart hook can put text in front of Claude, but it can’t work out what the text should be. Anything you print has to come from somewhere: a file you maintain, git log, or a tool that keeps track for you. How to make Claude Code remember between sessions covers the options that don’t need a hook.

We make Cachejay, which uses hooks this way. Connecting it to Claude Code adds two hooks to ~/.claude/settings.json. SessionStart prints where you left off on the project in that folder, from your earlier sessions, calls and screen. UserPromptSubmit adds what’s on your screen when your message points at something you can see, like “fix this error”. Both print nothing when there’s nothing useful, and Cachejay also keeps the sessions so other agents can search them. Setup is on the Claude page.

Questions

What are Claude Code hooks?

Commands you configure that Claude Code runs at set points in a session: before a tool call, after one, when a session starts, when Claude finishes responding. They always run when their event fires, so they don’t depend on Claude choosing to follow an instruction.

Where do Claude Code hooks go?

In a hooks block in a settings file: ~/.claude/settings.json for all your projects, .claude/settings.json for one project and your team, or .claude/settings.local.json for one project and only you. Plugins, skills and subagents can also define them.

What is the difference between hooks and skills?

A skill gives Claude instructions and it decides how to apply them. A hook is a command that runs when its event fires, whatever Claude thinks. Use a skill for how to do a kind of work, and a hook for what must always or never happen.

Why is my Claude Code hook not working?

Run /hooks first. If it isn’t listed, check that the JSON is valid and the file is in the right place. If it’s listed but doesn’t fire, check the event and the matcher, which is case-sensitive. If it fires and fails, pipe sample JSON into the script by hand and read the exit code.

Can a hook block Claude Code from doing something?

Yes. A PreToolUse hook that exits with code 2 cancels the tool call, and its stderr goes to Claude as the reason. A hook’s deny holds in every permission mode, including bypassPermissions.

Do Claude Code hooks work on Windows?

Yes. Anthropic’s guide has PowerShell examples. One failure specific to Windows is Claude Code not finding Git Bash, and the docs cover how to fix it.

Can a hook give Claude Code memory?

A SessionStart hook can: Claude Code adds what the command prints to Claude’s context. The hook only delivers text, so something still has to keep that text current. For fixed reminders, CLAUDE.md is simpler.

Stop explaining your day to ChatGPT

Install it, connect the apps you use, and get on with your work. Free for 14 days, no card.