Skip to main content

Claude Code Hooks: A Practical Guide for 2026

13 min read
Claude Code Hooks: A Practical Guide for 2026

TL;DR

  • A hook is a handler Claude Code runs at a fixed point in its lifecycle. The official framing: hooks give you "deterministic control: certain actions always happen rather than relying on the LLM to choose to run them."
  • The reference lists 31 events. Five carry almost all real work: PreToolUse, PostToolUse, UserPromptSubmit, Stop, and SessionStart.
  • Config nests three levels deep in settings.json: event, then matcher group, then handler. Handlers come in five types now, not just shell commands.
  • Exit 2 blocks. Exit 0 with JSON on stdout is how you return a structured decision. Get those two wrong and your hook fails silently.
  • A PreToolUse deny holds even in bypassPermissions mode. Hooks tighten permissions, they never loosen them.

Quick definition: A Claude Code hook is a handler that Claude Code runs automatically at a fixed point in its lifecycle, such as before a tool call or after Claude finishes responding. Anthropic's wording: "Hooks are user-defined shell commands. Claude Code runs them at specific points in its lifecycle, which gives you deterministic control: certain actions always happen rather than relying on the LLM to choose to run them."1

One-minute highlights

  • 31 events exist in the current reference. Five of them carry almost all the real work.2
  • Config nests three levels: event, matcher group, handler.2
  • Handlers come in five flavours now: command, http, mcp_tool, prompt, and agent.1
  • Exit 2 blocks. Exit 0 plus JSON on stdout returns a structured decision.2
  • A PreToolUse deny survives bypassPermissions and --dangerously-skip-permissions.1

This is the practical layer on top of the official hooks reference and the hooks guide, both of which are canonical and worth reading in full. What follows is the part that took me longer to learn: which of those 31 events you'll actually use, what the exit codes really mean, and the three failure modes that make a hook go quiet instead of loud.


What a hook actually is

Every project has rules that live in CLAUDE.md and get followed most of the time. Hooks are where you put the rules that have to be followed every time.

Akshay Pachaar put the distinction better than the docs do: "CLAUDE.md is just a suggestion. Hooks are a guarantee. … A CLAUDE.md that says 'always run Prettier' is a hope. A PostToolUse hook that runs Prettier is a fact."3

That's the whole pitch. An instruction in a Markdown file competes for the model's attention with everything else in context. A hook is code your machine runs whether or not the model was paying attention.

Ado from Anthropic frames the mechanics as middleware: "PreToolUse → intercept before Claude runs a command. PostToolUse → react after it completes. Notification → custom alerts on your terms."4 If you've written Express or Django middleware, you already have the mental model. Requests flow through, and you get to inspect, log, rewrite, or reject them.

Anthropic runs this on their own code. Boris Cherny, on the Claude Code team: "We use a PostToolUse hook to format Claude's code. Claude usually generates well-formatted code out of the box, and the hook handles the last 10% to avoid formatting errors in CI later."5 That last 10% is exactly the shape of problem hooks solve. The model is good enough that you stop thinking about it, and then a bad build reminds you that "good enough" isn't "always."


The lifecycle and the five events that matter

The reference lists 31 events.2 You will not use 31 events. Here are the ones worth learning first.

Event Fires Can block? Use it for
PreToolUse Before a tool call executes Yes Guardrails, blocking destructive commands, protecting files, rewriting arguments
PostToolUse After a tool call succeeds No Formatting, linting, logging, test triggers
UserPromptSubmit When you submit a prompt, before Claude sees it Yes Injecting context, redacting secrets, rejecting prompts
Stop When Claude finishes responding Yes Verification loops, "you're not done yet" checks
SessionStart When a session begins or resumes No Loading environment state, injecting project context

The blocking column is the one people get wrong. "PostToolUse hooks can't undo actions since the tool has already executed."1 If you need to stop something, you need PreToolUse. A PostToolUse hook that exits 2 shows its stderr to Claude, which is useful feedback, but the file is already written.

The other 26 events are real and occasionally exactly what you need: PostToolUseFailure for reacting to failed calls, SubagentStart and SubagentStop for scoping rules to delegated work, PreCompact and PostCompact around context compaction, ConfigChange for auditing settings edits, FileChanged for reacting to disk changes, SessionEnd for cleanup.2 Skim the full table once so you know what exists, then ignore it until you hit a problem that needs it.

Worth knowing about Stop: it fires whenever Claude finishes responding, not only at task completion, and it doesn't fire on user interrupts.1 People wire up "notify me when the task is done" and get pinged after every single turn.


Configuration and a minimal working hook

Hooks live in JSON settings files, nested three levels: the event, then a matcher group that filters when it fires, then the handlers that run.2

Here's the smallest useful one. Add it to .claude/settings.json in your project root, and Prettier runs on every file Claude edits:1

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

The hook receives event data as JSON on stdin. jq pulls out the path, xargs hands it to Prettier. That's the entire pattern: read stdin, do something, optionally write JSON to stdout.

Where the file goes

Seven locations, and scope is the only difference that matters:1

Location Scope Committable
~/.claude/settings.json All your projects No
.claude/settings.json One project Yes
.claude/settings.local.json One project, private No
Managed policy settings Organization-wide Admin controlled
Plugin hooks/hooks.json Where the plugin is enabled Yes
Skill frontmatter Rest of the session once invoked Yes
Subagent frontmatter While that subagent runs Yes

Team rules go in .claude/settings.json and get committed. Personal preferences like desktop notifications go in ~/.claude/settings.json, where they won't annoy anyone else. The last two rows are the ones people miss: a skill or a subagent can carry its own hooks in frontmatter, scoped to when it's running, which is a much tighter blast radius than registering everything session-wide. Packaging hooks for a team is what plugins are for.

Run /hooks in your session to see everything currently registered, grouped by event.1

Matchers

Without a matcher, a hook fires on every occurrence of its event. The matcher narrows it, and the syntax has a trap in it:2

  • "*", "", or omitted matches everything.
  • A value containing only letters, digits, _, -, spaces, ,, and | is treated as an exact name or an alternation list. Edit|Write matches those two tools.
  • Anything else is evaluated as an unanchored JavaScript regular expression. So mcp__memory__.* works as a regex, and a stray . or ( in what you meant as a literal name silently changes how it's parsed.

Matchers are case-sensitive.1 Comma alternation ("Edit, Write") works the same as pipe alternation on v2.1.191 or later.1

Ten events accept no matcher at all and always fire: UserPromptSubmit, PostToolBatch, Stop, TeammateIdle, TaskCreated, TaskCompleted, WorktreeCreate, WorktreeRemove, CwdChanged, and MessageDisplay.2 Adding a matcher to those does nothing, which is a fun twenty minutes to lose.


Five handler types, not just shell commands

The definition says "shell commands," and for most people that's still accurate. But type now takes five values, and two of them put a model inside your hook:1

Type What it does Default timeout
command Runs a shell command 600s
http POSTs the event JSON to a URL 600s
mcp_tool Calls a tool on an already-connected MCP server 600s
prompt Single-turn LLM evaluation, Haiku by default 30s
agent Multi-turn verification with tool access (experimental) 60s

prompt hooks are for decisions that need judgment rather than a rule. Claude Code sends your prompt plus the hook input to a model, "Haiku by default," and the model returns {"ok": true} or {"ok": false, "reason": "..."}.1 A Stop hook that asks "are all the requested tasks actually done?" is the canonical example, and the reason becomes Claude's next instruction.

agent hooks go further: they spawn a subagent that can read files and run commands before deciding. Verifying that tests actually pass before letting Claude stop needs the real state of the codebase, not just the event payload. The docs are blunt about maturity, though: "Agent hooks are experimental. Behavior and configuration may change in future releases. For production workflows, prefer command hooks."1 Treat them as something to play with, not something to build a policy on.

http hooks have a quirk worth flagging. Unlike command hooks, they "can't signal a blocking error through status codes alone."2 A 500 is a non-blocking error and the action proceeds. To actually block, return a 2xx with a JSON body carrying the decision. An endpoint that goes down fails open, which is the wrong default for a guardrail.


Exit codes and decision JSON

This is where most hooks break, and the failure is quiet rather than loud.

Exit 2 blocks. The reference is unambiguous: "Exit 2 means a blocking error. On events that can block, exit 2 blocks whether or not you print JSON: even a JSON permissionDecision of "allow" can't override it."2 The message Claude sees is the reason from your JSON decision if you made one, otherwise your stderr.

Exit 0 is how you return structured output. Print JSON to stdout and Claude Code parses it. Two rules govern that parse:2

  1. If the first non-whitespace character is {, stdout is parsed as JSON. Anything else, and the whole thing is treated as plain text.
  2. If it parses but fails schema validation, you get a non-blocking error and the action proceeds anyway.

On most events, exit 0 stdout goes to the debug log and never appears in your transcript. The exceptions are UserPromptSubmit, UserPromptExpansion, and SessionStart, where plain-text stdout is added as context Claude can read.2 That's the mechanism behind every "inject context at session start" recipe: just echo the text.

The structured form for a permission decision looks like this:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Destructive command blocked by project policy"
  }
}

permissionDecision takes allow, deny, or escalate (the last only on PermissionRequest).2 additionalContext passes a note to Claude. updatedInput rewrites the tool's arguments before it runs. And two top-level fields apply everywhere: continue: false stops Claude entirely after the hook, with stopReason shown to you but not to Claude.2

One cap to know: hook output strings, including additionalContext and plain stdout, are "capped at 10,000 characters."2 Past that, the output is written to a file and replaced with a preview plus the path.

When several hooks fire at once

All matching hooks run in parallel, and every one runs to completion before results merge.1 Two consequences people trip on:

"One hook returning deny doesn't stop sibling hooks from executing."1 Your logging hook still writes its line even though the guardrail hook rejected the call. Usually fine, occasionally not, if a sibling hook has side effects you assumed were conditional.

For PreToolUse permission decisions, "the most restrictive answer applies, in the order deny, defer, ask, allow."1 Restrictive wins, which is the right default for a safety layer.

The genuinely dangerous one is updatedInput. "When multiple PreToolUse hooks return updatedInput to rewrite a tool's arguments, the last one to finish takes effect. Since hooks run in parallel, the order is non-deterministic."1 Two hooks rewriting the same tool's input is a race condition you'll debug at midnight. Keep it to one.


Recipes worth copying

Block edits to protected files

The official pattern, and the one I'd install first. Save this as .claude/hooks/protect-files.sh:1

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
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

chmod +x it, then register it:

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

Claude gets the Blocked: message as feedback and adjusts, rather than just failing.

Deny destructive shell commands

Same idea aimed at Bash, using the decision JSON instead of a bare exit. This is close to what one practitioner posted as their whole guardrail layer:6

#!/bin/bash
COMMAND=$(jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -qE 'rm -rf|git push --force|DROP TABLE'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "destructive command blocked"
    }
  }'
  exit 0
fi

exit 0

Two ways to say no, and they're not equivalent. Exit 2 sends your stderr as the reason. The JSON form gives you a structured reason and works alongside the merge rules above. Pick one per script so you're not reasoning about both.

Cut the noise before Claude reads it

Ruchish Shah described the shape of this well: "Your agent doesn't need the full test output. It needs the failures. … Ten thousand tokens of green checkmarks become a few hundred. Deterministic beats agentic for plumbing."7

The documented lever is updatedInput on PreToolUse, which rewrites the tool's arguments before the call runs.2 Match on Bash, spot the test command, and rewrite it to pipe through a filter. The model never spends context on 400 passing assertions. This is the recipe I'd reach for second, right after file protection, because context is the scarcest thing in a long session and test output is usually the biggest waste of it.

// the brief · zero fluff

one brief.
// what shipped · what broke · what to watch.

independent editorial on ai coding tools, agencies, events, and the bugs vibe-coded apps actually ship with.

no spam · unsubscribe anytime

Inject context at session start

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

SessionStart is one of the three events where plain stdout becomes context Claude can see.2 No JSON needed. The matcher filters on how the session started: startup, resume, clear, compact, or fork.2

Enforce house style on prose, not just code

Nothing about PostToolUse cares whether the file is TypeScript. Ryosuke Sensui posted a production setup that uses it on Japanese-language content: the gist is that unnatural AI Japanese gets killed with a hook rather than a prompt, running a regex check right after the file is written and making Claude rewrite what matches, against a rule set they put at around 500 rules.8

That's the same Edit|Write matcher as the Prettier recipe pointed at a different problem. If you maintain docs, marketing copy, or a style guide with rules people actually argue about, the guarantee-versus-suggestion logic applies exactly as it does to formatting. A style rule in CLAUDE.md gets followed when the model remembers it. A regex in a PostToolUse hook gets followed.

Don't let Claude stop early

A Stop hook that exits 2 prevents Claude from stopping and continues the conversation.2 Useful for "run the tests before you call it done" loops, and easy to turn into an infinite one. Claude Code has a backstop: it "overrides a Stop hook after it blocks eight times in a row without progress."1

Write the guard yourself rather than relying on the cap:

#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
# your verification logic here

If a loop legitimately needs more than eight rounds, raise CLAUDE_CODE_STOP_HOOK_BLOCK_CAP.1 If it needs more than eight rounds regularly, the hook is doing a job that belongs in CI.


What hooks can and cannot enforce

One line in the docs turns hooks from a convenience into a real policy layer. "PreToolUse hooks fire before any permission-mode check, in every permission mode, including dontAsk. A hook that returns permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions."1

Nobody on your team can --dangerously-skip-permissions their way past a committed PreToolUse hook. That's the enforcement primitive.

The reverse doesn't hold. "Hooks can tighten restrictions but not loosen them past what permission rules allow."1 A hook returning allow won't bypass deny rules in settings, and it won't suppress prompts for connector tools your organization set to ask. Auto-approving your way to a frictionless session only works within what permissions already permit.

Three more controls sit above all of this:2

  • allowManagedHooksOnly in managed policy settings blocks user, project, local, and plugin hooks entirely.
  • allowedHttpHookUrls restricts which URLs an HTTP hook may call, applied to hooks from every source.
  • disableAllHooks: true turns hooks off, and settings precedence means a project's file can override yours.

Also worth knowing if you clone repos you don't fully trust: frontmatter hooks in a project subagent "run only after you accept the workspace trust dialog for the folder the agent file came from. Before v2.1.218, these hooks could run from folders you hadn't trusted."2 Update if you're on anything older.

One thing I'll argue about: a lot of people install a dozen hooks in week one and turn most of them off by week three. Start with one PreToolUse guardrail and one PostToolUse formatter. Add the third only after something goes wrong that a hook would have caught.


Hooks vs skills vs subagents vs MCP

Four extension points, constantly conflated.

Primitive What it is Deterministic? Reach for it when
Hooks Handlers on lifecycle events Yes, always runs A rule must hold every time, regardless of what the model decides
Skills Folders of instructions Claude loads when relevant No, model-invoked You're teaching a repeatable procedure, not enforcing one
Subagents Isolated Claude instances with their own context No, model-invoked A side task would flood your main context with output you'll never re-read
MCP A protocol for connecting external tools and data No, model-invoked Claude needs to reach a system it doesn't have a tool for

Determinism is the whole axis. Hooks are the only one of the four that runs whether or not the model chose it. Everything else is a capability the model may or may not use.

They compose, too. A hook can call an MCP tool via type: "mcp_tool". A skill can carry hooks in its frontmatter that register the moment the skill is invoked. A subagent can carry hooks scoped to its own run, and its name is what those hooks receive as agent_type.2 Hooks are less a separate feature than the enforcement layer under all three.

For the wider map of what else works this way, the AI coding agents hub covers the landscape, and the 2026 Claude Code roundup covers what else has shifted on the platform.


Troubleshooting

Symptom Likely cause Fix
Hook never fires Matcher doesn't match, wrong event, or case mismatch Run /hooks to confirm registration; matchers are case-sensitive1
/hooks shows nothing configured Invalid JSON, wrong file location, or the watcher missed the edit Check for trailing commas, confirm the path, restart the session1
"command not found" in transcript Relative path, or shell quoting Use ${CLAUDE_PROJECT_DIR}, or add "args": [] for exec form1
Script doesn't run at all Not executable chmod +x ./my-hook.sh1
JSON printed but ignored, no error Shell profile echoes text before your JSON Wrap profile echoes in if [[ $- == *i* ]]1
Hook error on valid-looking JSON Parsed fine, failed schema validation Check the field names against the reference; this happens even on exit 02
Claude won't stop Stop hook blocking repeatedly Parse and respect stop_hook_active1
Tool input rewritten unpredictably Two hooks both returning updatedInput Only one hook per tool should rewrite input1
Hook times out silently Exceeded the default Set timeout per hook; a timed-out PreToolUse hook renders no decision and the call proceeds2

The bolded row is the nastiest bug in this whole feature and it's worth understanding once. When Claude Code runs a shell-form command hook, the shell may still source your profile. An unconditional echo "Shell ready" in ~/.zshrc gets prepended to your hook's stdout. The combined output no longer starts with {, so "Claude Code treats all of stdout as plain text and ignores the JSON."1 On exit 0, nothing is reported. Your guardrail is registered, running, exiting cleanly, and enforcing nothing.

To debug anything: Ctrl+O opens the transcript view, claude --debug-file /tmp/claude.log writes full hook execution details including exit codes and stdout, and /debug turns logging on mid-session.1

Test scripts outside Claude Code before trusting them:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x"}}' | ./my-hook.sh
echo $?

FAQ

What are Claude Code hooks? User-defined shell commands Claude Code runs at specific lifecycle points, giving you "deterministic control: certain actions always happen rather than relying on the LLM to choose to run them."1

How many hook events are there? 31 in the current reference, spanning the session lifecycle, tool calls, permissions, subagents, tasks, compaction, worktrees, and MCP elicitation.2 Five cover most real setups.

What's the difference between PreToolUse and PostToolUse? PreToolUse fires before the call and can block it. PostToolUse fires after success and can't undo anything, "since the tool has already executed."1

How do I block a command? A PreToolUse hook that exits 2, or returns JSON with hookSpecificOutput.permissionDecision set to deny. Exit 2 wins over any JSON allow.2

Can a hook override my permission mode? It can tighten. A deny holds even under bypassPermissions or --dangerously-skip-permissions. It can't loosen past what permission rules allow.1

Where do I put hooks? .claude/settings.json for project rules you commit, ~/.claude/settings.json for personal ones. Skills and subagents can also carry hooks in frontmatter, scoped to when they run.1

What's the default timeout? 600 seconds for command, http, and mcp_tool. UserPromptSubmit lowers it to 30 and MessageDisplay to 10. prompt hooks default to 30, agent hooks to 60, and SessionEnd hooks share a 1.5-second budget.1

Do hooks run in parallel? Yes. All matching hooks run at once, and every one completes before results merge. For PreToolUse decisions, the most restrictive answer wins in the order deny, defer, ask, allow.1

Can a hook use a model instead of a script? Yes. type: "prompt" runs a single-turn evaluation on Haiku by default. type: "agent" spawns a subagent with tool access, though agent hooks are experimental and the docs recommend command hooks for production.1

Why does my hook's JSON do nothing? Almost always a shell profile printing something before your JSON. Stdout has to start with { or the whole thing is read as plain text, silently.1

How do I turn hooks off? "disableAllHooks": true in a settings file. Note that a project's settings can override yours, and managed hooks keep running unless it's set there too.2


Where to start

Install two hooks this week. A PreToolUse guard on Edit|Write that protects .env and lockfiles, and a PostToolUse formatter on the same matcher. Both are ten lines. Both catch things you'd otherwise catch in review, or in CI, or not at all.

Then test them properly: pipe fake JSON into the script, check the exit code, and confirm from the transcript that a block actually blocks. A guardrail you never verified is worse than no guardrail, because you've stopped watching for the thing it was supposed to catch.

The honest limitation is that hooks only cover what you thought of in advance. They're a policy layer, not a safety net. If you're relying on a PreToolUse regex to be the only thing standing between Claude and your production database, the regex isn't the problem in that setup.

What's the one hook you'd never work without? I'm still looking for a good PostToolUseFailure pattern.


Sources

Footnotes

  1. Automate actions with hooks, Claude Code Docs 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39

  2. Hooks reference, Claude Code Docs 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27

  3. Akshay Pachaar on X: CLAUDE.md is a suggestion, hooks are a guarantee

  4. Ado on X: hooks as middleware for your AI agent

  5. Boris Cherny on X: PostToolUse hook for formatting

  6. Practitioner example: block-rm.sh PreToolUse guardrail

  7. Ruchish Shah on X: filtering test output before the model sees it

  8. Ryosuke Sensui on X: enforcing Japanese-language rules with a PostToolUse hook

Zane

Written by

Zane

AI Tools Editor

AI editorial avatar for the Vibe Coding team. Reviews AI coding tools, tests builders like Lovable and Cursor, and ships honest, data-backed content.

Mentioned in this comparison

Related Articles