Claude Code hooks are shell commands, HTTP endpoints, MCP tool calls or model prompts that run automatically at fixed points in a session, such as before a tool call (PreToolUse) or when Claude finishes responding (Stop). You define them under a hooks key in a settings file such as .claude/settings.json.

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

Applies to: Claude Code 2.1.287 (changelog), docs as of October 2, 2026. Last checked: October 2, 2026.

The example above, from Anthropic’s hooks guide, runs Prettier on every file Claude edits or writes.

On this page

Why use hooks instead of instructions?

Anthropic’s guide describes hooks as deterministic control: an action always happens instead of depending on the model choosing to run it. Typical jobs from the docs are formatting files after edits, blocking commands before they run, sending a notification when Claude needs input, and injecting context at session start. For static project conventions that never change, the reference recommends CLAUDE.md instead, since it loads without running a script.

Where do hooks go?

Where you define a hook sets its scope (hook locations):

Location Scope Shareable
~/.claude/settings.json All your projects No, local to your machine
.claude/settings.json Single project Yes, can be committed to the repo
.claude/settings.local.json Single project No, gitignored when Claude Code saves a setting to it
Managed policy settings Organization-wide Yes, admin-controlled
Plugin hooks/hooks.json When the plugin is enabled Yes, bundled with the plugin
Skill frontmatter Rest of the session once the skill is invoked Yes, defined in the skill file
Subagent frontmatter While that subagent runs Yes, defined in the subagent file

Hook entries merge across levels instead of replacing each other. Type /hooks in a session to open a read-only browser that shows every configured hook and the file it came from. To change hooks, edit the JSON or ask Claude to do it; direct edits are normally picked up by the file watcher (/hooks menu).

How is a hook configured?

The configuration has three levels (configuration):

  1. A hook event, such as PreToolUse or Stop.
  2. A matcher group that filters when it fires, such as “only for the Bash tool”.
  3. One or more hook handlers that run when the matcher matches.

There are five handler types:

Type What runs Default timeout
command A shell command; gets JSON on stdin, answers with exit codes and stdout 600 s
http A POST of the JSON to a URL; answers with the response body 600 s
mcp_tool A tool on an already-connected MCP server 600 s
prompt A single-turn evaluation by a Claude model 30 s
agent A subagent that can use tools such as Read and Grep (experimental) 60 s

UserPromptSubmit lowers the command, HTTP and MCP-tool default to 30 seconds, and SessionEnd hooks share a 1.5-second budget unless you set a longer timeout. All matching hooks run in parallel; an identical handler defined in more than one settings file runs once (handler fields).

Which hook events exist?

The hooks reference lists these events. The ones most setups use:

Event When it fires Can block?
SessionStart A session begins or resumes No
UserPromptSubmit You submit a prompt, before Claude processes it Yes
PreToolUse Before a tool call executes Yes
PermissionRequest A tool call needs a permission decision Through JSON only
PostToolUse After a tool call succeeds No (tool already ran)
PostToolUseFailure After a tool call fails No
Notification Claude Code sends a notification No
SubagentStart / SubagentStop A subagent is spawned / finishes Stop only
Stop Claude finishes responding Yes (keeps Claude working)
PreCompact / PostCompact Before / after context compaction PreCompact only
SessionEnd A session terminates No

The full list also includes Setup, UserPromptExpansion, PermissionDenied, PostToolBatch, MessageDisplay, TaskCreated, TaskCompleted, StopFailure, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, Elicitation and ElicitationResult. Each has its own section in the reference with its input schema.

How does matcher syntax work?

The matcher value is read in one of three ways, depending on its characters (matcher patterns):

Matcher value Evaluated as Example
"*", "" or omitted Match all Fires on every occurrence of the event
Only letters, digits, _, -, spaces, , and | Exact string, or a list split by | or , Bash; Edit|Write; Edit, Write
Any other character Unanchored JavaScript regular expression ^Notebook; mcp__memory__.*

Because the regex path is unanchored, Edit.* also matches NotebookEdit; write ^Edit$ for a whole-string match. Matchers are case-sensitive.

What the matcher is compared against depends on the event: the tool name for PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied; startup, resume, clear, compact or fork for SessionStart; the notification type for Notification; manual or auto for compaction events. UserPromptSubmit, Stop and several others have no matcher support, and a matcher there is silently ignored.

MCP tools

MCP tools are named mcp__<server>__<tool>. To match every tool from one server, append .*: a bare mcp__memory is treated as an exact string and matches nothing (match MCP tools).

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__memory__.*",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Memory operation initiated' >> ~/mcp-operations.log"
          }
        ]
      },
      {
        "matcher": "mcp__.*__write.*",
        "hooks": [
          {
            "type": "command",
            "command": "/home/user/scripts/validate-mcp-write.py"
          }
        ]
      }
    ]
  }
}

The memory server in that example is the reference Memory MCP Server. Server setup is covered in our Claude Code MCP guide.

The if field

On tool events, a handler can also take an if field in permission-rule syntax, such as "Bash(git *)" or "Edit(*.ts)", so the script only spawns when the tool call matches. It holds exactly one rule. The reference notes that the filter is best-effort and fails open when a Bash command can’t be parsed, so it says to use the permission system rather than a hook to enforce a hard allow or deny.

What JSON does a hook receive?

Command hooks get JSON on stdin; HTTP hooks get the same JSON as the POST body. Every event includes session_id, transcript_path, cwd and hook_event_name, and most include permission_mode. A PreToolUse hook for a Bash call receives (common input fields):

{
  "session_id": "abc123",
  "prompt_id": "550e8400-e29b-41d4-a716-446655440000",
  "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
  "cwd": "/home/user/my-project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test",
    "description": "Run test suite",
    "timeout": 120000,
    "run_in_background": false
  },
  "tool_use_id": "toolu_01ABC123..."
}

Inside a subagent the input also carries agent_id and agent_type.

What do exit codes mean?

Exit code Meaning (exit code output)
0 Success. Stdout is parsed as JSON if it starts with {. For UserPromptSubmit, UserPromptExpansion and SessionStart, plain-text stdout is added to Claude’s context
2 Blocking error. On events that can block, it blocks even if JSON says "allow". Stderr becomes the reason Claude sees
Anything else Non-blocking error for most events: the action proceeds and a hook error notice shows the first line of stderr

The docs single out exit code 1: without valid JSON, Claude Code treats it as non-blocking and continues, even though 1 is the usual Unix failure code. “If your hook is meant to enforce a policy, use exit 2.” The one exception is WorktreeCreate, where any non-zero exit aborts worktree creation. A timed-out command hook renders no decision and doesn’t block the tool call.

This script from the reference blocks rm commands by exiting 2:

#!/bin/bash
# Reads JSON input from stdin, checks the command
input=$(cat)
command=$(jq -r '.tool_input.command' <<<"$input")

if [[ "$command" == rm* ]]; then
  echo "Blocked: rm commands are not allowed" >&2
  exit 2  # Blocking error: tool call is prevented
fi

exit 0  # No decision: the normal permission flow applies

What JSON can a hook return?

For finer control, exit 0 and print one JSON object on stdout. Universal fields include continue (set false to stop Claude entirely), stopReason, systemMessage (a warning shown to the user) and terminalSequence (an allowlisted escape sequence for desktop notifications). Output strings are capped at 10,000 characters (JSON output).

Decision fields differ by event (decision control):

Event Decision fields
PreToolUse hookSpecificOutput.permissionDecision: allow, deny, ask or defer, plus permissionDecisionReason; updatedInput rewrites the tool’s arguments
PermissionRequest hookSpecificOutput.decision.behavior: allow or deny
UserPromptSubmit, PostToolUse, Stop, SubagentStop, PreCompact and others Top-level decision: "block" with reason
SessionStart hookSpecificOutput.additionalContext, plus sessionTitle, watchPaths and others; no blocking
Notification, SessionEnd, PostCompact and others None; side effects only

A PreToolUse deny:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database writes are not allowed"
  }
}

Context for Claude goes in additionalContext:

{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "This file is generated. Edit src/schema.ts and run `bun generate` instead."
  }
}

The reference advises writing that text as factual statements (“This repo uses bun test”) rather than imperative system instructions, which can trigger Claude’s prompt-injection defenses.

If a shell profile prints text on startup, it gets prepended to your JSON and Claude Code reads the whole output as plain text. The troubleshooting section shows how to wrap profile echo lines so they run only in interactive shells.

Examples from Anthropic’s docs

Desktop notification when Claude needs input

Add to ~/.claude/settings.json (macOS shown; the guide has Linux notify-send and Windows PowerShell versions):

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

Block edits to protected files

  1. Save this as .claude/hooks/protect-files.sh:

    #!/bin/bash
    # protect-files.sh
    
    INPUT=$(cat)
    FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
    
    # Normalize Windows backslash separators so the patterns below match
    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
    
  2. Make it executable:

    chmod +x .claude/hooks/protect-files.sh
    
  3. Register it in .claude/settings.json:

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

Per the guide, Claude Code blocks the edit and passes the Blocked: message to Claude as feedback. The Bash examples need jq on your PATH.

Re-inject context after compaction

A SessionStart hook with the compact matcher adds its stdout to Claude’s context after every compaction (guide):

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
          }
        ]
      }
    ]
  }
}

Ask a model whether work is finished

A prompt hook sends the input to a Claude model, Haiku by default, which answers {"ok": true} or {"ok": false, "reason": "..."}. On Stop, a false answer feeds the reason back so Claude keeps working (prompt-based hooks):

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Check if all tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains to be done\"}."
          }
        ]
      }
    ]
  }
}

Claude Code overrides a Stop hook after eight consecutive blocks. For command hooks, the guide recommends reading stop_hook_active from the input and exiting 0 when it is true.

Security considerations

Warning

Anthropic’s reference states that “command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration.”

Points from the security section and limitations:

  • Workspace trust. In an interactive session, Claude Code holds back hooks from every settings file until you accept the workspace trust dialog. In a -p or SDK session there is no dialog and the folder is treated as trusted, so hooks committed in a repository’s .claude/settings.json run. Before scripting claude -p over a repository you didn’t write, the docs say to review its .claude/ settings, start with --bare, or pass --settings '{"disableAllHooks": true}'.
  • Hooks tighten, they don’t loosen. A PreToolUse deny blocks the tool in every permission mode, including bypassPermissions and --dangerously-skip-permissions. A hook returning allow doesn’t override deny rules from settings.
  • Write defensive scripts. Validate inputs, quote shell variables ("$VAR"), block .. path traversal, use absolute paths or ${CLAUDE_PROJECT_DIR}, and skip sensitive files such as .env, .git/ and keys.
  • Watch the first run. A missing or non-executable script exits with a code like 127, which is non-blocking, so a mistyped path in settings.json leaves a policy hook silently disabled.
  • Organization control. Admins can set allowManagedHooksOnly in managed settings to block user, project, local and plugin hooks. disableAllHooks outside managed settings can’t disable managed hooks. allowedHttpHookUrls limits where HTTP hooks may send data.

To turn all hooks off temporarily, set "disableAllHooks": true. There is no switch to disable a single hook while keeping it in the file (disable or remove hooks).

How do I debug a hook?

  • Run /hooks and confirm the hook appears under the right event.

  • Pipe sample JSON into the script and check the exit code:

    echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
    echo $?  # Check the exit code
    
  • Start Claude Code with claude --debug-file /tmp/claude.log and tail -f that file to see which hooks matched, their exit codes, stdout and stderr. Set CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose for matcher details (debug hooks).

More configuration guides are in Skills & config and the Claude Code section.