claudeBenutzer09
6

Hooks

Event-driven handlers that Claude Code runs at fixed points of its lifecycle — from formatting after every edit to vetoing a dangerous command.

Intermediate 1 hour

A hook is a piece of automation that Claude Code runs at a fixed point of its lifecycle — without the model deciding whether it happens. That is exactly what separates it from a rule in CLAUDE.md: a hook runs because the event occurred, not because Claude remembered it. Command hooks receive the event data as JSON on stdin and answer through an exit code and stdout; prompt and agent hooks hand the decision to a Claude model instead and are therefore non-deterministic. This module covers how the configuration is put together, which of the 30 events you actually need day to day, how a hook blocks, adds context, or replaces a tool result — and where its limits are.

How hooks are configured and when they fire

The configuration lives in a settings file under the hooks key and is three levels deep: first the event, then a matcher group that narrows when it counts, and inside it the handlers that actually run. How the matcher is read depends on its characters. If it contains only letters, digits, underscore, hyphen, space, comma, or vertical bar, Claude Code compares it exactly — Bash hits that one tool, Edit|Write exactly those two. As soon as any other character appears, the value is evaluated as an unanchored regular expression: mcp__github__.* then hits every tool of the GitHub MCP server, but Edit.* also hits NotebookEdit. An empty matcher, an omitted one, or * fires on every occurrence of the event:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.py\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

The if field filters more finely. It sits on an individual handler, has been available since v2.1.85, and uses permission-rule syntax. The matcher only sees the tool name; if checks name and arguments together, so the hook process never starts when the actual call does not match. That pays off wherever only a slice of a tool’s calls matters to you — every git push, say, but not every Bash command:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(git push*)",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/check-push.sh"
          }
        ]
      }
    ]
  }
}

A handler carries exactly one rule; the field knows no && and no lists, so several conditions mean several handlers. For Bash patterns Claude Code takes the command line apart: leading variable assignments are stripped, every segment after && and every command inside $() or backticks is checked separately, and a pattern that names more than the command itself runs the hook on such substitutions to be safe. If the line cannot be parsed, the hook runs as well — the filter is deliberately fail-open and therefore no substitute for a permission rule. Outside the tool events PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied, an if field does the opposite of what you would expect: the hook never runs there at all.

Claude Code knows 30 hook events in total, yet daily work gets by with four. PreToolUse checks before a tool runs and can prevent it. PostToolUse reacts afterwards and can add context or replace the result. UserPromptSubmit intercepts your input before Claude processes it. Stop runs once Claude has finished responding. The rest group around permissions (PermissionRequest, PermissionDenied), the session and subagent lifecycle (SessionStart, SessionEnd, SubagentStart, SubagentStop), failure cases (PostToolUseFailure, StopFailure), compaction, worktrees, configuration and file changes, plus display and notification.

A few of these events are young and worth a second look. CwdChanged and FileChanged arrived in v2.1.83 and make the environment reactive: one fires on every directory change, the other on changes to the files its matcher lists by name. Both typically write to CLAUDE_ENV_FILE, which Claude Code runs as a preamble before each Bash command — that is how tools like direnv follow along inside Claude’s shell. TaskCreated (v2.1.84) fires when a task is created through TaskCreate and can roll that creation back with exit code 2. Elicitation and ElicitationResult (v2.1.76) sit around the dialog an MCP server uses to ask for structured input mid tool call: the first can answer or decline it before it is ever shown to you, the second can override the answer before it goes back. WorktreeCreate, by contrast, has been around since v2.1.50, and it is not a notification event: it replaces the built-in git logic, creates the workspace itself, and returns its path — which is what makes worktree isolation possible under SVN, Perforce, or Mercurial. Since v2.1.84 an HTTP handler can do the same by returning the path as hookSpecificOutput.worktreePath.

PreCompact runs immediately before Claude Code summarizes the conversation to free context — and it is one of the events you can genuinely prevent. Its matcher tells the trigger apart: manual stands for a /compact you ran yourself, auto for the automatic compaction when context fills up. If you want to snapshot state first, or head off a summary in the middle of a refactor, this is where you hook in:

{
  "hooks": {
    "PreCompact": [
      {
        "matcher": "auto",
        "hooks": [
          { "type": "command", "command": "./scripts/snapshot-context.sh" }
        ]
      }
    ]
  }
}

There are two ways to block: exit code 2 with a reason on stderr, or exit 0 plus a JSON object carrying "decision": "block" and a reason on stdout. The two do not combine — on exit 2 Claude Code ignores any JSON and takes stderr as the reason. Its counterpart PostCompact fires once the summary is in place; it can no longer prevent anything and is the place to re-attach lost notes, re-invoke a skill, or record what survived.

Besides the JSON on stdin, a hook process inherits Claude Code’s environment and gets a few variables set on top. CLAUDE_CODE_SESSION_ID carries the identifier of the running session — the same one the JSON reports as session_id, and it changes with /clear; use it to line up hook logs and external telemetry with one session. ${CLAUDE_PROJECT_DIR} points at the project root and ${CLAUDE_PLUGIN_ROOT} at a plugin’s installation directory, so a script is found regardless of the working directory. A model name is not available: there is no $CLAUDE_MODEL variable, and only SessionStart can see a model field in its input at all.

In Python, event data and session identifier are together in a handful of lines:

import json, sys, os
data = json.load(sys.stdin)
tool_name = data.get("tool_name", "")
tool_input = data.get("tool_input", {})
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")

The answer travels back through the exit code. 0 means “no objection” — Claude Code then parses stdout as JSON, and for UserPromptSubmit, UserPromptExpansion, and SessionStart that output goes straight into Claude’s context. 2 is the blocking case: JSON is ignored, stderr becomes the reason Claude sees, and what exactly gets blocked depends on the event — PreToolUse prevents the tool call, Stop keeps Claude working, SessionStart can prevent nothing and merely shows the message. Any other code is a non-blocking error: the action continues and the transcript shows a notice with the first stderr line. For scripts coming from elsewhere that is the sharpest edge — the usual Unix convention does not apply here, and 1 blocks nothing.

How thoroughly a hook should check can depend on the effort level of the turn. The input carries an effort object with a level field for that, and the same value reaches hook commands and the Bash calls they spawn as $CLAUDE_EFFORT. The levels are low, medium, high, xhigh, and max — there is no auto level. If the active model does not support the requested level, the field reports the downgraded level actually used:

import json, os, sys
data = json.load(sys.stdin)
effort_level = data.get("effort", {}).get("level", "medium")  # from JSON
effort_env = os.environ.get("CLAUDE_EFFORT", "medium")          # from env var

The five handler types and what they return

Command hooks come in two forms, and the choice decides whether a shell is involved. Without args you get shell form: the command string goes to sh -c, on Windows to Git Bash or, failing that, PowerShell, with pipes, &&, redirects, and variable expansion. As soon as args is set, exec form applies: command is now only the executable, each element of args is passed as exactly one argument, and quotes, dollar signs, or backticks travel through untouched. For any hook that references a path placeholder, exec form is the right choice — it spares you quoting paths that contain spaces:

{
  "type": "command",
  "command": "node",
  "args": ["./scripts/validate.js", "--strict"]
}

Beyond command, Claude Code knows four more handler types. http posts the same JSON input to a URL and reads the reply in the same output format; headers may interpolate environment variables, but only those listed explicitly in allowedEnvVars. mcp_tool calls a tool on an already-connected MCP server — you name the server, the tool, and an input object whose string values can substitute placeholders such as ${tool_input.file_path} from the hook input. prompt and agent hand the decision to a model; they are the subject of the last section. Not every event supports every type — SessionStart and Setup, for instance, support only command and mcp_tool. The configuration builder further down this page emits command hooks only so far; for the other types this JSON is the template:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "mcp_tool",
            "server": "slack",
            "tool": "send_message",
            "input": { "channel": "#deploys", "text": "Claude finished the task" }
          }
        ]
      }
    ]
  }
}

If you want to know where the time goes, the inputs of PostToolUse and PostToolUseFailure have carried a duration_ms field since v2.1.119. It measures the tool’s execution time in milliseconds and deliberately leaves out permission prompts and the runtime of PreToolUse hooks — it describes the tool, not the wait in front of it.

Whether a session is truly finished cannot be read off the answer text at the end of a turn. That is why the inputs of Stop and SubagentStop have carried two lists since v2.1.145. background_tasks describes each piece of in-flight background work with an id, a status, and a type such as shell, subagent, monitor, or workflow. session_crons lists the session’s scheduled wakeups, which come from CronCreate, ScheduleWakeup, and /loop, each with its schedule and whether it recurs. A completion gate blocks as long as either list is non-empty:

import json, sys
data = json.load(sys.stdin)
pending_bg = [t for t in data.get("background_tasks", []) if t.get("status") in ("running", "starting")]
pending_cron = data.get("session_crons", [])
if pending_bg or pending_cron:
    print(json.dumps({
        "decision": "block",
        "reason": f"{len(pending_bg)} background task(s) and {len(pending_cron)} scheduled task(s) still active"
    }))
    sys.exit(0)

Two details belong with that. Both lists are scoped to the parent session even when the hook fires inside a subagent — a SubagentStop gate therefore sees the background work of the whole run, not only its own. And a blocking Stop hook needs a way out: the input carries stop_hook_active for that, and after eight consecutive blocks Claude Code overrides the hook anyway.

Four patterns from everyday work

The first pattern is also the most common: format as soon as Claude has touched a file. A PostToolUse hook with an Edit|Write matcher takes the path from tool_input.file_path and passes it to whichever formatter matches the extension. Claude’s output then follows the project style with no effort — and nobody has to remember it:

#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))")
case "$FILE" in
  *.ts|*.tsx|*.js) prettier --write "$FILE" 2>/dev/null ;;
  *.py) black "$FILE" 2>/dev/null ;;
  *.go) gofmt -w "$FILE" 2>/dev/null ;;
esac
exit 0

The second pattern reports instead of preventing. A PostToolUse hook scans the freshly written content for patterns that look like credentials and returns its finding as additionalContext inside hookSpecificOutput. Claude Code attaches that text as a system reminder next to the tool result; Claude reads it on the next model request and can clear the finding itself. Word such text as a statement of fact rather than a command — lines disguised as system instructions trip Claude’s prompt-injection defenses and end up surfaced to you instead of acting as context:

SECRET_PATTERNS = [
    (r"api[_-]?key\s*=\s*['\"][^'\"]+['\"]", "Potential hardcoded API key"),
    (r"password\s*=\s*['\"][^'\"]+['\"]", "Potential hardcoded password"),
]
# ... check content, then:
output = {"hookSpecificOutput": {"hookEventName": "PostToolUse",
  "additionalContext": f"Security warnings: {'; '.join(warnings)}"}}
print(json.dumps(output))

The third pattern goes one step further and replaces what Claude gets to see in the first place: updatedToolOutput inside hookSpecificOutput swaps out the tool result, since v2.1.121 for all tools rather than MCP tools only. Two things matter here. The replacement value has to match the tool’s output shape — Bash, for example, returns an object with stdout, stderr, interrupted, and isImage, and a value that does not fit is silently discarded for built-in tools. And only perception is replaced: the tool ran long ago, so files written and commands issued stay exactly as they are. To intervene before anything happens, you need PreToolUse. The example below shows the shape only — for a built-in tool the replacement would have to carry that tool’s fields rather than plain text, and the input field is called tool_response:

import json, sys
data = json.load(sys.stdin)
original = data.get("tool_response", "")
sanitized = original.replace("/home/user", "~")
output = {"hookSpecificOutput": {"updatedToolOutput": sanitized}}
print(json.dumps(output))

The fourth pattern is the emergency brake. A PreToolUse hook checks the command against a list of patterns and exits with code 2 as soon as one matches; the message on stderr tells Claude why. Such hooks run before any permission-mode check and therefore in every mode — including bypassPermissions. The reverse does not hold: an allow from a hook does not lift a deny rule from settings. Hooks can tighten, not loosen:

BLOCKED = [(r"\brm\s+-rf\s+/", "Blocking dangerous rm -rf /")]
for pattern, message in BLOCKED:
    if re.search(pattern, command):
        print(message, file=sys.stderr)
        sys.exit(2)

When judgment is required: prompt and agent hooks

Not every condition can be written as a regular expression. For cases that call for judgment there is type: "prompt": Claude Code sends your prompt together with the hook input to a fast model — Haiku by default, changeable through the model field — and expects nothing back but an ok and, when that is false, a reason. The $ARGUMENTS placeholder puts the input wherever your prompt needs it. On Stop and SubagentStop the reason becomes Claude’s next instruction, so the work continues:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Check: 1) Were all files modified? 2) Do tests pass? 3) Is the PR description updated? If anything is missing, explain what.",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

When a look at the input data is not enough and somebody has to go and check, type: "agent" steps in. Instead of a single model call, Claude Code spawns a subagent that may read files, search, and run commands before returning the same ok and reason pair — with a 60-second default instead of 30 and up to 50 tool turns. A warning from the documentation belongs with it: agent hooks are experimental, and their behavior and configuration may change. For production work, command hooks remain the more dependable choice.

Hooks do not have to apply globally. Skills and subagents may carry a hooks field in their own frontmatter; those hooks stay registered while the component is active and are cleaned up afterwards. The format is the same as in settings, with one rewrite: inside a subagent, Stop automatically becomes SubagentStop, because that is the event that actually fires there. Frontmatter hooks of a project subagent also run only after you have confirmed the folder as trusted:

---
name: production-deploy
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/production-safety-check.sh"
          once: true
---

The example carries once: true: the hook then runs once per session and is removed afterwards, which is exactly right for one-off setup checks. The field is honored only in skill frontmatter, though — in settings files and in subagent frontmatter it is ignored, without any error message.

When a prompt or agent hook returns ok: false, many events end the turn: on PreToolUse and PostToolUse the reason appears as a warning line and Claude stops. The continueOnBlock field turns that around — the reason goes back to Claude as a tool error and the turn keeps running, so Claude can adjust instead of stopping. For lint and style checks that is usually what you want. Some events ignore the field entirely — two groups matter for this module: on PostToolUseFailure and TaskCreated the reason always goes back to Claude anyway, and on PostToolBatch, UserPromptSubmit, and UserPromptExpansion the turn ends regardless.

Two things matter in day-to-day operation. Hook processes run without a controlling terminal and therefore cannot write to /dev/tty; for desktop notifications, window titles, or a bell there is the terminalSequence field in the JSON output instead, which Claude Code emits through its own write path — restricted to an allowlist of harmless sequences so no hook can corrupt what is on screen. And when you want to know what is currently registered, /hooks opens an overview grouped by event, including where each definition came from. It is read-only: you change hooks in the settings file, and switch them off temporarily with disableAllHooks.