# 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:

```json
{
  "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:

```bash
#!/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):

```json
{
  "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`:

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

## Hook Types

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

### 1. `command` (default workhorse)

```json
{
  "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`

```json
{ "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`

```json
{ "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)

```json
{ "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)

```json
{ "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:

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

Skill/agent frontmatter shape (YAML):

```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 | `low`…`max` |
| `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`):

```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 `*`):

```bash
#!/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):

```bash
#!/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):

```bash
#!/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`):

```bash
#!/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

```bash
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](debugging-reference.md) for the hook-not-firing decision tree.
