← All posts

Explainer

What Claude Code hooks are, and why they matter

What Claude Code hooks are, and why they matter

A Claude Code hook is a handler the agent runs when a lifecycle event fires, with a JSON object describing that event piped to its stdin. You register hooks in settings.json keyed by event name. There is no SDK and nothing to register with: a script that reads stdin and sets an exit code is a valid hook. There are 30 documented events; three of them — PreToolUse, Notification and Stop — carry most of the practical value. The single rule people get wrong: exit 2 blocks, exit 1 does not.

Most people use Claude Code as a black box: you give it a task, it works, you read the output. But the agent is broadcasting events the whole time it runs — it's about to run a command, it's waiting on you, it just finished its turn. Hooks are how you tap that stream. They're the difference between staring at a terminal hoping to catch the moment it needs you, and having the agent tell you.

agent runs event fires turn continues your hook reads JSON on stdin notify / gate toast, allow, deny
The agent runs, a lifecycle event fires, your handler reads the JSON and decides what to do — notify you, or allow/deny the action.

How a hook actually works

You register hooks in your Claude Code settings (settings.json), keyed by event name. When that event fires, Claude Code spawns your command and pipes a JSON object into its stdin describing what just happened — which tool, which session, the working directory, and so on. Your command does whatever it wants with that, and for some events its exit code and stdout feed a decision back into the agent.

A minimal handler is genuinely a one-liner. Read stdin, pull a field, do something:

#!/usr/bin/env bash
payload="$(cat)"
tool="$(echo "$payload" | jq -r '.tool_name')"
echo "Claude wants to run: $tool"

That's it — no special runtime. The important nuance is that hooks aren't all read-only. Some are pure side-effect (fire a notification, write a log); others are decision points where what your script returns changes what the agent does next. That second category is what makes hooks more than a logging mechanism.

command is one of five handler types. The others are http (Claude Code POSTs the same JSON to a URL you control), mcp_tool (call a tool on an MCP server), prompt (hand the event to a model rather than a script), and agent, which the docs still label experimental and subject to change. Everything below describes the command contract, because it is the one that has stayed stable.

One caution up front: event names, payload fields and decision semantics do move between releases. Everything here was verified against the reference on July 30, 2026, against Claude Code 2.1.220 (npm latest; stable was 2.1.212). Several behaviours below carry explicit minimum versions, and they are noted inline. Before wiring something up, check Anthropic's hooks reference — note that the old docs.anthropic.com/en/docs/claude-code/hooks URL now 301s to code.claude.com/docs/en/hooks.

All 30 hook events

The reference documents 30. You will still see "9 hook events" in older write-ups; that framing predates PermissionRequest, PostToolBatch, MessageDisplay, TeammateIdle and the Task* / Worktree* families. Cadence varies: SessionStart and SessionEnd fire once per session, UserPromptSubmit / Stop / StopFailure once per turn, and PreToolUse / PostToolUse on every tool call except EndConversation, which skips both. Two columns tell you more than a description of each would: what the matcher is tested against, and whether exiting 2 there stops anything.

EventMatcher matches onCan exit 2 block?
SessionStartsession source: startup, resume, clear, compact, forkNo
SetupCLI flag: init, maintenanceNo
UserPromptSubmitno matcherYes — blocks processing and erases the prompt
UserPromptExpansionsee referenceYes — blocks the expansion
PreToolUsetool nameYes — blocks the tool call
PermissionRequesttool nameYes — denies the permission
PermissionDeniedtool nameNo — exit code and stderr ignored
PostToolUsetool nameNo — the tool already ran
PostToolUseFailuretool nameNo
PostToolBatchno matcherYes — stops the loop before the next model call
Notificationnotification type (8 values, below)No
MessageDisplayno matcherNo
SubagentStartagent typeNo
SubagentStopagent typeYes
TaskCreatedno matcherYes — rolls back creation
TaskCompletedno matcherYes
Stopno matcherYes — prevents stopping, continues the turn
StopFailureerror type: rate_limit, overloaded, authentication_failed, billing_error, …No — output and exit code ignored
TeammateIdleno matcherYes
InstructionsLoadedload reasonNo
ConfigChangeconfig source: user_settings, project_settings, local_settings, policy_settings, skillsYes — except policy_settings
CwdChangedno matcherNo
FileChangednarrower exact-match set (letters, digits, _ and | only)No
WorktreeCreateno matcherYes — and any non-zero exit aborts
WorktreeRemoveno matcherNo
PreCompactmanual | autoYes
PostCompactmanual | autoNo
Elicitationsee referenceYes
ElicitationResultsee referenceYes — action becomes decline
SessionEndexit reason: clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, otherNo

A 31st event, DirectoryAdded, appears in the 2.1.219 changelog — it fires after /add-dir or an SDK register_repo_root request registers a new working directory mid-session — but it has not reached the reference page yet and no payload schema is published. Treat it as shipped-but-undocumented.

What your script actually reads on stdin

Every event delivers a JSON object with a common core: session_id, prompt_id (the UUID of the current user prompt, matching the OpenTelemetry prompt.id; absent until the first user input, v2.1.196+), transcript_path, cwd and hook_event_name. Two fields are conditional and worth not assuming: permission_mode (the docs say not all events receive it — the published Notification, SessionStart, SessionEnd and Worktree* examples omit it) and effort, documented as present for events that fire inside a tool-use context when the model supports the parameter. Inside a subagent, or with --agent, you also get agent_id and agent_type.

A trap on permission_mode: the UI mode labelled Manual arrives as "default", never "manual". The full set is default, plan, acceptEdits, auto, dontAsk, bypassPermissions.

Here is the shape PreToolUse receives, verbatim from the docs:

{
  "session_id": "abc123",
  "prompt_id": "550e8400-e29b-41d4-a716-446655440000",
  "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",
    "description": "Run test suite",
    "timeout": 120000,
    "run_in_background": false
  },
  "tool_use_id": "toolu_01ABC123..."
}

tool_input is the per-tool part, and its shape is the thing you actually parse: Bash gives command / description / timeout / run_in_background; Write gives file_path / content; Edit gives file_path / old_string / new_string / replace_all; Read gives file_path / offset / limit; WebFetch gives url / prompt; Agent gives prompt / description / subagent_type / model. PostToolUse adds tool_response and duration_ms; PostToolUseFailure adds error and is_interrupt.

Hooks also get environment variables: $CLAUDE_PROJECT_DIR, $CLAUDE_PLUGIN_ROOT, $CLAUDE_PLUGIN_DATA, $CLAUDE_CODE_REMOTE, $CLAUDE_EFFORT, and $CLAUDE_ENV_FILE (SessionStart, Setup, CwdChanged and FileChanged only). There is no $CLAUDE_MODEL; only SessionStart can receive a model field, and even there it is not guaranteed. All OTEL_* exporter variables are stripped from hook subprocesses.

Matcher syntax: three paths, decided by one character

matcher looks like a regex field and usually isn't. Claude Code picks an evaluation path based on which characters the string contains:

MCP tools are matched as ordinary tool names in the form mcp__<server>__<tool>, and this is where people lose an afternoon: the trailing .* is required. mcp__memory alone is exact-matched and matches nothing; you want mcp__memory__.* or mcp__.*__write.*. Plugin-bundled servers appear as mcp__plugin_<plugin>_<server>__<tool>. Matchers are case-sensitive throughout. For narrowing inside a matched event, the per-handler if field takes permission-rule syntax: "Bash(git *)", "Edit(*.ts)". It holds exactly one rule — there is no && or || — and it is only evaluated on tool events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied); on any other event, a handler with if set never runs.

Exit codes: the part everyone gets wrong

Exit 1 does not block anything. Claude Code proceeds. Policy hooks must exit 2. The full contract for command hooks:

Exit codeMeaningWhat Claude sees
0Success — and the only case where stdout is parsed as JSONNothing, except for UserPromptSubmit, UserPromptExpansion and SessionStart, where plain stdout is injected as context
2Blocking error; stdout and any JSON in it are ignoredstderr, fed back as the error message
anything else (incl. 1)Non-blocking error; execution continuesNothing — the user sees a <hook name> hook error notice plus the first line of stderr

As of v2.1.214, a hook that exits 2 while printing schema-invalid JSON still blocks, with stderr as the reason; before that release the combination silently fell through as non-blocking. And for SessionStart, Setup and SubagentStart, an exit-2 stderr renders as a hook-error notice the model never sees (v2.1.199+).

Decision control: how a hook says allow or deny

Pick one approach per hook — exit codes alone, or exit 0 and print JSON on stdout. Mixing them fails silently, because JSON is only processed on exit 0.

PreToolUse is the important one, and it does not use the top-level decision field. It returns:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "npm test is on the safe list",
    "updatedInput": { "command": "npm test --silent" }
  }
}

allow skips the permission prompt. deny prevents the call. ask forces a user prompt, labelled with its source ([User], [Project], [Plugin], [Local]). defer exits gracefully for later resume and is honoured only in -p headless mode. When several hooks disagree the precedence is deny > defer > ask > allow.

Reason visibility differs per decision, which matters if you want the model to learn from a block: for allow and ask the reason is shown to the user but not to Claude; for deny it is shown to Claude; for defer it is ignored. updatedInput replaces the entire input object, so include unchanged fields. The old top-level decision: "approve" | "block" is deprecated for this event and maps onto allow/deny.

The asymmetry worth internalising: hooks can tighten permissions, never loosen them. A hook deny blocks even under bypassPermissions / --dangerously-skip-permissions, but a hook allow cannot override a settings deny rule. And files pulled in with an @ reference in a prompt fire no PreToolUse hook at all, because no tool call happens — if you need to gate those, use a Read deny rule. Our notes on pre-approval policies go into where that boundary should sit.

Other events use the top-level form {"decision": "block", "reason": "..."}block is the only accepted value, and to allow you omit it. That set: UserPromptSubmit, UserPromptExpansion, PostToolUse, PostToolUseFailure, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompact.

Four JSON fields work anywhere. continue: false stops Claude entirely and outranks any event-specific decision (stopReason explains it to the user); suppressOutput hides stdout from the transcript; systemMessage shows the user a warning; and terminalSequence (v2.1.141+) has Claude Code emit an allowlisted escape sequence for you — OSC 0/1/2 titles, OSC 9, OSC 99, OSC 777 and bare BEL are permitted, while CSI, palette changes and OSC 8/52/1337 are rejected. Every output string, additionalContext included, is capped at 10,000 characters; longer output goes to a file and is replaced by a preview and its path.

The three events that matter most

Claude Code fires hooks across its lifecycle, but three carry almost all the value for staying out of the terminal. Learn these and the rest are variations.

There are more — events around subagents finishing, sessions ending, prompts being submitted — but if you only ever wire up these three, you've covered "is it asking me something," "is it done," and "should this even be allowed to run." Those are the three questions that otherwise force you to keep eyes on the terminal.

What each event is good for

The split is roughly: PreToolUse is for control, Notification and Stop are for awareness. Knowing which bucket you're in tells you whether your hook needs to return a decision or just fire and forget.

Where hooks live, and how the scopes combine

Hook entries merge across settings levels rather than overriding each other. That is different from ordinary settings keys, where precedence runs managed > CLI args > local > project > user. If a hook fires twice, this is usually why.

FileScopeShared?
~/.claude/settings.jsonAll your projects (User Settings)No
.claude/settings.jsonThis project (Project Settings)Yes — committed
.claude/settings.local.jsonThis project, just you (Local Settings)No — gitignored
Managed policy settingsOrganisation-wide, admin-controlled: /Library/Application Support/ClaudeCode/managed-settings.json (macOS), /etc/claude-code/managed-settings.json (Linux), C:\Program Files\ClaudeCode\managed-settings.json (Windows), plus a managed-settings.d/ drop-in merged alphabeticallyEnforced
Plugin hooks/hooks.jsonActive while the plugin is enabledShips with the plugin

disableAllHooks: true removes user, project and plugin hooks, but cannot disable managed hooks unless it is set at the managed level. There is no way to disable a single hook while keeping it in config — you delete the entry. Enterprise deployments get three more keys: allowManagedHooksOnly, allowedHttpHookUrls (an allowlist supporting *; undefined means unrestricted, an empty array blocks all) and httpHookAllowedEnvVars.

Security: a hook config is executable code that arrives with a repo

The vendor's own warning is blunt: "Command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access." That is the real caveat, and it has a specific shape — a .claude/settings.json is executable code that arrives when you clone a repository or install a plugin, and PreToolUse hooks run before any permission-mode check. Read hook config in unfamiliar repos the way you'd read a postinstall script.

Anthropic's five practices are worth following literally: validate and sanitise inputs rather than trusting the payload; always quote shell variables ("$VAR", not $VAR); reject .. in file paths; use absolute paths for scripts (${CLAUDE_PROJECT_DIR} needs no quoting in exec form, but does in shell form); and skip sensitive files — .env, .git/, key material. Anthropic ships a reference implementation of a Bash-command validator in the claude-code repo if you want a starting point.

Two structural details sharpen the picture. Since v2.1.139, hooks run in their own session with no controlling terminal on macOS and Linux — they cannot open /dev/tty, which is precisely why systemMessage and terminalSequence exist as output channels. And v2.1.218 fixed agent-frontmatter hooks running from untrusted folders; frontmatter hooks now require the agent file's own folder to have accepted workspace trust.

Debugging: why your hook did nothing

Hook success is silent, so "no output" tells you nothing about whether it ran. Work through these in order:

The silent failures that account for most reports, from the vendor's own troubleshooting page: a shell profile that echos unconditionally prepends text to your JSON and breaks parsing (guard it with if [[ $- == *i* ]]; then …; fi); the script isn't executable; jq isn't installed; a bare command name isn't on PATH (use an absolute path, or add "args": [] to switch to exec form and skip the shell); a matcher that doesn't match case-exactly; invalid JSON in settings.json — trailing commas and comments are not allowed — which makes /hooks show nothing at all.

Then the timeouts, which discard output silently. command, http and mcp_tool hooks default to 600s, but UserPromptSubmit is capped at 30s and MessageDisplay at 10s; prompt hooks get 30s, agent hooks 60s, and all SessionEnd hooks share a 1.5s budget. A timed-out UserPromptSubmit hook is cancelled and its additionalContext thrown away — the prompt still reaches Claude, just without it (v2.1.196+ at least shows a transcript notice). Two more: hooks run in parallel, so if two PreToolUse hooks both return updatedInput the last to finish wins — never let two hooks rewrite the same tool's input. And async: true makes decision, permissionDecision and continue no-ops entirely.

One macOS-specific trap worth its own line: osascript routes through Script Editor, and if Script Editor lacks notification permission the command fails silently and macOS never prompts you. Run osascript -e 'display notification "test"' once, then enable Script Editor under System Settings → Notifications.

How hooks power gating and notifications

Put the two halves together and you have the whole "stop babysitting" pattern, built from primitives the agent already ships. Approval gating is PreToolUse returning a verdict — allow the safe stuff automatically, deny the dangerous stuff outright, and escalate the gray-area stuff to a human. Awareness is Notification and Stop shelling out to whatever can get your attention.

On macOS that's often terminal-notifier; on Linux, notify-send. A bare-bones Stop hook is just:

terminal-notifier -title "Claude Code" -message "Agent finished" -sound default

For working configs rather than shapes, we keep five of them in Claude Code hooks: 5 copy-paste examples, and the free notification builder generates the settings.json block and the notify scripts from a form — no signup, nothing to install.

This is the right architecture, and it's underused. The catch is what it costs to maintain: you're writing and re-deploying shell glue per machine, parsing payloads by hand to say anything useful, and a bare toast still drops you back to square one — you see "something happened," then you still have to find the right terminal, focus it, and read the scrollback to learn what it wants. The hooks give you the signal; turning that signal into something you can act on without a context switch is the part that takes work. If you want the full ladder of notification options ranked honestly, we covered that in how to actually know when Claude Code needs you.

Frequently asked questions

What are Claude Code hooks?

A hook is a handler that Claude Code runs when a lifecycle event fires, with a JSON object describing the event delivered on stdin (or as an HTTP POST body). You register them in settings.json keyed by event name. There is no SDK: if you can write a script that reads stdin and sets an exit code, you can hook Claude Code. Some hooks are pure side effects — fire a notification, write a log — and some are decision points whose output changes what the agent does next.

Which exit code blocks a tool call in Claude Code?

Exit 2, not exit 1. Exit 0 means success and is the only case where stdout is parsed as JSON. Exit 2 is a blocking error: stdout is ignored and stderr is fed back to Claude as the reason. Any other code, including 1, is a non-blocking error — the transcript shows a hook error notice and execution continues. This trips people up constantly, because 1 is the conventional Unix failure code. The one exception is WorktreeCreate, where any non-zero exit aborts the worktree.

How do I check whether my Claude Code hook actually fired?

Three places, in order. Run /hooks for a read-only list of every configured hook and its source file. Press Ctrl+O for the transcript view, which shows one line per hook — but note that success is silent, so no line can mean "worked" or "never matched". For the full record, including which hooks matched, their exit codes and complete stdout and stderr, run claude --debug-file /tmp/claude.log and tail that file. You can also test a handler outside Claude entirely by piping a fake payload into it and echoing $?.

Can a hook allow something my permission rules deny?

No. Hooks can tighten permissions but never loosen them. A PreToolUse hook returning permissionDecision "deny" blocks a tool call even in bypassPermissions mode, but a hook returning "allow" cannot override a deny rule in settings. Files pulled into a prompt with an @ reference fire no PreToolUse hook at all, because no tool call happens — use a Read deny rule for those.

Where Backgrind fits

Backgrind consumes these same PreToolUse / Notification / Stop hooks for you, so you don't assemble the shell glue yourself. It wraps your real Claude Code CLI in an always-on-top overlay: when the agent needs a decision, asks a question, or finishes, the window flashes and the right tab gets an accent ring — and because the terminal is already floating over your editor, answering is just typing, no hunting for a buried window. See it in action in the demo, and if you haven't yet, install Claude Code first so the hooks have something to fire on.

Backgrind is not a model and not an agent — it's a desktop overlay for macOS and Windows that PTY-wraps the CLI you already run (Claude Code, Cursor, Codex, OpenCode) or a managed endpoint. It is an always-on-top window, so it stays above ordinary apps and borderless-fullscreen games — not over exclusive-fullscreen ones, which take over the display. In BYO-CLI mode your agent's content never touches our servers, and Live mode lets you answer a held permission prompt from a phone or browser instead of walking back to the desk.

Sources

Event list, payload schemas, matcher rules, exit-code table and security guidance: Claude Code — hooks reference (the old docs.anthropic.com/en/docs/claude-code/hooks URL now redirects here). Troubleshooting, timeouts and debug techniques: hooks guide. Settings scopes and managed policy paths: settings. Version-gated behaviours (2.1.139, 2.1.141, 2.1.145, 2.1.191, 2.1.195, 2.1.196, 2.1.198, 2.1.199, 2.1.214, 2.1.218, 2.1.219): CHANGELOG.md. Current release numbers: @anthropic-ai/claude-code on npm (latest 2.1.220, stable 2.1.212, checked July 30, 2026). Reference validator implementation: bash_command_validator_example.py. Community write-ups consulted for framing, not for figures: Morph, ClaudeFast, PromptLayer.