Hooks are shell scripts (or other handlers) that Claude Code runs automatically when specific events happen during a session. They are how you add deterministic behaviour to a probabilistic tool — logging every Bash command, validating file edits before they land, saving checkpoints before context compacts, blocking specific operations regardless of permissions.
This article is the complete reference: every event Claude Code fires, what data the hook receives, what the hook can do in response, and how to debug hooks that are not behaving.
If you have not used hooks before, see Claude Code Hooks for the conceptual introduction. This article assumes you know what hooks are and want to know exactly how each event works.
Hook configuration shape
Hooks are defined in .claude/settings.json under the hooks key. Each event maps to an array of hook entries. Each entry has an optional matcher (filters which tool calls or actions trigger the hook) and one or more hook definitions.
{
"hooks": {
"EventName": [
{
"matcher": "ToolName",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/my-script.sh",
"timeout": 30
}
]
}
]
}
}
Hook entries support these fields:
- type — usually
command(run a shell command). Other types:http(POST to a URL),mcp_tool(invoke an MCP tool),prompt(send Claude a message),agent(delegate to a sub-agent). - command — the script path or URL or tool reference (depends on type)
- timeout — seconds before the hook is cancelled (default 600 for commands, 30 for prompts, 60 for agents)
- statusMessage — text shown in the status line while the hook runs
- once — boolean; if true, this hook entry only fires once per session
- async — boolean; if true, the hook runs in the background and does not block the event
- shell — which shell to use for command hooks (default
bash)
Event reference
SessionStart
Fires: at the start of every session, including resumed sessions.
Payload (stdin):
{
"hook_event_name": "SessionStart",
"cwd": "/Users/you/your-workspace",
"permission_mode": "acceptEdits",
"session_id": "sess_abc123",
"transcript_path": "/Users/you/.claude/projects/.../sess_abc123.jsonl"
}
Can block: No.
Return contract: JSON on stdout, e.g. { "continue": true, "systemMessage": "Welcome back to project Foo." }. The systemMessage is injected into Claude's context.
Common uses: announce session in chat or external system, validate workspace state (e.g. clean git tree), inject project-specific context that doesn't fit in CLAUDE.md.
SessionEnd
Fires: when the session ends (user exits, /exit, terminal closed gracefully).
Payload: session id, transcript path, summary of work done.
Can block: No.
Common uses: commit work-in-progress, sync state to a backup, log session metadata to a tracker, send a summary notification.
UserPromptSubmit
Fires: when the user submits a message, before Claude processes it.
Payload: the user's message text plus session metadata.
Can block: Yes — return { "decision": "block", "reason": "..." } to prevent the prompt from reaching Claude.
Common uses: redact secrets from prompts before they go to Claude (block credentials accidentally pasted in), enforce a prompt format, log every user message to an audit trail, route specific keywords to specific skills.
PreToolUse
Fires: before any tool call executes — Read, Write, Edit, Bash, WebFetch, MCP tools, everything.
Payload:
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "git status",
"description": "Check git status"
},
"session_id": "sess_abc123"
}
Can block: Yes. This is the most powerful hook event — you can deny tool calls, force them through ask, or just observe.
Return contract:
{
"continue": true,
"hookSpecificOutput": {
"permissionDecision": "allow|deny|ask",
"permissionDecisionReason": "explanation shown to user / Claude"
}
}
Or exit code 2 with stderr to block hard with the stderr text as the reason.
Common uses: block rm -rf patterns regardless of permission mode; require approval for any command touching production paths; log every Bash command for audit; auto-allow read-only git commands so they don't prompt.
PostToolUse
Fires: after a tool call completes successfully.
Payload: the original input plus the tool's result.
Can block: No — the tool already ran. The hook can influence what happens next but not undo the action.
Common uses: run a linter or test after every Edit; commit changes after specific edits; notify external systems; record metrics about tool usage.
PostToolUseFailure
Fires: after a tool call fails or errors.
Payload: the original input plus the error.
Common uses: log failures to a tracker; auto-retry with different parameters; notify when specific failure patterns occur.
PermissionRequest
Fires: when a permission prompt is about to be shown to the user.
Can block: Yes — return a decision and the prompt is skipped.
Common uses: auto-approve based on context (e.g. allow any Edit during business hours); auto-deny when locked down; route the decision to a different system (Slack approval, ticket).
Stop
Fires: when Claude finishes responding to a user message.
Can block: Yes — return { "decision": "block", "reason": "..." } to force Claude to continue working rather than stopping.
Common uses: ensure Claude doesn't stop until specific conditions are met (e.g. all tests passing); require a self-review before ending; enforce a "did you save?" check.
SubagentStart and SubagentStop
Fires: when a sub-agent (spawned via the Task tool) starts and stops.
Payload: agent type, task description, parent session id.
Common uses: log sub-agent activity for cost tracking; show progress in a dashboard; enforce constraints on which agent types can be spawned.
PreCompact and PostCompact
Fires: immediately before and after the context window is compacted (summarised because it's filling up).
Can block: No — compaction is necessary; the hook fires for awareness, not to prevent.
Common uses: save a checkpoint file before detail is lost (the most common pattern); log when compaction happened so you know detail in the conversation may have been summarised; trigger a warning to the user.
This is the most important hook for long sessions. Without it, when the conversation summarises, you have no marker for where detail was lost. With a PreCompact checkpoint, you can write the current state to disk before the summary happens.
UserPromptExpansion
Fires: when slash commands or @-mentions in the user's prompt are expanded.
Common uses: log which commands are used most often; enforce additional logic when specific commands fire.
FileChanged
Fires: when a file in the workspace is modified (by Claude or by you in your editor).
Common uses: auto-format on save; auto-commit specific paths; trigger linters or tests on relevant changes.
Notification
Fires: when Claude Code wants to notify the user (e.g. needs attention, completed long task).
Common uses: route notifications to external systems (push, Slack, email) instead of just the terminal.
Matchers
The matcher field on a hook entry filters which tool calls or actions trigger the hook. Only PreToolUse and PostToolUse use matchers extensively, but the syntax applies wherever matchers appear:
"Bash"— matches all Bash tool calls"Bash(*)"— same as above (wildcard)"Bash(npm test:*)"— matches Bash calls where the command starts withnpm test"Edit|Write"— matches either Edit or Write tool calls (regex alternation)"^Notebook"— regex; matches any tool name starting with Notebook"mcp__.*"— regex; matches any MCP tool- Omit matcher entirely — matches all events of that type
Letters, digits, underscore, and pipe are treated literally; other characters are interpreted as regex. Test ambiguous patterns with simple example tool calls before relying on them.
Return contracts and exit codes
For command hooks, Claude Code reads three things back:
- Exit code 0: success. Claude parses stdout for any decision JSON. If stdout has structured output, that decision is applied; if not, the event continues normally.
- Exit code 2 + stderr: blocking error. The hook is asserting "do not let this happen". The stderr text is shown as the reason. The original action is blocked.
- Other non-zero exit code: non-blocking error. Logged but does not block the action.
JSON output for decision-making (PreToolUse example):
#!/bin/bash
read -r INPUT
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q "rm -rf"; then
echo '{"continue":false,"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"rm -rf is blocked workspace-wide"}}'
exit 0
fi
# Otherwise allow without comment
exit 0
Timeout behaviour
Default timeouts:
- Command hooks: 600 seconds (10 minutes)
- Prompt hooks: 30 seconds
- Agent hooks: 60 seconds
Override per hook by setting the timeout field. When a hook hangs past its timeout, Claude Code cancels it and continues. The cancellation is logged as a non-blocking error — the original action proceeds. If you need a hook to definitely complete before the action proceeds, set a generous timeout and make sure the script handles its own error cases.
Async hooks
Set "async": true on a hook entry to run it in the background without blocking the event. Useful for hooks that perform side-effects (logging, notifications) where you don't want the event to wait for them.
Async hooks cannot block the action — their return value is ignored. Use sync hooks (the default) when the hook's decision matters for what happens next.
Disabling hooks
Three levels:
- Set
"disableAllHooks": truein settings.json to turn everything off (debug or trust scenarios) - Comment out a specific event entry in settings.json to disable just that event
- Set the
SKIP_HOOKSenvironment variable for a single session
Debugging hooks that don't fire
Common causes when a hook is configured but doesn't seem to be running:
- Settings.json syntax error. A missing comma elsewhere in the file silently invalidates everything. Validate with
jq . .claude/settings.jsonor jsonlint.com. - Wrong event name. Event names are case-sensitive:
PreToolUsenotpretooluse. Typos here fail silently. - Matcher too narrow. If the matcher doesn't match the actual tool call, the hook is skipped without warning. Test with no matcher first to confirm the hook fires at all, then narrow.
- Script not executable. The script needs
chmod +x. If it isn't, the hook fails silently. Check withls -l .claude/hooks/. - Wrong shebang. The script needs a shebang line (
#!/bin/bashor similar) and must work on its own when run directly. - Path resolution. Hook commands resolve relative to the workspace root, not the user's home directory. Use absolute paths or paths relative to the workspace.
To debug, add a echo at the top of the hook script that writes to a known file (echo "fired" >> /tmp/hook-debug.log). If the file gets written, the hook is firing — the issue is in the script logic. If it doesn't, the hook is not being triggered — work through the list above.
Examples worth stealing
Block rm -rf and force push regardless of mode
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/block-dangerous-bash.sh" }
]
}
]
}
}
.claude/hooks/block-dangerous-bash.sh:
#!/bin/bash
read -r INPUT
CMD=$(echo "$INPUT" | jq -r '.tool_input.command')
if echo "$CMD" | grep -qE "rm -rf|git push --force|git push -f"; then
echo "{\"continue\":false,\"hookSpecificOutput\":{\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Blocked by workspace policy\"}}"
exit 0
fi
exit 0
Save a checkpoint before context compacts
{
"hooks": {
"PreCompact": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/precompact-checkpoint.sh" }
]
}
]
}
}
.claude/hooks/precompact-checkpoint.sh:
#!/bin/bash
TS=$(date +%Y-%m-%d-%H%M)
CHECKPOINT_DIR="$HOME/Documents/checkpoints"
mkdir -p "$CHECKPOINT_DIR"
echo "Compaction at $TS" >> "$CHECKPOINT_DIR/$TS-compaction.md"
echo '{"continue":true}'
Log every Bash command to an audit file
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' >> ~/.claude/audit/bash.log",
"async": true
}
]
}
]
}
}
This is async because logging shouldn't block the action; it just needs to record what happened.
What hooks should not do
Hooks are deterministic infrastructure. They should be small, fast, and reliable. Things they should not do:
- Long-running computation. If a hook takes more than a few seconds, the session feels broken. Push expensive work to async hooks or external workers.
- Network calls in sync hooks. A flaky network turns into mysterious session failures. Use async or handle timeouts explicitly.
- Decisions that should be in CLAUDE.md. Hooks enforce; they should not be the primary instruction mechanism. Tell Claude what you want in CLAUDE.md and use hooks only for enforcement that needs to be guaranteed.
- Side-effects you can't undo. A hook that, say, posts to Slack on every PreToolUse will flood Slack the first time you have a long session. Be conservative about what hooks emit externally.
