hooks-reference.md 13 KB

Hooks Reference

Complete hook system reference, verified against https://code.claude.com/docs/en/hooks (June 2026).

Stale contract warning: old guides describe a $TOOL_INPUT env-var contract. That is dead. Hooks receive a JSON payload on stdin and respond via exit code + stdout JSON.

Event Catalog

Event Fires Matcher matches Blocking (exit 2)
SessionStart Session begins startup, resume, clear, compact No
SessionEnd Session ends End reason: clear, resume, logout, prompt_input_exit, ... No
Setup Only with --init/--init-only/--maintenance init, maintenance No
UserPromptSubmit Before prompt is processed (30s default timeout) (none) Yes — prompt rejected and erased
UserPromptExpansion When a /command expands Command/skill name Yes — blocks expansion
PreToolUse Before each tool call Tool name Yes — tool call prevented
PermissionRequest When a permission dialog would show Tool name Yes — permission denied
PermissionDenied After a tool call is denied Tool name No (retry output supported)
PostToolUse After tool succeeds Tool name No (stderr fed back; tool already ran)
PostToolUseFailure After tool fails Tool name No (tool already failed)
PostToolBatch After a parallel tool batch resolves (none) Yes — stops loop before next model call
Stop Main agent finishes a turn (none) Yes — prevents stop, conversation continues
StopFailure Turn ends in API error Error type (rate_limit, overloaded, ...) No (output ignored)
SubagentStart / SubagentStop Subagent spawn / finish Agent type (e.g. Explore, custom names) Start: No / Stop: Yes
TaskCreated / TaskCompleted Task list changes (none) Yes — rolls back / prevents completion
TeammateIdle Agent-team teammate goes idle (none) Yes — prevents idle
Notification Notification sent permission_prompt, idle_prompt, auth_success, ... No
MessageDisplay While a message streams (10s timeout) (none) No (displayContent rewrite, screen-only)
ConfigChange Settings file changed mid-session user_settings, project_settings, local_settings, policy_settings, skills Yes — blocks the change (except policy)
CwdChanged Working directory changes (none) No (CLAUDE_ENV_FILE available)
FileChanged Watched file changes Literal filenames, e.g. .envrc\|.env No (CLAUDE_ENV_FILE available)
PreCompact / PostCompact Before / after compaction manual, auto Pre: Yes — blocks compaction / Post: No
InstructionsLoaded CLAUDE.md / rules file loads Load reason: session_start, nested_traversal, path_glob_match, include, compact No (async, observability)
WorktreeCreate / WorktreeRemove Worktree lifecycle (none) Create: any non-zero fails creation
Elicitation / ElicitationResult MCP server requests user input / response MCP server name Yes — denies / blocks response

Stdin JSON Contract

Every hook gets JSON on stdin. Common fields:

{
  "session_id": "…",
  "transcript_path": "/abs/path/to/transcript.jsonl",
  "cwd": "/working/dir",
  "hook_event_name": "PreToolUse",
  "permission_mode": "default|plan|acceptEdits|auto|dontAsk|bypassPermissions"
}

agent_id / agent_type appear in subagent context; effort: {"level": "low|medium|high|xhigh|max"} on tool-context events.

Event-specific fields:

Event Extra stdin fields
Tool events (PreToolUse, PermissionRequest, PermissionDenied) tool_name, tool_input (tool-specific object)
PostToolUse + tool_output (string or object)
PostToolUseFailure + error_message
PermissionDenied + denial_reason
SessionStart source (startup\|resume\|clear\|compact), model
Setup trigger (init\|maintenance)
UserPromptSubmit prompt
UserPromptExpansion command, expansion
StopFailure error_type, error_message
SubagentStart/SubagentStop agent_type, agent_id
Notification notification_type, message
ConfigChange source
FileChanged file_path, change_type (create\|modify\|delete)
CwdChanged directory
TaskCreated/TaskCompleted task_id, task_title
InstructionsLoaded file_path, memory_type, load_reason, globs?, trigger_file_path?, parent_file_path?
Elicitation server_name, form_fields[] (name, type, label, required)
WorktreeCreate/WorktreeRemove worktree_id, worktree_path (remove)

Read it with jq:

#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

Exit Codes

Code Meaning
0 Success. stdout parsed as JSON if valid, else treated as plain text context
2 Blocking error. stdout ignored; stderr is fed to Claude as feedback
other Non-blocking error. stderr logged, first line shown in transcript, execution continues

Stdout JSON Contract (exit 0)

Universal fields (any event):

{
  "continue": false,              // false stops Claude entirely
  "stopReason": "why we stopped", // shown when continue:false
  "suppressOutput": true,         // hide stdout from transcript
  "systemMessage": "warning shown to user",
  "terminalSequence": "\u001b]…"  // raw OSC sequence (v2.1.141+)
}

Event-specific output goes inside hookSpecificOutput with a required hookEventName:

Event hookSpecificOutput fields
PreToolUse permissionDecision: allow\|deny\|ask\|defer, permissionDecisionReason, updatedInput (replace tool args), additionalContext
PermissionRequest decision: { "behavior": "allow\|deny", "updatedInput": {…} }
PermissionDenied retry: true (let the model retry)
PostToolUse updatedToolOutput (replace the result Claude sees), additionalContext
SessionStart / SubagentStart additionalContext, watchPaths: [...] (feeds FileChanged), reloadSkills: true; SessionStart only: sessionTitle, initialUserMessage
Stop / SubagentStop additionalContext (inject feedback and continue)
PostToolBatch / Setup additionalContext
MessageDisplay displayContent (screen-only rewrite, transcript untouched)
Elicitation / ElicitationResult action: accept\|decline\|cancel, content: {field: value}
WorktreeCreate worktreePath (or print path on stdout for command hooks)

Top-level decision/reason pattern (alternative to exit 2) for UserPromptSubmit, UserPromptExpansion, PostToolUse, PostToolUseFailure, PostToolBatch, ConfigChange, PreCompact, Stop, SubagentStop:

{ "decision": "block", "reason": "explanation Claude sees" }

Hook Types

Five types. All accept timeout (seconds), if (permission-rule filter), statusMessage.

1. command (default workhorse)

{
  "type": "command",
  "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check.sh",
  "args": ["--fast"],
  "async": false,
  "asyncRewake": false,
  "shell": "bash",
  "timeout": 600
}
  • Shell form (no args): command string runs via shell (sh -c, Git Bash, or PowerShell with shell: "powershell"); pipes, &&, globs work.
  • Exec form (with args): resolved as executable on PATH, spawned directly, no shell.
  • async: true: background, never blocks, output discarded.
  • asyncRewake: true: background, but wakes Claude on exit 2 with stderr as a system reminder. Implies async.

2. http

{ "type": "http", "url": "https://hooks.internal/check",
  "headers": {"Authorization": "$HOOK_TOKEN"}, "allowedEnvVars": ["HOOK_TOKEN"] }

POST; 2xx = success (body parsed as JSON or plain text); non-2xx = non-blocking error. Env interpolation in headers requires allowedEnvVars.

3. mcp_tool

{ "type": "mcp_tool", "server": "my-server", "tool": "validate",
  "input": {"cmd": "${tool_input.command}"} }

Calls a configured MCP server's tool; ${path.to.field} interpolates from the stdin payload.

4. prompt (default timeout 30s)

{ "type": "prompt", "prompt": "Is this command destructive? $ARGUMENTS", "model": "haiku" }

One-shot yes/no judgment by a fast model; returns the decision as JSON.

5. agent (default timeout 60s)

{ "type": "agent", "prompt": "Verify the edited file still compiles. $ARGUMENTS" }

Spawns a subagent with tool access; returns a decision.

Matchers

Pattern Interpreted as
"*", "", omitted Match everything
Letters/digits/_/\| only Exact name or \|-list: Bash, Edit\|Write
Anything else JavaScript regex: ^Notebook, mcp__memory__.*
  • Matching is case-sensitive (bash never matches Bash).
  • matcher must be a string, not an array — an array is a schema error and the hook is silently dropped (visible in /doctor).
  • MCP tools: mcp__<server>__<tool>; match a whole server with regex mcp__memory__.*.

if filters

Narrow within a matched event using permission-rule syntax — "if": "Bash(git *)" runs only for git commands (subcommands inside && chains and $() are checked; leading FOO=bar assignments stripped). Edit(*.ts) filters by file pattern. Fails open if the command can't be parsed — use the permission system for hard enforcement.

Where Hooks Live

Location Scope
~/.claude/settings.json All your projects
.claude/settings.json Project (committed)
.claude/settings.local.json Project (gitignored)
Managed policy settings Organization
Plugin hooks/hooks.json (or inline in plugin.json) While plugin enabled
Skill or agent frontmatter While that component is active

Settings-file shape:

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

Skill/agent frontmatter shape (YAML):

---
name: secure-operations
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/security-check.sh"
---

For subagents, Stop hooks auto-convert to SubagentStop. Plugin subagents ignore hooks frontmatter (security restriction).

Edits to settings.json hooks take effect in the running session after a brief delay — no restart. disableAllHooks: true turns everything off (managed hooks only by managed-level setting). Identical handlers are deduplicated. Browse live config with /hooks.

Environment Variables in Hooks

Variable Available Value
CLAUDE_PROJECT_DIR All hooks Project root
CLAUDE_PLUGIN_ROOT Plugin hooks Plugin install dir (changes on update)
CLAUDE_PLUGIN_DATA Plugin hooks Persistent data dir (survives updates)
CLAUDE_ENV_FILE SessionStart, Setup, CwdChanged, FileChanged File to append export VAR=… lines; persists into later Bash calls
CLAUDE_EFFORT Tool-context events lowmax
CLAUDE_CODE_REMOTE All "true" on web, unset locally

Path placeholders in hook config: ${CLAUDE_PROJECT_DIR}, ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}, and (plugins) ${user_config.*}.

Recipes

Block dangerous commands (PreToolUse on Bash):

#!/bin/bash
CMD=$(jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE 'rm -rf /|git push --force.*(main|master)'; then
  jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
          permissionDecision: "deny",
          permissionDecisionReason: "Destructive command blocked by policy"}}'
fi
exit 0

Audit log (PostToolUse, matcher *):

#!/bin/bash
cat | jq -c '{ts: now|todate, tool: .tool_name, input: .tool_input}' >> ~/.claude/audit.jsonl
exit 0

Inject project context + reload skills at session start (SessionStart):

#!/bin/bash
jq -n --arg ctx "$(git -C "$CLAUDE_PROJECT_DIR" status --short)" \
  '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $ctx, reloadSkills: true}}'

Force a re-check before stopping (Stop):

#!/bin/bash
if ! npm test --silent >/dev/null 2>&1; then
  echo "Tests are failing — fix them before finishing." >&2
  exit 2
fi
exit 0

Rewrite tool input (PreToolUse updatedInput):

#!/bin/bash
INPUT=$(cat)
SAFE=$(echo "$INPUT" | jq '.tool_input.command |= sub("^pip install"; "uv pip install")')
jq -n --argjson ti "$(echo "$SAFE" | jq '.tool_input')" \
  '{hookSpecificOutput: {hookEventName: "PreToolUse", updatedInput: $ti}}'

Security Checklist

  • Quote all shell variables ("$VAR"); validate paths (reject .. traversal)
  • Use ${CLAUDE_PROJECT_DIR} instead of relative paths (hooks run from varying cwd)
  • Keep UserPromptSubmit hooks fast (30s default timeout); set explicit timeout elsewhere
  • set -euo pipefail plus jq fallbacks (// empty) so malformed payloads don't crash into exit-2 blocks
  • Remember hooks execute arbitrary code with your credentials — review hooks in any repo before trusting it

Debugging Hooks

claude --debug hooks      # live: events fired, matchers checked, exit codes, output
/hooks                    # what's registered this session
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo "exit=$?"

See debugging-reference.md for the hook-not-firing decision tree.