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.

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 August 8, 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 guide opens with a single-line definition – “Hooks are user-defined shell commands.” – and states the point plainly: hooks give you “deterministic control: certain actions always happen rather than relying on the LLM to choose to run them.”3 (The guide’s single line undersells the current surface; the reference’s fuller definition already adds HTTP endpoints and LLM prompts, and handlers also come as MCP tools – covered under Configuration below.) 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 September 6, 2026, the reference documents 33 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.

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 same events in the Agent SDK

If you are building on the Claude Agent SDK rather than driving the CLI, you do not get a different hook system. The SDK fires the same events and registers hooks as callback functions in the hooks field of your agent options, filtered by the same matcher syntax.10 In Python that is ClaudeAgentOptions(hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[my_callback])]}); in TypeScript it is the hooks key on the options object passed to query(). A callback returns the same JSON output shape a shell hook prints on stdout, so a PreToolUse deny or a PermissionRequest decision looks identical in both worlds.10

Two things differ. First, coverage: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, SubagentStart, SubagentStop, PreCompact, PermissionRequest, and Notification are available in both SDKs; SessionStart, SessionEnd, Setup, PostToolBatch, PermissionDenied, the compaction, model-switch, task, worktree, elicitation, and file-watch events are TypeScript-only at the time of writing.10 Second, layering: shell-command hooks from your settings files still run inside an SDK app whenever the matching settingSources (setting_sources in Python) entry is enabled, which the default query() options do. An SDK app can therefore inherit a project’s settings.json hooks and add callbacks on top of them.10

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/fork – the fifth value arrived with forked sessions in v2.1.214, and a source-matching hook copied from an older four-value list will silently miss forks), 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

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, while DirectoryAdded sends stderr to the debug log 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

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 – the most restrictive answer wins. Even so, give each decision one owning hook rather than leaning on the tie-break.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 One semantics change to know: since v2.1.214, a single-segment path pattern in if (like Edit(src/**)) matches only a top-level src under the working directory; an if written before that release quietly stops matching nested paths like packages/app/src/ – write Edit(**/src/**) for the old any-depth behavior.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 caps a Stop hook at 8 consecutive blocks by default (raiseable with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP), 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:

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 in- PreToolUse.

- Stopfires on every response end, not only at “task complete,” and never on user interrupts (API errors fire- StopFailureinstead). Gate logic must tolerate mid-task stops.

- PermissionRequestdoes not fire in plain headless (- -p) runs. It does fire in- -pwhen an Agent SDK- canUseToolcallback supplies the prompt, and for background-subagent tool calls; for everything else automated, use- PreToolUse.

- PreToolUsedoes not see- @-referenced files. Files pulled in via- @in your prompt involve no tool call; use- Readdeny rules to protect paths from that route.1

- Parallel updatedInputis unreliable by design. When several PreToolUse hooks rewrite the same tool’s arguments, only one rewrite survives and you don’t control which. 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 nuance: PermissionRequest hooks skip plain -p runs (nothing supplies a permission prompt there), though they do fire when an Agent SDK canUseTool callback provides one, and for background-subagent tool calls. Automated permission decisions for plain headless runs 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 August 8, 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 ↩

-

Anthropic, “Agent SDK — Intercept and control agent behavior with hooks: available hooks (Python vs TypeScript), configure hooks, hook output.” code.claude.com/docs/en/agent-sdk/hooks (verified September 6, 2026) ↩↩↩↩