Claude Code Hooks Explained: The Deterministic Layer Around Your Agent

From the guide: Claude Code Comprehensive Guide

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, and SessionStart, 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 error notice, 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 additionalContext is injected as plain text.
  • PostToolUse cannot undo. The tool already ran. Prevention lives in PreToolUse.
  • Stop fires on every response end, not only at “task complete,” and never on user interrupts (API errors fire StopFailure instead). Gate logic must tolerate mid-task stops.
  • PermissionRequest does not fire in headless (-p) mode. Use PreToolUse for automated permission decisions.
  • PreToolUse does not see @-referenced files. Files pulled in via @ in your prompt involve no tool call; use Read deny rules to protect paths from that route.1
  • Parallel updatedInput is 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 for MessageDisplay); 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 /debug mid-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.


  1. Anthropic, “Hooks reference — Hook lifecycle and hook events.” code.claude.com/docs/en/hooks#hook-events 

  2. 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 

  3. Anthropic, “Automate actions with hooks.” code.claude.com/docs/en/hooks-guide 

  4. Anthropic, “Hooks guide — Hooks and permission modes.” code.claude.com/docs/en/hooks-guide#hooks-and-permission-modes 

  5. Anthropic, “Hooks reference — JSON output and decision control.” code.claude.com/docs/en/hooks#json-output 

  6. Anthropic, “Hooks reference — Configuration: hook locations, matcher patterns, hook handler fields, the /hooks menu.” code.claude.com/docs/en/hooks#configuration 

  7. Anthropic, “Hooks guide — Limitations and troubleshooting.” code.claude.com/docs/en/hooks-guide#limitations-and-troubleshooting 

  8. Anthropic, “Hooks reference — Security considerations.” code.claude.com/docs/en/hooks#security-considerations 

  9. Anthropic, “Claude Code settings.” code.claude.com/docs/en/settings 

관련 게시물

Codex CLI vs Claude Code 2026: Architecture, Pricing, and China Access

Codex CLI vs Claude Code in 2026: kernel sandboxing, hook governance, model context, pricing, China cloud access, and wh…

29 분 소요

Claude Code Hooks: Why Each of My 95 Hooks Exists

I built 95 hooks for Claude Code. Each one exists because something went wrong. Here are the origin stories and the arch…

10 분 소요