05 / 20Automation7 MIN READ

Hooks

A hook is a shell command, HTTP endpoint, MCP tool call, or model prompt that Claude Code runs at a fixed point in the session lifecycle, with the power to block the action that triggered it.

An event travelling through a hook pipelineAn event moves left to right through four stages: the prompt, a PreToolUse hook that can block it, the tool call itself, and a PostToolUse hook. A marker shows where a blocking hook stops the event.PROMPTPRETOOLPOSTexit 2UserPromptSubmitPreToolUseBash / EditPostToolUse

What it is

Hooks are configured in JSON settings files with three levels of nesting: the event, a matcher group that filters when it fires, and one or more handlers that run when it matches. Claude Code sends the handler a JSON payload — on stdin for a command hook, as the POST body for an HTTP hook — and reads the result back from the exit code and stdout.

The events fall into three cadences. SessionStart and SessionEnd fire once per session. UserPromptSubmit, Stop, and StopFailure fire once per turn. PreToolUse and PostToolUse fire on every tool call inside the agentic loop, with EndConversation the one exception that skips both. Beyond those, Claude Code fires PermissionRequest, PermissionDenied, PostToolUseFailure, PostToolBatch, SubagentStart, SubagentStop, PreCompact, PostCompact, Notification, MessageDisplay, TaskCreated, TaskCompleted, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult, and Setup.

This is the enforcement layer. CLAUDE.md asks Claude to do something and Claude usually complies; a hook is code Claude Code executes whether or not Claude agrees. Hooks also run inside subagents, and the payload carries agent_id and agent_type so a handler can tell the difference.

What it does for you

  • It makes a rule hold. "Always run the formatter after editing" is a coin flip as an instruction and a certainty as a PostToolUse hook on Edit|Write.
  • It blocks actions before they happen. A PreToolUse hook that exits 2 stops the tool call and hands your stderr text to Claude as the reason, which takes precedence even over an allow rule.
  • It gives you an audit trail. A hook on Bash appends every command to a log file, independent of what the transcript shows or what Claude reports.

How it works

  1. 01An event fires and matchers are evaluated

    The matcher filters on a different field per event: tool name for tool events, start reason for SessionStart, agent type for SubagentStart. A matcher of only letters, digits, _, -, spaces, ,, and | is an exact string or a list; anything else is an unanchored JavaScript regular expression.

  2. 02The optional if condition narrows further

    On tool events, if uses permission rule syntax against the tool name and arguments together, so "Bash(git *)" or "Edit(*.ts)" decides whether the handler process is spawned at all. Each if holds exactly one rule — no && or ||.

  3. 03All matching handlers run in parallel

    Command handlers get the event JSON on stdin and run in the current directory with Claude Code’s environment. Defaults are 600 seconds for command, HTTP, and MCP tool hooks, 30 for prompt hooks, and 60 for agent hooks.

  4. 04Exit 0 means success

    Stdout is parsed for JSON output fields. For most events stdout goes only to the debug log; UserPromptSubmit, UserPromptExpansion, and SessionStart are the exceptions, where stdout becomes context Claude can act on.

  5. 05Exit 2 means block

    Stdout is ignored and stderr is fed to Claude as the reason. PreToolUse blocks the tool call, UserPromptSubmit erases the prompt, Stop prevents Claude from stopping, PreCompact blocks compaction. PostToolUse cannot block — the tool already ran — but Claude does see the stderr.

  6. 06Any other exit code is a non-blocking error

    Exit 1 does not block. The action proceeds and the transcript shows a hook error notice. WorktreeCreate is the one exception, where any non-zero code aborts creation.

How to implement it

  1. 01Pick the event and the narrowest matcher

    Decide whether you need to block something before it happens (PreToolUse) or react to something that already did (PostToolUse), then set matcher to the exact tool names involved.

  2. 02Write the handler script and make it executable

    Put it in .claude/hooks/, read the JSON from stdin, and chmod +x it. Reference it as ${CLAUDE_PROJECT_DIR}/.claude/hooks/<name>.sh so it resolves regardless of the working directory.

  3. 03Return the right exit code

    Exit 0 for "no opinion", exit 2 with a message on stderr for "stop". Do not use exit 1 to enforce a policy — it is treated as a non-blocking error.

  4. 04Add an if condition to avoid spawning processes you do not need

    A hook matched on Bash runs on every shell command. Adding "if": "Bash(rm *)" means the script only spawns for the calls it cares about.

  5. 05Register it and confirm it fires

    Add the block to .claude/settings.json, then run /hooks to see the configuration and trigger the event to confirm. Settings files reload without a restart.

Examples

.claude/settings.jsonjson
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh",
            "statusMessage": "Formatting",
            "timeout": 60
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Edit|Write|NotebookEdit",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-paths.sh"
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '\"[\" + (now | todate) + \"] \" + .tool_input.command' >> \"$HOME/.claude/bash-audit.log\""
          }
        ]
      }
    ]
  }
}
Three hooks: format after every edit, refuse writes to protected paths, and log every shell command.
.claude/hooks/protect-paths.shbash
#!/bin/bash
# Refuse edits to generated and vendored files.
# Registered on PreToolUse for Edit|Write|NotebookEdit.
set -euo pipefail

payload=$(cat)
path=$(jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' <<<"$payload")

[[ -z "$path" ]] && exit 0

case "$path" in
  */db/schema.rb|*/package-lock.json|*/vendor/*|*.generated.ts)
    echo "Refusing to edit $path: it is generated or vendored. Change the source and regenerate." >&2
    exit 2
    ;;
esac

exit 0
The blocking half. Exit 2 stops the write and hands the stderr text to Claude as the reason.
.claude/hooks/format.shbash
#!/bin/bash
# Format the file Claude just wrote. Registered on PostToolUse for Edit|Write.
set -euo pipefail

path=$(jq -r '.tool_input.file_path // empty')
[[ -z "$path" || ! -f "$path" ]] && exit 0

case "$path" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
    if ! npx --no-install prettier --write "$path" 2>/dev/null; then
      echo "prettier failed on $path; leaving it unformatted" >&2
      exit 2   # the edit already landed, but Claude sees this message
    fi
    ;;
  *.py)
    command -v ruff >/dev/null && ruff format "$path" >/dev/null
    ;;
esac

exit 0
The reactive half. Exit 2 here cannot undo the edit, but it does surface the failure to Claude.

Use it when

  • Running a formatter or linter after every file edit so the diff is always clean.
  • Blocking edits to generated files, lockfiles, or migrations that have already shipped.
  • Appending every Bash command to an audit log outside the transcript.
  • Injecting environment context at SessionStart, whose stdout Claude can read.
  • Failing a Stop hook when the test suite is red, so Claude keeps working instead of handing back a broken tree.

Avoid it when

  • The check is advisory. A hook on every Edit adds a process spawn to every edit; if you only want a nudge, CLAUDE.md costs nothing at tool-call time.
  • The command is slow. Hook latency lands directly in your turn, and /doctor will flag the slow ones. Move anything expensive to CI.
  • You are trying to shape what Claude writes rather than gate what it does. Hooks see tool calls, not intent — an output style or CLAUDE.md is the right layer.
  • You cannot express the condition mechanically. A hook that guesses gets in the way on every false positive, and Claude has no way to argue with it.

Common mistakes

  • SYMPTOMThe hook detects a violation, exits 1, and the tool call proceeds anyway.

    FIXOnly exit code 2 blocks. Exit 1 is a non-blocking error for every event except WorktreeCreate. Use exit 2 and put the reason on stderr.

  • SYMPTOMA PostToolUse hook prints a warning and Claude never mentions it.

    FIXStderr from a hook that exits 0 goes to the debug log only. Exit 2 from PostToolUse so Claude sees the message, even though the tool already ran.

  • SYMPTOMA matcher written as mcp__memory never fires.

    FIXThat string contains only exact-match characters, so it is compared literally against tool names and matches nothing. Write mcp__memory__.* to match every tool from the server.

  • SYMPTOMA hook on Bash spawns a process for every command and the session feels sluggish.

    FIXAdd an if condition such as "Bash(rm *)". The handler only spawns when the rule matches, so ordinary commands cost nothing.

  • SYMPTOMA hook tries to prompt you and hangs, or its output never appears.

    FIXCommand hooks run without a controlling terminal and cannot open /dev/tty. Return systemMessage in JSON output to show text, or terminalSequence for a desktop notification.

Best practices

  • Reach for a hook when an instruction has failed twice; that is the signal the rule needs enforcement rather than persuasion.
  • Use matcher plus if together so handlers only spawn for the calls they care about.
  • Exit 2 to block and 0 to stay silent, and never rely on exit 1 meaning anything.
  • Reference scripts as ${CLAUDE_PROJECT_DIR}/.claude/hooks/... and commit them next to the settings that register them.
  • Keep handlers fast and idempotent — they run on every matching tool call, including inside subagents.
  • Treat a hook as code with your privileges: it runs arbitrary shell, so review any hook that arrives with a repository or a plugin.

Try it in five minutes

Write a PreToolUse hook that blocks a command, and prove exit 1 does not.

  1. 1.Create .claude/hooks/no-force-push.sh that reads stdin, extracts .tool_input.command with jq, and exits 2 with a message on stderr when the command contains push --force.
  2. 2.Run chmod +x .claude/hooks/no-force-push.sh.
  3. 3.Register it in .claude/settings.json under PreToolUse with "matcher": "Bash" and "if": "Bash(git push *)".
  4. 4.Start claude and ask it to force-push. The call is blocked and Claude repeats your stderr message.
  5. 5.Change exit 2 to exit 1, ask again, and watch the push go through with only a hook-error notice — proof that 1 does not block.

Verified against code.claude.com/docs/en/hooks on 2026-08-09. See content/SOURCES.md for the full table.

← / → MOVE BETWEEN CONCEPTS