Cream illustration of a terminal window with a chain of connected steps representing an event hook

Claude Code Hooks: The Complete Guide (2026)

July 8, 2026 · SkillProof test team · 12 min read

A skill can be ignored. That’s not a flaw in skills, it’s the whole design: Claude reads the description, decides whether the current task matches, and loads the body only if it thinks so. Most of the time that judgment call is right. Sometimes it isn’t, and the task where it matters most, the code review right before a merge, the log entry that should exist no matter what, is exactly the task where “probably” isn’t good enough.

Hooks are the other half of Claude Code. A hook is a shell command the harness runs when a specific event fires, whether or not any skill would have thought to. No model judgment sits between the event and the command. It runs, every time, in order, and its exit code can even stop Claude cold. If you’ve ever wanted to say “always format this file after an edit” or “never let Claude touch this directory,” a hook is the tool built for that sentence.

This guide covers what hooks are, the settings.json schema behind them, six recipes you can paste in today, how hooks and skills work together, and the failure modes that eat an afternoon if you don’t know to look for them.

What a hook actually is

Claude Code fires named events during a session: before a tool runs, after a tool runs, when Claude finishes responding, when a notification would show. A hook binds a shell command to one of those events, optionally filtered to specific tools. The harness runs your command, passes it context as JSON on stdin, then reads the exit code to decide what happens next.

The events you’ll use most:

  • PreToolUse — fires before a tool call executes. A hook here can block the call outright.
  • PostToolUse — fires after a tool call finishes. Good for formatting, testing, or logging what just happened.
  • Stop — fires when Claude finishes its turn and is about to hand control back to you.
  • Notification — fires when Claude Code would show you a system notification (permission requests, idle prompts).
  • UserPromptSubmit — fires when you submit a message, before Claude sees it.

That’s the mechanism. The reason it matters is the guarantee it gives you that a skill structurally cannot.

The mental model: deterministic vs discretionary

This is the one idea worth remembering if you remember nothing else from this guide.

A skill is discretionary. Claude reads its description at the start of a session, and later decides, based on your request, whether to load and follow it. Good skills trigger reliably, but “reliably” is still a probability, not a guarantee. Claude can misread an ambiguous prompt, or two skills can have overlapping descriptions that confuse the match, a failure mode we cover in more depth in our guide to why skills fail to trigger.

A hook is deterministic. It doesn’t ask Claude whether to run. It doesn’t read a description and judge relevance. The harness sees the event, and the command runs, full stop. If the event is PostToolUse on the Edit tool, your formatter runs after every edit, including the one Claude made while thinking about something else entirely.

That difference maps directly onto when to reach for which:

SkillHook
Runs whenClaude judges it relevantEvery time the event fires
Can be skippedYes, by a bad match or busy contextNo
Best forJudgment, structure, “how to do X well”Enforcement, “X must always happen”
Failure modeSilent non-triggerSilent bad exit code, or blocking everything

If the sentence you’re enforcing starts with “Claude should always…” or “Claude must never…”, you want a hook. If it starts with “when Claude is doing X, it should approach it like…” you want a skill. Formatting code after every edit is a hook; writing idiomatic Python is a skill. Blocking commits to main is a hook; structuring a good commit message is a skill.

Anatomy of a hook in settings.json

Hooks live under the hooks key in .claude/settings.json (project-level) or ~/.claude/settings.json (user-level), a different file from CLAUDE.md and worth not confusing: CLAUDE.md is prose Claude reads, settings.json is config the harness executes. If you haven’t set up either yet, our CLAUDE.md guide and our full setup walkthrough cover the rest of the stack this file lives inside. Here’s a minimal but complete hooks example, annotated:

{
  "hooks": {
    // The event name — PreToolUse, PostToolUse, Stop, Notification, etc.
    "PostToolUse": [
      {
        // matcher filters which tool calls trigger this hook.
        // Omit it (or use "*") to match every tool.
        "matcher": "Edit|Write",
        "hooks": [
          {
            // "command" is currently the only hook type.
            "type": "command",
            // The shell command to run. Receives event JSON on stdin.
            "command": "npx prettier --write \"$(echo $CLAUDE_TOOL_INPUT | jq -r .file_path)\"",
            // Optional: kill the command if it hangs.
            "timeout": 15
          }
        ]
      }
    ]
  }
}

A few things worth pointing out because they trip people up:

The matcher field operates on the tool name, not on file paths or content. "Edit|Write" matches the Edit and Write tools; "Bash" matches shell calls. If you need to filter by file path or command content, do that inside your script by reading the JSON payload, not in the matcher.

Each event key holds an array of matcher blocks, and each matcher block holds an array of hook commands, so you can attach several commands to one matcher, or one command to several matchers, without duplicating config.

The command receives the event payload as JSON on stdin: tool name, tool input, and for PostToolUse, the tool’s result. A hook acting on the specific file being edited reads that JSON rather than assuming the shell’s working directory tells the whole story.

Exit codes carry meaning. Exit 0 means “fine, continue.” A nonzero exit on a PreToolUse hook blocks the tool call and feeds stderr back to Claude as a reason. A nonzero exit on PostToolUse just gets logged; the tool already ran, so there’s nothing left to block.

Six recipes you can use today

These are deliberately narrow. Copy the block, adjust the command, and confirm it does what you expect on a throwaway file before trusting it on real work.

1. Auto-format after every edit

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "cd \"$CLAUDE_PROJECT_DIR\" && npx prettier --write . --ignore-unknown"
          }
        ]
      }
    ]
  }
}

Runs Prettier after any edit or write. For large repos, swap the blanket . for a path derived from the hook’s JSON input so you only format the touched file.

2. Block edits to protected paths

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 .claude/hooks/guard_paths.py"
          }
        ]
      }
    ]
  }
}

guard_paths.py reads the file path from stdin JSON, checks it against a denylist (migrations/, .env, infra/prod/), and exits 1 with a message on stderr if it matches. This is the closest thing to a hard permission boundary Claude Code has.

3. Run tests after source changes

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "cd \"$CLAUDE_PROJECT_DIR\" && npm test -- --onlyChanged --silent"
          }
        ]
      }
    ]
  }
}

Feeds Claude an immediate signal when an edit breaks a test, instead of waiting for you to notice at review time. Keep the test command narrow (--onlyChanged, a fast subset) or this becomes recipe six in the “when not to” section below.

4. Desktop notification when Claude finishes

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude finished\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

macOS-specific (swap for notify-send on Linux). Useful once you start running longer autonomous turns and stop watching the terminal the whole time.

5. Log every bash command

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' >> \"$CLAUDE_PROJECT_DIR/.claude/bash-history.log\""
          }
        ]
      }
    ]
  }
}

An audit trail that doesn’t depend on you remembering to check the transcript. On a shared machine or a repo with a compliance requirement, this is close to mandatory.

6. Lint gate before commit

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block_bad_commit.sh"
          }
        ]
      }
    ]
  }
}

block_bad_commit.sh reads stdin, checks whether the command is a git commit, and if so runs your linter first, exiting nonzero if it fails. That turns “please lint before committing” from a request Claude might forget into a rule it can’t get past.

FREE STARTER PACK

Setting up hooks alongside your first skills? We'll send our 3 top-scored skills plus the install checklist we run before every SkillProof test. Free.

Get the free starter pack

Hooks and skills together

They’re not competing tools; the best setups use both for what each is good at. A worked example: a team we tested wanted every commit written in their house style, imperative mood, a scoped prefix, a body explaining why, and they also wanted commits blocked if the diff touched a database migration without a matching rollback file.

The style part is judgment. What counts as a good “why” varies by change, and there’s no shell script that reliably writes good prose. That’s a skill’s job: something like Git Workflow Coach loaded whenever Claude is about to commit, teaching the structure and giving examples of good versus lazy commit messages. Claude reads it, applies judgment, and writes a message that fits the pattern without being a template fill. If the team also runs strict red-green-refactor, Test-Driven Development is the same kind of judgment-not-law addition: it shapes how Claude approaches the work, a hook can’t do that part.

The migration rule is not judgment, it’s law: either the rollback file exists or it doesn’t, and the team didn’t want “Claude decided this one didn’t need it” to be an option. That’s the PreToolUse hook from recipe six, adapted to check for the paired file instead of running a linter, blocking the git commit call outright if it’s missing.

Run them together and you get a good commit message that’s also guaranteed to pass the migration check, because the skill handles the part that needs a brain and the hook handles the part that needs a wall. Neither replaces the other. The skill can’t guarantee compliance, and a hook writing “fix: various changes” on every commit would be useless. Our best coding skills page ranks the judgment side of this pairing by tested score, if you’re picking a first skill to run alongside your hooks.

Debugging hooks

Hooks fail quietly more often than loudly. What usually breaks:

Quoting. Hook commands are shell strings inside JSON strings, so a " that isn’t escaped breaks the JSON parse before your command ever runs. When in doubt, put the real logic in a script file and have the hook command just invoke it (bash .claude/hooks/my-hook.sh) rather than inlining a complex one-liner.

Exit codes that don’t mean what you think. A hook script that hits an unrelated error (missing dependency, permission denied) exits nonzero the same as a hook that deliberately wants to block. If a PreToolUse hook starts blocking every tool call and you didn’t write it to be that strict, check whether the script is actually failing rather than judging.

PATH assumptions. Hooks run in a shell environment that may not match your interactive terminal. A command that works fine when you type it yourself can fail inside a hook because nvm, a virtualenv, or a tool installed via a shell plugin isn’t on PATH in that context. Use absolute paths to binaries, or source the right environment at the top of the script.

Silent stdin assumptions. If your script expects JSON on stdin and doesn’t get it, because you tested it by running it directly rather than piping in a sample payload, it’ll behave differently under the harness than it did on your terminal.

Timeouts. A hook with no timeout that hangs will hang the whole turn. Set an explicit timeout on anything that touches the network or a slow subprocess.

When not to use hooks

Hooks are cheap to write and easy to overuse. The failure mode isn’t a hook doing the wrong thing, it’s a hook doing the right thing too often. A PostToolUse hook that runs your full test suite after every single edit turns a five-second change into a two-minute wait, repeated for every edit in a session that makes twenty of them.

The rule of thumb: if a hook’s command takes more than a second or two, narrow the matcher, narrow what it checks, or move it to a less frequent event. Test-on-every-edit becomes test-on-file-write becomes test-before-commit as the check gets more expensive. Match the hook’s cost to how often its event fires, and consider whether a skill, which only loads context and doesn’t run a process, is a better fit for anything that isn’t strictly enforcement.

It’s also worth not reaching for a hook to fix a skill’s trigger problem. If a skill isn’t firing when it should, the fix is a better description, not a hook, since hooks run shell commands and can’t load skill content. For that failure mode, see why skills don’t trigger.

SKILLPROOF PACK

Pairing hooks with the right skills is most of a good Claude Code setup. The Developer Toolkit bundles our top-scored coding skills, pre-checked for trigger conflicts, so the skill half of this pairing is done for you.

Get the Developer Toolkit — $10

FAQ

Do hooks slow down every Claude Code session?

Only the events you attach them to, and only by however long your command takes. A hook on PostToolUse for Edit runs once per edit; a fast formatter is unnoticeable, a full test suite is felt on every edit, which is the case covered above under when not to use hooks.

Can a hook stop Claude from doing something entirely?

Yes, that’s what PreToolUse hooks are for. Exit nonzero and the tool call is blocked before it runs, with stderr typically surfaced back to Claude as the reason. That’s the mechanism behind recipe two (protected paths) and recipe six (lint gate).

Where do I put my hooks config, project or user settings?

Project-level (.claude/settings.json, committed) if it should apply to everyone on that codebase: formatting, protected paths, migration checks. User-level (~/.claude/settings.json) for a personal preference, like the desktop notification in recipe four.

What’s the difference between a hook and a skill that says “always format code”?

The hook actually always runs. A skill telling Claude to always format code is still an instruction Claude reads and decides to follow; it’s a strong nudge, not a guarantee, and it competes with other things in context for attention on any given turn. If “always” is a requirement rather than a preference, use a hook.

My hook isn’t running at all. What’s the first thing to check?

Confirm the settings file is valid JSON (a trailing comma or unescaped quote can silently disable the whole hooks block) and that the event name and matcher are spelled exactly as expected; both are case-sensitive. After that, check whether you edited project settings when the session is reading user settings, or vice versa.

★ 9.6/10 × 3

The free starter pack

3 skills with our highest test scores plus the install checklist — the setup we'd put on a fresh machine. Free, by email.

One email with the pack + a short weekly digest of new test results. Unsubscribe anytime.