Claude Code Hooks: A Complete Reference
A Claude Code hook is a shell command, HTTP endpoint, or MCP tool call that Claude Code runs automatically at a fixed point in a session, such as before a tool executes or when a session starts. Hooks are configured in settings.json under a hooks key, keyed by event name, with a matcher to scope which tool or reason triggers them and a hooks array naming the command to run. They can observe what is happening, block an action outright, or rewrite the input before Claude ever sees the result. That last capability is the one worth building around: a hook is the only point in the loop where you can change what enters the context window before the model pays to read it. Everything else, compaction, summarization, /clear, only cleans up tokens after they were already spent.
Where hooks are configured
Hooks live in the same settings.json files Claude Code already reads for permissions and environment variables. Four locations can define hooks, and they merge rather than override each other:
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json | All projects on this machine | No, local only |
.claude/settings.json | This project | Yes, commit it |
.claude/settings.local.json | This project | No, gitignored |
| Managed policy settings | Organization-wide | Yes, admin-controlled |
Plugins and skills can also ship their own hooks (a plugin’s hooks/hooks.json, or hooks declared in a skill’s or subagent’s frontmatter), scoped to when that plugin is enabled or that skill or subagent is running. User, project, and local hooks all add to the set rather than replacing it, and disableAllHooks set outside managed settings cannot turn off a managed hook. A minimal registration looks like this:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/filter-test-output.sh",
"timeout": 10
}
]
}
]
}
}
matcher scopes the hook (a tool name, a regex, or an event-specific value such as a SessionStart reason); hooks is an array so more than one command can run off the same matcher. The most common type is command, a shell script that reads JSON on stdin and writes JSON (or plain text) to stdout.
The event list
Every hook event documented in the official hooks reference, in the order the lifecycle runs, what fires it, and whether it can block or rewrite the turn versus only observe it:
| Event | Fires when | Can it block or rewrite |
|---|---|---|
SessionStart | Session begins or resumes | Observe, inject context |
Setup | Run with --init-only, --init, or --maintenance | Observe |
UserPromptSubmit | You submit a prompt, before Claude sees it | Block, rewrite input, inject context |
UserPromptExpansion | A slash command expands into a prompt | Block |
PreToolUse | Before a tool call executes | Block, allow, deny, ask, rewrite input, inject context |
PermissionRequest | A tool call needs a permission decision | Allow, deny, ask (no exit-code block) |
PermissionDenied | Auto mode denies a tool call | Observe, can flag a retry |
PostToolUse | After a tool call succeeds | Observe, inject context |
PostToolUseFailure | After a tool call fails | Observe, inject context |
PostToolBatch | After a batch of parallel tool calls resolves | Block |
Notification | Claude Code sends a notification | Observe |
MessageDisplay | While assistant text is being displayed | Observe (display-only) |
SubagentStart | A subagent is spawned | Observe |
SubagentStop | A subagent finishes | Block, inject context |
TaskCreated | A task is created via TaskCreate | Block |
TaskCompleted | A task is marked complete | Block |
Stop | Claude finishes responding | Block |
StopFailure | The turn ends due to an API error | Observe |
TeammateIdle | An agent-team teammate is about to go idle | Block |
InstructionsLoaded | A CLAUDE.md or rules file loads into context | Observe |
ConfigChange | A configuration file changes mid-session | Block |
CwdChanged | The working directory changes (a cd) | Observe |
DirectoryAdded | A directory is added mid-session via /add-dir | Observe |
FileChanged | A watched file changes on disk | Observe |
WorktreeCreate | A worktree is being created | Block (any nonzero exit) |
WorktreeRemove | A worktree is removed | Observe |
PreCompact | Before context compaction | Observe |
PostCompact | After compaction completes | Observe |
PreModelSwitch | Before a requested model switch applies | Block, allow |
PostModelSwitch | After the session’s model changes | Observe |
Elicitation | An MCP server requests user input mid-tool-call | Observe |
ElicitationResult | After you respond to an MCP elicitation | Observe |
SessionEnd | A session terminates | Observe |
That’s every hook event in the current reference, not marked experimental or version-gated in the docs as of this writing, though newer entries like TeammateIdle (agent teams) and Setup (init/maintenance runs) only make sense once you’re using the feature they hook into.
In practice, a handful of these carry almost all of the weight. PreToolUse is the one worth building a habit around, since it’s the only place you can rewrite a tool call before it runs. SessionStart and UserPromptSubmit are the standard places to inject context without the model having to go read files for it. Stop and SubagentStop are where you enforce “don’t stop until X is true.” Everything else on this list is either a narrow observability hook (Notification, PreCompact, FileChanged) or scoped to a feature you may not be using at all (agent teams, worktrees, MCP elicitation). Wire those four or five first and treat the rest as reference.
Every command hook receives a JSON object on stdin. All events share a base shape (session_id, transcript_path, cwd, permission_mode, hook_event_name), and tool events add tool_name and tool_input. A PreToolUse hook watching Bash sees something like:
{
"session_id": "abc123",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run test suite"
},
"tool_use_id": "toolu_01ABC123..."
}
A hook that wants to act writes JSON back to stdout, wrapped in hookSpecificOutput. The fields that matter most: permissionDecision (allow, deny, or ask) to block or pass a tool call, updatedInput to replace the tool’s arguments before it runs, and additionalContext to inject text for Claude to read without touching the tool call at all. Exit code 2 always blocks the action, even if the JSON said allow; exit code 0 means read the JSON (or plain stdout) as the decision; any other exit code is a non-blocking error and the action proceeds.
Why this is a context-economics tool, not just automation glue
Most writeups treat hooks as an automation feature: format on save, block a dangerous command, ping Slack when a session ends. That undersells the mechanism. Every tool result, every file read, every command’s stdout gets appended to the conversation and resent on every later turn for the rest of the session. A hook that intercepts a result before it lands in context removes those tokens from every subsequent request, not just the one that produced them. Nothing downstream (/compact, choosing a cheaper model, trimming CLAUDE.md) recovers tokens that were never spent.
The canonical example, from Anthropic’s own cost-management guide, is a PreToolUse hook matched on Bash that intercepts a test run and rewrites it to grep for failures before the output reaches Claude:
“reducing context from tens of thousands of tokens to hundreds”
The mechanism: the hook script reads tool_input.command from stdin, and if it looks like a test invocation, returns an updatedInput that appends a filter (grep for FAIL, or pipe through a summarizer) instead of letting the full, unfiltered log flow into the transcript. A passing test suite that would have dumped tens of thousands of tokens of green checkmarks becomes a few lines. That’s the whole pattern: filter, summarize, or truncate before the result reaches the model, not after. See our guide on reducing Claude Code token usage for the rest of the official cost-reduction list this fits into.
Working examples
A PreToolUse filter hook, matching the official pattern above. Registered on the Bash matcher, it only touches commands that look like test runs:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/filter-test-output.sh",
"timeout": 10
}
]
}
]
}
}
The script itself just needs to detect a test command, rewrite it to pipe through grep, and emit updatedInput as JSON on stdout; if the command isn’t a test run it exits zero without writing anything and the original command proceeds untouched.
A SessionStart hook that injects a digest, so a new session doesn’t have to rediscover project state by reading files. The shiploop harness registers one on the * matcher that parses the most recent entries out of a running learnings.md and emits them as additionalContext, skipping the injection entirely if the file is empty:
{
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "scripts/learnings-digest.sh",
"timeout": 10
}
]
}
]
}
}
A Stop hook, firing when Claude finishes responding. A real one, scripts/ticket-sweep-reminder.sh, only fires once per session and only if the session actually touched code (checked against a baseline the SessionStart hook snapshotted), nudging the session to reconcile its ticket queue, “ticket” being shiploop’s own term for a named unit of dispatched work, the ## #N entries in queue/tickets.md, before ending:
{
"hooks": {
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "scripts/ticket-sweep-reminder.sh",
"timeout": 15
}
]
}
]
}
}
Gotchas that cost real time
A hook script that runs under set -euo pipefail and ends on a bare conditional aborts the caller. If the last line of a bash hook is [[ "$cond" ]] && do_thing, and the condition is false, that line’s exit status becomes the function’s exit status, and set -e kills the whole script right there, silently, with no output. End functions with an explicit return 0 if the last statement is a conditional. The same shell also trips on local a=x b="$a": b is a separate local declaration and reads $a before a is guaranteed set in that scope, so split dependent locals into their own statements.
A hook that writes to stdout is a standing per-turn cost, not a one-time automation. additionalContext output gets read by Claude on every fire, so a chatty SessionStart or UserPromptSubmit hook that dumps a paragraph every session is tokens spent on every session whether or not anyone reads them. Keep the injected text to what’s actually decision-relevant, and make hooks silent by default, only speaking up when there’s something to say.
Verify a hook is actually registered instead of assuming it fired. Type /hooks in a running session to open a read-only browser of every configured hook: event, matcher, handler, and which settings file it came from. If a hook silently didn’t run, check that file list before debugging the script itself, a hook defined in the wrong settings file or shadowed by a typo’d matcher never gets a chance to fail loudly.
What shiploop registers
shiploop is a multi-agent Claude Code harness; these are the hooks it wires into settings.json on scaffold, listed here because they’re a concrete example of the patterns above, not because you need shiploop to use hooks:
SessionStart(matcher*), four hooks in order: a snapshot of workspace code-work state keyed by session, a digest of the most recentlearnings.mdentries, a non-blocking warning if a checkout has drifted off its default branch, and an adopter for any pending validation job.UserPromptSubmit(matcher*): fires once per session to prime a “delegate heavy work, keep the driver’s own context thin” posture viaadditionalContext.PreToolUse(matcherRead|Bash): a capped, low-noise warning the moment the driver session (not a subagent) does a large inline read or a verbose command, never blocking.Stop(matcher*): the ticket-sweep nudge described above.SessionEnd(matcher*): tears down worktree-related processes and deploys the session started.
Every hook but the Stop one suppresses its own stderr; the Stop hook is left unsuppressed deliberately since it’s the one hook meant to be seen.
Where to go next
For the rest of the official token-reduction techniques these hooks are one part of, see reducing Claude Code token usage. For how permission modes interact with what a PreToolUse hook is allowed to block, see Claude Code permissions. To surface hook or governor activity in your prompt rather than a hook’s own output, see the Claude Code statusline guide. And for how hooks behave differently when the caller is a spawned subagent rather than the main session, see Claude Code subagents.
Last updated 2026-09-04.