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:

LocationScopeShareable
~/.claude/settings.jsonAll projects on this machineNo, local only
.claude/settings.jsonThis projectYes, commit it
.claude/settings.local.jsonThis projectNo, gitignored
Managed policy settingsOrganization-wideYes, 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:

EventFires whenCan it block or rewrite
SessionStartSession begins or resumesObserve, inject context
SetupRun with --init-only, --init, or --maintenanceObserve
UserPromptSubmitYou submit a prompt, before Claude sees itBlock, rewrite input, inject context
UserPromptExpansionA slash command expands into a promptBlock
PreToolUseBefore a tool call executesBlock, allow, deny, ask, rewrite input, inject context
PermissionRequestA tool call needs a permission decisionAllow, deny, ask (no exit-code block)
PermissionDeniedAuto mode denies a tool callObserve, can flag a retry
PostToolUseAfter a tool call succeedsObserve, inject context
PostToolUseFailureAfter a tool call failsObserve, inject context
PostToolBatchAfter a batch of parallel tool calls resolvesBlock
NotificationClaude Code sends a notificationObserve
MessageDisplayWhile assistant text is being displayedObserve (display-only)
SubagentStartA subagent is spawnedObserve
SubagentStopA subagent finishesBlock, inject context
TaskCreatedA task is created via TaskCreateBlock
TaskCompletedA task is marked completeBlock
StopClaude finishes respondingBlock
StopFailureThe turn ends due to an API errorObserve
TeammateIdleAn agent-team teammate is about to go idleBlock
InstructionsLoadedA CLAUDE.md or rules file loads into contextObserve
ConfigChangeA configuration file changes mid-sessionBlock
CwdChangedThe working directory changes (a cd)Observe
DirectoryAddedA directory is added mid-session via /add-dirObserve
FileChangedA watched file changes on diskObserve
WorktreeCreateA worktree is being createdBlock (any nonzero exit)
WorktreeRemoveA worktree is removedObserve
PreCompactBefore context compactionObserve
PostCompactAfter compaction completesObserve
PreModelSwitchBefore a requested model switch appliesBlock, allow
PostModelSwitchAfter the session’s model changesObserve
ElicitationAn MCP server requests user input mid-tool-callObserve
ElicitationResultAfter you respond to an MCP elicitationObserve
SessionEndA session terminatesObserve

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 recent learnings.md entries, 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 via additionalContext.
  • PreToolUse (matcher Read|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.