Claude Code Hooks Explained: The Deterministic Layer Around Your Agent
What are Claude Code hooks? Hooks are user-defined shell commands (plus HTTP endpoints, MCP tools, and model prompts) that Claude Code executes automatically at fixed points in its lifecycle: before a tool call, after an edit, at session start, when Claude finishes responding.1 Where CLAUDE.md gives the model instructions it will probably follow, hooks execute whether or not the model cooperates. Type /hooks inside any session to see every lifecycle event and what is wired to it.
{.answer-block}
Most developers run Claude Code with two layers of control: permissions gating what the agent may do, and CLAUDE.md describing what it should do. Hooks are the third layer, and the only one that guarantees anything. Below: the mental model, every lifecycle event in the current docs, the exact input/output contract, configuration, five working patterns, and a decision framework. Every API detail was verified against the official hooks reference and guide as of July 1, 2026 — this system moves fast, so where this post and the reference disagree, the reference wins. (New to Claude Code? Start with the 5-minute setup or the New to Claude Code path.)
TL;DR: Hooks receive JSON on stdin and answer with exit codes or JSON on stdout. Exit 0 allows, exit 2 blocks (on the events that can block), and exit 1 — the conventional Unix failure code — blocks nothing, which is the single biggest hook footgun.2 Configure them in settings.json under event names like PreToolUse and Stop, filtered by matchers. Use hooks for anything that must always happen; use CLAUDE.md for anything the model should merely know.
The mental model: guarantees around a non-deterministic core
A coding agent is a probabilistic system. Ask it to run Prettier after every edit and it will — most of the time. It might skip the step when the change looks trivial, when context runs long, or when your phrasing lands differently. CLAUDE.md, skills, and prompts are all suggestions: high-quality, usually followed, never guaranteed.
Hooks are the deterministic shell around that core. The official definition: “user-defined shell commands, HTTP endpoints, or LLM prompts that execute automatically at specific points in Claude Code’s lifecycle,” providing “deterministic control over Claude Code’s behavior, ensuring certain actions always happen rather than relying on the LLM to choose to run them.”3 The formatter fires on every edit. The command guard evaluates every Bash call. The completion gate checks every finish.
The enforcement is real, not cosmetic: PreToolUse hooks fire before any permission-mode check, so a hook that returns permissionDecision: "deny" blocks the tool even in bypassPermissions mode or under --dangerously-skip-permissions. The reverse does not hold — a hook returning "allow" cannot loosen deny rules from settings. Hooks can tighten policy past what permissions allow, never weaken it.4
The lifecycle: every hook event
As of July 1, 2026, the reference documents 30 hook events.1 They fall into three cadences: once per session (SessionStart, SessionEnd), once per turn (UserPromptSubmit, Stop, StopFailure), and on every tool call inside the agentic loop (PreToolUse, PostToolUse). The rest fire on specific conditions — config changes, compaction, subagents, MCP interactions.
| Event | Fires | One real use |
|---|---|---|
SessionStart |
Session begins or resumes | Inject git branch and open issues as context |
Setup |
--init-only, or --init/--maintenance in -p mode |
Install dependencies in CI before the agent runs |
UserPromptSubmit |
You submit a prompt, before Claude processes it | Append the current date; reject prompts containing secrets |
UserPromptExpansion |
A typed command expands into a prompt | Audit or veto skill/command expansions |
PreToolUse |
Before a tool call executes | Block destructive shell commands |
PermissionRequest |
A permission dialog appears | Auto-approve trusted commands so you are not prompted |
PermissionDenied |
Auto-mode classifier denies a tool call | Return retry: true so the model may retry |
PostToolUse |
After a tool call succeeds | Auto-format every edited file |
PostToolUseFailure |
After a tool call fails | Log failing commands for triage |
PostToolBatch |
After a batch of parallel tool calls, before the next model call | Checkpoint or halt the agentic loop |
Notification |
Claude Code sends a notification | Desktop alert when Claude needs input |
MessageDisplay |
While assistant message text is displayed | Redact on screen (display-only; transcript unchanged) |
SubagentStart |
A subagent is spawned | Inject agent-type-specific context |
SubagentStop |
A subagent finishes | Validate subagent output before it returns |
TaskCreated |
A task is created via TaskCreate |
Enforce task naming or scope rules |
TaskCompleted |
A task is marked completed | Verify acceptance criteria before completion sticks |
Stop |
Claude finishes responding | Completion gate: block finishing until tests pass |
StopFailure |
The turn ends due to an API error | Alert on rate_limit or billing_error (log-only; output ignored) |
TeammateIdle |
An agent-team teammate is about to go idle | Keep teammates working through a queue |
InstructionsLoaded |
A CLAUDE.md or .claude/rules/*.md file loads into context |
Log which instructions entered the session |
ConfigChange |
A configuration file changes mid-session | Block unauthorized settings edits |
CwdChanged |
The working directory changes | Reload direnv-style environments |
FileChanged |
A watched file changes on disk | Refresh env vars when .env changes |
WorktreeCreate |
A worktree is created via --worktree or isolation: "worktree" |
Replace default git worktree provisioning |
WorktreeRemove |
A worktree is removed | Custom cleanup at session or subagent exit |
PreCompact |
Before context compaction | Save state you cannot afford to lose |
PostCompact |
After compaction completes | Re-inject critical context |
Elicitation |
An MCP server requests user input | Auto-fill forms in headless runs |
ElicitationResult |
After you answer an MCP elicitation | Validate or override the response before it returns |
SessionEnd |
The session terminates | Archive logs, tear down resources |
You will not need most of these. Nearly every production setup is built from five: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, and Stop. The rest exist for the day you need them.
The contract: JSON in, exit codes or JSON out
Command hooks receive JSON on stdin and answer through exit codes, stdout, and stderr. (HTTP hooks receive the same JSON as a POST body and answer through the response body.)2
Every event delivers a common envelope — session_id, transcript_path, cwd, and hook_event_name, with permission_mode on most events — plus event-specific fields. A PreToolUse hook for a Bash command receives:
{
"session_id": "abc123",
"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" }
}
Other events swap the tail: UserPromptSubmit carries prompt, SessionStart carries source (startup/resume/clear/compact), Stop carries stop_hook_active and last_assistant_message. Hooks fired inside subagents additionally receive agent_id and agent_type.2
Exit codes
Three outcomes:2
- Exit 0 — success. Claude Code parses stdout for JSON output fields. For most events stdout goes to the debug log only; for
UserPromptSubmit,UserPromptExpansion, andSessionStart, plain stdout is added as context Claude can see. - Exit 2 — blocking error. Stdout (including any JSON) is ignored; stderr is fed back to Claude as the error message. What “block” means depends on the event.
- Any other exit code — non-blocking error. The transcript shows a
<hook name> hook errornotice, and execution continues.
That last line deserves bold: exit 1 does not block anything. The docs warn about this directly — Claude Code treats exit 1 as a non-blocking error and proceeds, even though 1 is the conventional Unix failure code. Policy hooks must exit 2.2
What exit 2 does, per event:2
| Event | Exit 2 effect |
|---|---|
PreToolUse |
Blocks the tool call |
PermissionRequest |
Denies the permission |
UserPromptSubmit |
Blocks processing and erases the prompt |
UserPromptExpansion |
Blocks the expansion |
Stop / SubagentStop |
Prevents stopping; the conversation continues |
TeammateIdle |
Prevents the teammate from going idle |
TaskCreated / TaskCompleted |
Rolls back creation / prevents completion |
ConfigChange |
Blocks the config change (except policy_settings) |
PreCompact |
Blocks compaction |
PostToolBatch |
Stops the agentic loop before the next model call |
Elicitation / ElicitationResult |
Denies the elicitation / turns the response into a decline |
WorktreeCreate |
Any non-zero exit aborts worktree creation |
Everything else cannot block. PostToolUse and PostToolUseFailure show stderr to Claude (the tool already ran); SessionStart, Notification, SessionEnd, CwdChanged, FileChanged, PostCompact, SubagentStart, and Setup show stderr to the user only; StopFailure, InstructionsLoaded, MessageDisplay, and PermissionDenied ignore the exit code — for PermissionDenied the only lever is JSON retry: true.2
JSON output
For finer control than block-or-silence, exit 0 and print a JSON object to stdout. One rule up front: exit codes or JSON, never both — JSON is only processed on exit 0, and exit 2 discards it.5
Universal fields work on every event: continue: false stops Claude entirely (with stopReason shown to the user), suppressOutput hides stdout from the transcript, systemMessage shows the user a warning, and terminalSequence emits an allowlisted terminal escape (desktop notification, window title, bell). Decision fields are per-event:5
| Events | Decision pattern | Key fields |
|---|---|---|
UserPromptSubmit, UserPromptExpansion, PostToolUse, PostToolUseFailure, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompact |
Top-level decision |
decision: "block" + reason (shown to Claude). Omit decision to allow |
PreToolUse |
hookSpecificOutput |
permissionDecision: "allow" | "deny" | "ask" | "defer", plus permissionDecisionReason and updatedInput to rewrite tool arguments before execution |
PermissionRequest |
hookSpecificOutput |
decision.behavior: "allow" | "deny", optional decision.updatedInput |
PermissionDenied |
hookSpecificOutput |
retry: true tells the model it may retry |
PostToolUse |
hookSpecificOutput |
updatedToolOutput replaces the tool result |
Stop / SubagentStop |
hookSpecificOutput |
additionalContext: non-error feedback that continues the conversation without counting as a hook error |
SessionStart, Setup, SubagentStart |
Context only | additionalContext, plus SessionStart-only initialUserMessage, sessionTitle, watchPaths, reloadSkills. No blocking |
MessageDisplay |
hookSpecificOutput |
displayContent replaces on-screen text only |
Elicitation / ElicitationResult |
hookSpecificOutput |
action: "accept" | "decline" | "cancel", plus content |
WorktreeRemove, Notification, SessionEnd, PostCompact, InstructionsLoaded, StopFailure, CwdChanged, FileChanged |
None | Side effects only |
Two details that bite people. First, PreToolUse is the exception to the top-level-decision pattern: it used top-level decision/reason historically, but those are deprecated for this event ("approve"/"block" map to "allow"/"deny"); use hookSpecificOutput.permissionDecision.5 Second, when multiple PreToolUse hooks disagree, precedence is deny > defer > ask > allow.5
Configuration: settings.json, matchers, scope
Hook configuration nests three levels: pick an event, add a matcher group to filter when it fires, and define one or more hook handlers to run.6
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "/path/to/lint-check.sh" }
]
}
]
}
}
Where you put this determines scope: ~/.claude/settings.json applies to all your projects, .claude/settings.json is project-scoped and committable, .claude/settings.local.json is project-scoped and gitignored, and the standard settings precedence applies — managed policy over local over project over user.9 Hooks can also ship in plugins (hooks/hooks.json) and in skill or agent frontmatter, and enterprise admins can enforce managed hooks that users cannot override.6
Matchers are evaluated by their characters: "*", "", or an omitted matcher matches everything; a value containing only letters, digits, _, -, spaces, commas, and | is an exact string or list (Bash, Edit|Write); anything else becomes an unanchored JavaScript regex, so Edit.* matches both Edit and NotebookEdit — anchor with ^Edit$ when you mean exactly one tool. Matchers are case-sensitive, and each event matches on its own field: tool name for tool events, source for SessionStart, agent type for SubagentStart, notification type for Notification.6 For sharper filtering on tool events, the per-handler if field accepts one permission rule like "Bash(git *)" — but it is best-effort (it fails open on unparseable commands), so use permission rules, not if, for hard guarantees.6
Handlers come in five types: command (shell), http (POST endpoint), mcp_tool, prompt (single-turn model evaluation), and agent (a subagent with Read/Grep/Glob access; experimental). Default timeouts: 600 seconds for command/http/mcp_tool (lowered to 30 for UserPromptSubmit and 10 for MessageDisplay), 30 for prompt, 60 for agent — override per hook with timeout.6 All matching hooks run in parallel with identical handlers deduplicated, and $CLAUDE_PROJECT_DIR points scripts at your project root.
Verify with /hooks: a read-only browser showing every event, its configured hooks, and which settings file each came from. To change anything, edit the JSON (or ask Claude to). To kill everything temporarily, set "disableAllHooks": true.6
Five patterns
Genericized and minimal. The hooks tutorial builds fuller production versions of several of these, and Hooks for Apple Development applies them to the iOS toolchain.
1. Auto-format after edits (PostToolUse)
Straight from the official guide — every file Claude touches gets formatted, no exceptions:3
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}
Swap the command for ruff format, gofmt, or swiftformat as your stack demands.
2. Block dangerous commands (PreToolUse, exit 2)
#!/bin/bash
# .claude/hooks/guard-bash.sh — register on PreToolUse, matcher "Bash"
command=$(jq -r '.tool_input.command // empty')
case "$command" in
*"rm -rf"* | *"git push --force"* | *"DROP TABLE"*)
echo "Blocked: matches a destructive pattern. Propose a safer alternative." >&2
exit 2 ;;
esac
exit 0
Exit 2 blocks the call and feeds stderr back to Claude, which adjusts course instead of retrying blindly. The JSON equivalent — permissionDecision: "deny" with a reason — does the same with room to grow into "ask" (escalate to the human) or updatedInput (rewrite the command).5
3. Inject context on session start (SessionStart)
Plain stdout from a SessionStart hook becomes context Claude can see — no JSON required:1
#!/bin/bash
# .claude/hooks/session-context.sh — register on SessionStart
echo "Current branch: $(git branch --show-current)"
echo "Recent commits:"
git log --oneline -5
echo "Uncommitted files: $(git status --porcelain | wc -l | tr -d ' ')"
exit 0
Use this for dynamic state. Static conventions belong in CLAUDE.md, which the docs themselves recommend for context that does not require a script.1
4. A completion gate on Stop
Stop fires when Claude finishes responding. Blocking it forces the agent to keep working until a condition holds:
#!/bin/bash
# .claude/hooks/stop-gate.sh — register on Stop
input=$(cat)
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # already continuing because of this hook; don't loop forever
fi
if ! npm test --silent > /tmp/stop-gate.log 2>&1; then
jq -n '{decision: "block", reason: "Tests are failing. Fix them before finishing. Log: /tmp/stop-gate.log"}'
fi
exit 0
The stop_hook_active check matters: Claude Code hard-caps a Stop hook at 8 consecutive blocks, and a gate that never checks whether it already triggered a continuation will burn straight through them.7 For softer steering, return hookSpecificOutput.additionalContext instead of decision: "block" — same continuation, but labeled feedback rather than a hook error. And for one-off conditions, the built-in /goal command is a session-scoped prompt-based Stop hook with zero configuration.1
5. The dispatcher: one entry point, many small hooks
Registering ten hooks means ten settings.json entries that drift across machines and projects. The alternative: register one dispatcher per event and route by convention.
#!/bin/bash
# .claude/hooks/dispatch.sh — register once per event you care about
input=$(cat)
event=$(echo "$input" | jq -r '.hook_event_name')
dir="$CLAUDE_PROJECT_DIR/.claude/hooks/$event"
[ -d "$dir" ] || exit 0
for hook in "$dir"/*.sh; do
[ -x "$hook" ] || continue
echo "$input" | "$hook" || exit $?
done
exit 0
Adding a guard is now chmod +x on a new file in .claude/hooks/PreToolUse/ — settings.json never changes, each script stays small enough to test in isolation, and the first exit 2 propagates. One caveat: the dispatcher serializes what Claude Code would run in parallel, and it suits exit-code hooks best — a JSON-emitting hook should stay standalone, since stdout must contain exactly one JSON object.5
Hook vs CLAUDE.md vs skill vs memory
Four mechanisms, four jobs:
| Mechanism | Job | Rule for choosing |
|---|---|---|
| Hook | Enforcement | If skipping it must be impossible — formatting, safety, gates — it’s a hook |
| CLAUDE.md | Guidance | If it’s a convention the model should know every session — stack, style, commands — it’s CLAUDE.md |
| Skill | Capability | If it’s a procedure with its own instructions and scripts, invoked when relevant, it’s a skill |
| Memory | Recall | If it’s a fact learned in one session that future sessions need, it’s memory |
The failure mode runs in both directions. Encoding conventions as hooks buys you brittle scripts enforcing things a sentence of guidance handles fine. Encoding policy as CLAUDE.md prose buys you an agent that force-pushes to main on the one day it matters. The test: what is the cost when the model ignores this once? Annoyance → CLAUDE.md. Incident → hook.
What hooks can’t do
Honest limits, all from the official docs:7
- Hooks cannot call tools or slash commands. Command hooks speak stdout, stderr, and exit codes — nothing else. Context returned via
additionalContextis injected as plain text. PostToolUsecannot undo. The tool already ran. Prevention lives inPreToolUse.Stopfires on every response end, not only at “task complete,” and never on user interrupts (API errors fireStopFailureinstead). Gate logic must tolerate mid-task stops.PermissionRequestdoes not fire in headless (-p) mode. UsePreToolUsefor automated permission decisions.PreToolUsedoes not see@-referenced files. Files pulled in via@in your prompt involve no tool call; useReaddeny rules to protect paths from that route.1- Parallel
updatedInputis non-deterministic. When several PreToolUse hooks rewrite the same tool’s arguments, the last to finish wins. Let one hook own each rewrite. - Timeouts cancel the hook. 600 seconds default for command hooks (30 for
UserPromptSubmit, 10 forMessageDisplay); a slow gate that times out is a gate that didn’t run. - Output is capped at 10,000 characters — overflow is written to a file and replaced with a preview.
- Hooks run with your full user permissions. The reference’s own warning: 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.”8 Quote your variables, use absolute paths, skip sensitive files.
- A broken hook degrades every session until fixed. Debug with the transcript view (
Ctrl+O),claude --debug-file /tmp/claude.log, or/debugmid-session; a classic gotcha is a shell profile that echoes on startup and corrupts your hook’s JSON output.7
FAQ
What are Claude Code hooks?
Hooks are user-defined commands — shell scripts, HTTP endpoints, MCP tools, or model prompts — that Claude Code executes automatically at specific lifecycle points.3 They receive event JSON on stdin and answer with exit codes or JSON: block a tool call, inject context, rewrite arguments, keep the agent working. Unlike CLAUDE.md instructions, they execute every time, regardless of model behavior.
What’s the difference between PreToolUse hooks and permissions?
Permission rules are declarative: static allow/deny/ask patterns that Claude Code evaluates itself. PreToolUse hooks are programmable: your code inspects the full tool input and decides. Hooks fire before permission-mode checks, so a hook’s "deny" holds even in bypassPermissions mode — but a hook’s "allow" cannot override a settings deny rule.4 Use permission rules for anything a pattern can express; reach for a hook when the decision needs logic, external state, or input rewriting.
Do hooks work in headless (-p) mode?
Yes — with one documented exception: PermissionRequest hooks do not fire in non-interactive mode, so automated permission decisions belong in PreToolUse.7 Headless mode also unlocks one option interactive sessions ignore: permissionDecision: "defer", which pauses a tool call so a wrapping process (an Agent SDK app, a custom UI) can collect input and resume the session later.5
Why does my hook run but not block anything?
Almost always a contract violation. Exit 1 doesn’t block — only exit 2 does, and only on events that support blocking.2 JSON decisions are only parsed on exit 0 — a script that prints {"decision": "block"} and then exits 2 has its JSON discarded. And matchers are case-sensitive — bash never matches Bash. Confirm registration with /hooks, then test by piping sample JSON into the script and checking echo $?.7
Sources
Verified against the official documentation on July 1, 2026. The hooks API has changed materially across Claude Code v2.1.x releases (new events, new fields, matcher semantics), so treat version-sensitive details as “as of this date.”
Related on this site: the Claude Code guide’s hooks section for the full-system view including prompt and agent hooks, the hooks tutorial for five production builds with complete configs, Hooks for Apple Development for the applied iOS patterns, and the quickstart if you haven’t installed Claude Code yet.
-
Anthropic, “Hooks reference — Hook lifecycle and hook events.” code.claude.com/docs/en/hooks#hook-events ↩↩↩↩↩↩
-
Anthropic, “Hooks reference — Hook input and output; exit code output; exit code 2 behavior per event.” code.claude.com/docs/en/hooks#exit-code-output ↩↩↩↩↩↩↩↩
-
Anthropic, “Automate actions with hooks.” code.claude.com/docs/en/hooks-guide ↩↩↩
-
Anthropic, “Hooks guide — Hooks and permission modes.” code.claude.com/docs/en/hooks-guide#hooks-and-permission-modes ↩↩
-
Anthropic, “Hooks reference — JSON output and decision control.” code.claude.com/docs/en/hooks#json-output ↩↩↩↩↩↩↩
-
Anthropic, “Hooks reference — Configuration: hook locations, matcher patterns, hook handler fields, the /hooks menu.” code.claude.com/docs/en/hooks#configuration ↩↩↩↩↩↩
-
Anthropic, “Hooks guide — Limitations and troubleshooting.” code.claude.com/docs/en/hooks-guide#limitations-and-troubleshooting ↩↩↩↩↩
-
Anthropic, “Hooks reference — Security considerations.” code.claude.com/docs/en/hooks#security-considerations ↩
-
Anthropic, “Claude Code settings.” code.claude.com/docs/en/settings ↩