Reference verified against Claude Code v2.1.205
— July 8, 2026. Hooks change between releases; if something below disagrees with your
install, trust claude --version and
the official reference,
then tell us.
Claude Code hooks, explained
A hook is a standing order: a shell command Claude Code runs automatically, every time,
at a fixed point in its lifecycle. Before a tool call. After a file edit. When a session
starts. When Claude thinks it's finished. You configure hooks once under the
"hooks" key in a settings file, and from then on the
behavior is deterministic — unlike an instruction in CLAUDE.md, which Claude should
follow, a hook always runs.
That determinism is the whole point. "Please run prettier after editing" works until it
doesn't; a PostToolUse hook on Edit|Write works every time,
forever, without spending a single token reminding anyone. Hooks are how you turn a habit
into infrastructure.
One warning before anything else: hooks run with your full user permissions, without asking. A hook is code you have agreed to execute unattended. Read every command before you register it — especially ones you copied from the internet, including this page.
Quick start
The smallest useful hook — log every shell command Claude runs, so you always have an
audit trail. In ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' >> ~/.claude/bash-log.txt"
}
]
}
]
}
}
Reading it inside-out: when the PreToolUse event fires and the tool
name matches Bash, run this command. The hook receives
the event as JSON on stdin — here jq plucks out the command text and appends
it to a log. That's the entire model. Everything else on this page is detail on the three
questions every hook answers: when (the event), on what (the matcher),
and do what (the hook entry).
Every hook event
Claude Code fires hooks at thirty points in its lifecycle. You will use five of them constantly and the rest rarely — but "rarely" is exactly when you'll want this table. The Blocks? column says whether the hook can veto the action by exiting with code 2 (details under exit codes).
The core five
| Event | Fires | Blocks? |
|---|---|---|
PreToolUse | Before any tool call executes. The gatekeeper event: inspect the call, allow it, rewrite it, or block it. | Yes |
PostToolUse | After a tool call succeeds. The janitor event: format the file that was just written, run the linter, regenerate types. | No — it already ran |
UserPromptSubmit | When you submit a prompt, before Claude sees it. Inject context, validate, or block the prompt entirely. | Yes |
Stop | When Claude finishes responding. Exit 2 sends Claude back to work with your stderr as instructions — the "not done yet" event. | Yes |
SessionStart | When a session begins or resumes. stdout becomes context Claude starts with — git status, open tickets, yesterday's notes. | No |
Tool and permission events
| Event | Fires | Blocks? |
|---|---|---|
PermissionRequest | When a permission dialog is about to appear. Auto-answer it from a hook instead of clicking. | Yes |
PermissionDenied | After a tool call is denied by the permission system. | Yes |
PostToolUseFailure | After a tool call fails — the error-path twin of PostToolUse. | No |
PostToolBatch | After a full batch of parallel tool calls resolves, before the next model call. | No |
Session lifecycle
| Event | Fires | Blocks? |
|---|---|---|
SessionEnd | When a session terminates. Matcher tells you why: clear, logout, other… | No |
Setup | On --init/--init-only/--maintenance runs — repo bootstrap hooks. | No |
PreCompact / PostCompact | Around context compaction. Matcher: manual or auto. | No |
Notification | When Claude Code sends a notification (permission prompt, idle, agent done…). The "ping my phone" event. | No |
StopFailure | When a turn ends due to an API error. Matcher is the error type (rate_limit, server_error…). | No |
MessageDisplay | When assistant message text is displayed. | No |
UserPromptExpansion | When a typed command expands into a prompt, before Claude sees it. Matcher is the command name. | Yes |
Agents, tasks, and teammates
| Event | Fires | Blocks? |
|---|---|---|
SubagentStart | When a subagent spawns. Matcher is the agent type. | No |
SubagentStop | When a subagent finishes — exit 2 keeps it working, same as Stop. | Yes |
TaskCreated / TaskCompleted | When a task is created / marked complete. | No |
TeammateIdle | When an agent-team teammate is about to go idle. | No |
Environment and config
| Event | Fires | Blocks? |
|---|---|---|
ConfigChange | When a settings file changes mid-session. Exit 2 rejects the change — a tamper alarm for your config. | Yes |
FileChanged | When a watched file changes on disk. Matcher is literal filenames (.env|.envrc), not regex. | No |
CwdChanged | When the working directory changes. | No |
InstructionsLoaded | When CLAUDE.md or rules files load into context. | No |
WorktreeCreate / WorktreeRemove | Around git-worktree isolation. Create can be failed by a non-zero exit. | Create: yes |
Elicitation / ElicitationResult | Around an MCP server requesting user input. Matcher is the server name. | No |
The configuration shape
Every hook config has the same three levels of nesting, and most first-attempt failures are a missing level. Memorize the skeleton:
"hooks": { // 1. the event map
"PreToolUse": [ // 2. a list of matcher groups per event
{
"matcher": "Bash", // which occurrences this group cares about
"hooks": [ // 3. the hooks that run when it matches
{ "type": "command", "command": "./check.sh", "timeout": 30 }
]
}
]
}
Notes that save debugging time:
matcheris optional. Omit it (or use"*"or"") and the group fires on every occurrence of the event. Events that don't support matchers (Stop,UserPromptSubmit,CwdChanged…) simply ignore it.- One event can have many matcher groups, and one group can have many hooks. All matching hooks run; identical commands are deduplicated.
timeoutis per-hook, in seconds. Default 600 for commands (30 forUserPromptSubmit, 10 forMessageDisplay)."type": "command"is what this builder emits and what you almost always want. Four other types exist:prompt(a one-shot LLM yes/no check),agent(a subagent verification loop),http(POST the event to a URL), andmcp_tool(call a tool on a connected MCP server). Reach for them when a shell one-liner can't express the judgment call.- A hook entry can also carry an
iffield using permission-rule syntax —"if": "Bash(git *)"runs the hook only for git commands, cheaper than parsing stdin yourself. It's evaluated on tool events only (PreToolUse,PostToolUse,PostToolUseFailure,PermissionRequest,PermissionDenied).
Matchers
The matcher string is interpreted in one of three ways:
- Match everything:
"*","", or omitted. - Exact name(s): plain names, separated by
|or commas —Bash,Edit|Write,Edit,Write. - Regex: anything with regex characters is evaluated as an unanchored
JavaScript regex —
mcp__.*matches every MCP tool,mcp__github__.*everything from one server.
What the matcher compares against depends on the event:
| Event | Matcher compares against |
|---|---|
PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied |
Tool name — Bash, Edit, Write, Read, Glob, Grep, WebFetch, Agent, or MCP tools as mcp__<server>__<tool> |
SessionStart | startup · resume · clear · compact |
SessionEnd | clear · resume · logout · prompt_input_exit · bypass_permissions_disabled · other |
Notification | permission_prompt · idle_prompt · auth_success · elicitation_dialog · elicitation_complete · elicitation_response · agent_needs_input · agent_completed |
PreCompact, PostCompact | manual · auto |
SubagentStart, SubagentStop | Agent type — general-purpose, Explore, or your custom agent names |
ConfigChange | user_settings · project_settings · local_settings · policy_settings · skills |
StopFailure | Error type: rate_limit · overloaded · server_error · billing_error · max_output_tokens · and friends |
FileChanged | Literal filenames separated by | — not regex |
Setup | init · maintenance |
InstructionsLoaded | session_start · nested_traversal · path_glob_match · include · compact |
Elicitation, ElicitationResult | MCP server name |
UserPromptExpansion | Command / skill name |
Stop, UserPromptSubmit, PostToolBatch, CwdChanged, TaskCreated, TaskCompleted, TeammateIdle, MessageDisplay, WorktreeCreate, WorktreeRemove | No matcher — always fires |
What your hook receives
Every hook gets one JSON object on stdin. The common fields:
{
"session_id": "…",
"prompt_id": "…",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/directory",
"permission_mode": "default",
"hook_event_name": "PreToolUse"
}
Each event adds its own fields — the ones you'll actually parse:
| Event | Extra fields |
|---|---|
PreToolUse | tool_name, tool_input (the tool's arguments — e.g. .tool_input.command for Bash, .tool_input.file_path for Edit/Write) |
PostToolUse | tool_name, tool_input, tool_output |
PostToolUseFailure | tool_name, tool_input, error |
UserPromptSubmit | prompt_text |
Stop | last_assistant_message, stop_hook_active (true once your Stop hook has blocked repeatedly — check it to avoid infinite loops) |
SessionStart | source, model |
FileChanged | file_path, change_type |
SubagentStart / SubagentStop | agent_type (+ stop_reason on stop) |
jq is the standard way in: jq -r '.tool_input.file_path' and
you're off. For anything longer than a one-liner, write a script that reads stdin and point
the hook at the script.
What your hook says back: exit codes and JSON
Hooks talk back through their exit code, and optionally through JSON on stdout.
- Exit 0 — success. stdout is parsed for JSON output (below); for
SessionStartandUserPromptSubmit, plain stdout becomes context Claude sees. - Exit 2 — block, where the event supports it. stderr becomes
the explanation: for
PreToolUsethe tool call is cancelled and Claude reads your stderr; forStopClaude isn't allowed to finish and your stderr becomes its next instruction; forUserPromptSubmitthe prompt is swallowed. On non-blocking events, exit 2 just shows stderr. - Anything else — non-blocking error; stderr appears in the transcript, execution continues.
For decisions richer than block/allow, print JSON on stdout with exit 0:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Writes to /etc are off-limits in this repo."
}
}
The fields worth knowing: permissionDecision
(allow / deny / ask / defer) pre-answers the
permission check on PreToolUse; updatedInput rewrites the tool's arguments before
it runs; additionalContext injects text into Claude's context;
continue: false with a stopReason halts processing outright;
and suppressOutput: true keeps your hook's stdout out of the transcript.
A top-level "decision": "block" with a "reason" is the JSON
equivalent of exit 2 on Stop and PostToolUse.
Where hooks live
| File | Scope | Use it for |
|---|---|---|
~/.claude/settings.json | You, everywhere | Personal habits: logging, notifications, formatters you always want |
.claude/settings.json | One project, committed | Team rules: lint gates, protected paths, project conventions |
.claude/settings.local.json | One project, gitignored | Your machine-specific overrides |
| Managed policy settings | Whole org | Admin-enforced guardrails |
Plugins can ship hooks in hooks/hooks.json, and skills and agents can carry
hooks in their frontmatter that apply only while they're active. All sources merge; to turn
everything off temporarily, set "disableAllHooks": true. To build the settings
file these fragments paste into — permissions, env, model, the works — use the
settings.json Builder.
Worked examples
Each of these is a complete "hooks" fragment — the same output the builder
produces. Load puts it in the builder above so you can tweak before copying.
Auto-format after every edit
The classic. Prettier runs on every file Claude edits or writes, the moment it happens.
Swap in black, gofmt, or rustfmt to taste.
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | xargs -r npx prettier --write --ignore-unknown"
}
]
}
]
}
Block dangerous rm commands
A PreToolUse guard on Bash: if the command smells like a recursive delete of
home or root, exit 2 — the call is blocked and Claude is told why. Deterministic, unlike
hoping the model declines.
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' | grep -qE 'rm .*-[a-z]*r[a-z]*f|rm .*-[a-z]*f[a-z]*r' && { echo 'Blocked: recursive force delete. Ask the human.' >&2; exit 2; } || exit 0"
}
]
}
]
}
Log every Bash command
An audit trail of everything Claude runs, one line per command, timestamped. Costs nothing, and the one day you need it you'll really need it.
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '\"[\\(now | todate)] \\(.tool_input.command)\"' >> ~/.claude/bash-log.txt"
}
]
}
]
}
Desktop notification when Claude needs you
You started a long task and switched windows. This macOS example pings you the moment
Claude is waiting on a permission prompt; on Linux, swap in notify-send.
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code is waiting on a permission prompt\" with title \"Claude Code\"'"
}
]
}
]
}
Play a sound when Claude finishes
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "afplay /System/Library/Sounds/Glass.aiff"
}
]
}
]
}
Inject git context at session start
On SessionStart, whatever the hook prints becomes context Claude begins
with — so every session opens already knowing your branch and dirty files.
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "echo \"Branch: $(git branch --show-current 2>/dev/null). Uncommitted: $(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') files.\""
}
]
}
]
}
FAQ
My hook isn't firing. What do I check first?
In order: (1) valid JSON — one trailing comma kills the whole file silently;
(2) the nesting — event → matcher group → hooks array, all three levels;
(3) the matcher — bash doesn't match Bash, matchers are
case-sensitive; (4) run /hooks inside Claude Code to see what's actually
registered. Settings edits are normally picked up live, but a restart forces a reload.
Hooks vs. CLAUDE.md instructions — when do I use which?
Instructions are requests; hooks are law. Use CLAUDE.md for judgment calls ("prefer small commits"), hooks for anything that must happen every time (formatting, logging, blocking a path). If you've written the same reminder twice, it wants to be a hook.
Can a Stop hook loop forever?
It can try — a Stop hook that always exits 2 sends Claude back to work indefinitely.
Claude Code caps consecutive blocks and sets stop_hook_active: true
in your hook's stdin once a block streak is running; well-behaved Stop hooks check it and
exit 0.
Do hooks work inside subagents?
Yes — tool events fire in subagents too, and the stdin JSON carries
agent_id and agent_type so you can tell. SubagentStart
and SubagentStop let you hook the subagent lifecycle itself, with the agent
type as the matcher.
Is any of this a security risk?
The hooks mechanism is a security feature — it's how you enforce guardrails. The commands are the risk: they run unattended with your permissions. Treat a hook entry like a cron job — read it, understand it, and be suspicious of anything you didn't write, including snippets from this page.