Claude Code Hooks: Events, Exit Codes and Examples
Claude Code has 33 hook events, and exit 2 blocks on 16 of them. One-page tables of events, exit codes and JSON fields, plus links to our tested hook guides.
- Hooks are commands, HTTP endpoints, MCP tool calls, prompts or subagents that Claude Code runs at fixed points in its lifecycle. The docs list 33 events.
- Exit 0 means success and exit 2 is a blocking error on events that can block. Any other exit code with no valid JSON is a non-blocking error, unless the hook sets onFailure to block.
- To stop a tool call before it runs, use PreToolUse with exit 2 or a permissionDecision of deny.
Hooks are user-defined shell commands, HTTP endpoints, MCP tool calls, LLM prompts or subagents that Claude Code runs automatically at specific points in its lifecycle. They fire the same way in the terminal, IDE extensions, the Desktop app and cloud sessions. This page is a reference: one table of every event, one table of exit codes and JSON fields, a minimal example, and links to our guides that test each kind of hook. We checked everything against the official hooks reference on 2026-10-24, with Claude Code 2.1.310.
Every hook event
The docs list 33 events. “Exit 2” says what happens when a hook on that event exits with code 2. “Matcher” says what the matcher field is compared with; “none” means the event has no matcher support and always fires.
| Event | Fires when | Exit 2 | Matcher |
|---|---|---|---|
SessionStart | A session starts or resumes | Shows stderr to the user only | How it started (startup, resume, clear, compact, fork) |
Setup | Only with --init-only, or --init / --maintenance with -p | Ignored | init, maintenance |
InstructionsLoaded | A CLAUDE.md or .claude/rules/*.md file is loaded | Ignored | Load reason |
UserPromptSubmit | A prompt is submitted, before Claude processes it | Blocks the prompt | none |
UserPromptExpansion | A typed command expands into a prompt | Blocks the expansion | Command name |
MessageDisplay | An assistant message streams to the screen | Original text is shown | none |
PreToolUse | After Claude builds a tool call, before it runs | Blocks the tool call | Tool name |
PermissionRequest | Claude Code is about to ask for permission | Not honored; deny with the decision object | Tool name |
PostToolUse | A tool completes successfully | Shows stderr to Claude; the tool already ran | Tool name |
PostToolUseFailure | A tool that started executing fails | Shows stderr to Claude | Tool name |
PostToolBatch | Every tool call in a batch has finished | Stops the loop before the next model call | none |
PermissionDenied | Auto mode denies a tool call | Ignored | Tool name |
Notification | Claude Code sends a notification | Ignored | Notification type |
SubagentStart | A subagent is spawned or resumed | Shows stderr to the user only | Agent type |
SubagentStop | A subagent finishes responding | Keeps the subagent working | Agent type |
TaskCreated | A task is created with TaskCreate | Rolls back the creation | none |
TaskCompleted | A task is marked completed | Keeps it from being marked completed | none |
Stop | The main agent finishes responding | Keeps Claude working | none |
StopFailure | The turn ends on an API error | Ignored, except terminalSequence | Error type |
TeammateIdle | An agent-team teammate is about to go idle | Keeps the teammate working | none |
ConfigChange | A configuration file changes mid-session | Blocks the change (except policy_settings) | Configuration source |
CwdChanged | A shell command changes the working directory | Shows stderr to the user only | none |
DirectoryAdded | A directory is added with /add-dir | Stderr goes to the debug log | How it was added |
FileChanged | A watched file changes on disk | Shows stderr to the user only | Literal filenames to watch |
WorktreeCreate | Claude Code creates a worktree | Any non-zero exit fails the creation | none |
WorktreeRemove | Claude Code removes a hook-created worktree | Any non-zero exit fails the removal | none |
PreCompact | Before a compact operation | Blocks compaction | manual, auto |
PostCompact | After a compact operation | Shows stderr to the user only | manual, auto |
PreModelSwitch | Before a requested model switch is applied | Blocks the switch | Target model name |
PostModelSwitch | After the session’s model changes | Shows stderr to the user only | Target model name |
SessionEnd | A session ends | Shows stderr to the user only | Why it ended |
Elicitation | An MCP server asks for user input | Declines the request | MCP server name |
ElicitationResult | The user answers an MCP elicitation | Blocks the response | MCP server name |
Source: https://code.claude.com/docs/en/hooks (checked 2026-10-24)
Sixteen events can block with exit 2: PreToolUse, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange, PostToolBatch, PreCompact, PreModelSwitch, Elicitation, ElicitationResult, WorktreeCreate and WorktreeRemove. WorktreeCreate and WorktreeRemove fail on any non-zero exit code, and WorktreeRemove only when the directory still exists afterward.
Exit codes and JSON output
| Your hook | Result |
|---|---|
exit 0 | Success. JSON on stdout, if any, is applied. For most events stdout goes to the debug log only; on UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch, plain stdout is added as context for Claude |
exit 2 | Blocking error on events that can block. Stderr is the reason. JSON is still read, but even "allow" can’t override the block |
| Any other code, including 1 | With plain text or no output: a non-blocking error; the action goes ahead and a hook error notice appears. With JSON that passes validation: the exit code is ignored and the JSON decides. The docs recommend exit 2 for policy hooks |
| Fails to start, times out, or prints invalid JSON | Non-blocking error by default. Exception: on PreModelSwitch, a hook canceled at its timeout blocks the model switch |
"onFailure": "block" on a command or HTTP hook | A failure (including exit 1) does what exit 2 does on that event; on PermissionRequest, where exit 2 isn’t honored, it denies the request. Requires Claude Code v2.1.295 or later. Has no effect on Stop, SubagentStop, TaskCompleted, TeammateIdle or async hooks |
Common JSON fields:
| Field | Where | What it does |
|---|---|---|
hookSpecificOutput.permissionDecision | PreToolUse | allow, deny, ask or defer. Precedence across hooks: deny > defer > ask > allow. defer only works in -p runs |
hookSpecificOutput.permissionDecisionReason | PreToolUse | Shown in the prompt for ask, shown to Claude for deny |
hookSpecificOutput.updatedInput | PreToolUse | Replaces the whole tool input |
decision: "block" and reason | PostToolUse, Stop, UserPromptSubmit and others | Top-level decision. On PostToolUse it adds the reason next to the tool result; on Stop it keeps Claude working |
hookSpecificOutput.additionalContext | Several events | Text added to Claude’s context |
systemMessage | Most events (some discard it) | A message shown to the user |
Source: https://code.claude.com/docs/en/hooks (checked 2026-10-24)
Where hooks live, and a minimal example
| Location | Scope |
|---|---|
~/.claude/settings.json | All your projects |
.claude/settings.json | One project; can be committed |
.claude/settings.local.json | One project; not shared |
| Managed policy settings | Organization-wide |
Plugin hooks/hooks.json | While the plugin is enabled |
| Skill or subagent frontmatter | After the skill is invoked, or while the subagent runs |
Hook entries merge across these levels instead of replacing each other (source: https://code.claude.com/docs/en/hooks, checked 2026-10-24).
The smallest useful PreToolUse hook blocks one command. This one stops npm publish in a dummy project:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-publish.sh" } ] }
]
}
}
#!/usr/bin/env bash
# .claude/hooks/no-publish.sh (PreToolUse, matcher: Bash)
cmd=$(jq -r '.tool_input.command // ""')
case "$cmd" in
*"npm publish"*) echo "Blocked: publishing is done by a human." >&2; exit 2 ;;
esac
exit 0
We piped dummy JSON into it on 2026-10-24. {"tool_name":"Bash","tool_input":{"command":"npm publish"}} printed Blocked: publishing is done by a human. and exited 2. npm test exited 0. The settings file passed jq and claude doctor. We did not watch it fire in a live session.
Our hook guides, in reading order
Start with the concepts, then copy an example, then debug, then design.
- Claude Code Hooks in Practice: why a hook beats a prose rule, what exit codes really do, and how we found holes in a git guard by measuring it.
- Claude Code PreToolUse Hook Examples: six copy-paste PreToolUse hooks, from blocking
rm -rf /and secret files to asking before migrations. - Claude Code PostToolUse: Format and Test on Edit: run Prettier and the tests after every edit, and why PostToolUse can report but not undo.
- Claude Code UserPromptSubmit Hook Examples: add git context to every prompt and block prompts that contain API keys, with the 30-second timeout in mind.
- Claude Code Stop Hook: Verify Before It Stops: keep Claude working until the tests pass, and the loop guard you need.
- Claude Code Hooks Not Working? A Checklist: the causes we reproduced, from invalid JSON to a missing jq that let everything through.
- Don’t Trust an Agent’s “Done”: using hooks and git numbers to check an agent’s completion report.
- Permission Design for AI Agents: how deny rules and hooks are evaluated, and letting an agent read production without writing.
- Claude Code settings.json Permissions: A Cheat Sheet: allow, ask and deny rules to pair with your hooks.
Summary
- Claude Code has 33 hook events; 16 of them can block with exit 2.
- Exit 0 is success and exit 2 blocks where the event allows it. Other exit codes let the action through unless the hook prints valid JSON that decides otherwise.
"onFailure": "block"(v2.1.295+) makes a crashed or timed-out hook fail closed, except on Stop-type and async hooks.- Use PreToolUse to stop a tool call, PostToolUse to report after one, and Stop to check work before Claude finishes.
- Hooks from user, project, local, managed and plugin settings merge rather than replace each other.
FAQ
- What is a PreToolUse hook in Claude Code?
- A hook that runs after Claude builds a tool call and before the call runs. It can allow, deny, ask or defer the call, or block it by exiting 2.
- How do I block a command with a Claude Code hook?
- Register a PreToolUse hook with a matcher such as Bash, check tool_input.command in the script, and exit 2 with the reason on stderr. Claude sees the stderr as the reason.
- What does exit code 2 mean in Claude Code hooks?
- It is a blocking error. On events that can block, such as PreToolUse, UserPromptSubmit and Stop, Claude Code stops the action. On events that can't, such as PostToolUse, it only shows stderr.
- Does exit code 1 block a tool call?
- Not by itself. Exit 1 with plain text or no output is a non-blocking error, and the tool call goes ahead. If the hook prints JSON that passes validation, the JSON decides, so a deny still blocks. With onFailure set to block (v2.1.295 or later), exit 1 counts as a failure and blocks. The docs recommend exit 2 for policy hooks.
- Why is my Claude Code hook not running?
- Common causes are a settings file in the wrong place, invalid JSON, a misspelled event name, a matcher with the wrong case, or a script that isn't executable. Our checklist covers each one.