30 Hook Events Fire in a Claude Code Session. Only 3 Can Talk Back.

The complete list of Claude Code hook events, when each one fires, which 15 can block, and the stdout rule that silently swallows most hook output. A field reference built from running hooks in production across thousands of agent sessions.

Two failures show up again and again when people wire hooks into Claude Code, and they look nothing alike.

The first: you add a hook, nothing happens. No error, no warning, no log line. The hook simply never runs.

The second: the hook clearly runs, you can see its side effects on disk, but the message it prints for the agent never arrives. The agent behaves as if the hook had said nothing.

Both come from the same place. The hook system is bigger and less uniform than the handful of events most write-ups cover, and the rules about who can speak to the agent are not the ones you would guess. This is the reference we wish we had had. We build AgentsRoom on top of these hooks, and everything below is either quoted from the official reference or measured in production.

There are 30 events, not six

Most guides cover PreToolUse, PostToolUse, UserPromptSubmit, Stop, Notification and SubagentStop. Those six are real, and they carry most of the useful work. They are also a fifth of what exists.

The complete list, grouped by what they observe:

GroupEvents
SessionSessionStart, SessionEnd, Setup
PromptUserPromptSubmit, UserPromptExpansion
ToolsPreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch
PermissionsPermissionRequest, PermissionDenied
TurnStop, StopFailure
Subagents and tasksSubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle
ContextPreCompact, PostCompact, InstructionsLoaded
EnvironmentFileChanged, CwdChanged, ConfigChange
WorktreesWorktreeCreate, WorktreeRemove
InterfaceNotification, MessageDisplay
MCP elicitationElicitation, ElicitationResult

Timeline diagram of the 30 Claude Code hook events in the order they fire during an agent session, from SessionStart through UserPromptSubmit, PreToolUse, PostToolUse, Stop and SessionEnd, showing which events can block the agent.

The order events fire in a single session. The tool block repeats once per tool call, and the whole prompt block repeats once per turn.

A few of these change how you think about the system. PostToolUseFailure exists, so the "did the tool work" branch is an event, not something you infer from a payload. PostToolBatch fires once after a batch of parallel tool calls resolves, which is the right place to run a linter once instead of once per edit. InstructionsLoaded fires when CLAUDE.md is read, which gives you a hook point for checking that the agent actually loaded the rules you think it loaded.

The stdout rule that swallows most hook output

This is the single most useful thing on this page.

On exit code 0, Claude Code parses stdout for JSON output fields. But whether that stdout is ever shown to the agent depends on the event, and the exceptions are a short list. From the reference:

For most events, stdout is written to the debug log but not shown in the transcript. The exceptions are UserPromptSubmit, UserPromptExpansion, and SessionStart, where stdout is added as context that Claude can see and act on.

Three events out of thirty. If you echo "warning: this migration is destructive" from a PostToolUse hook and expect the agent to read it, it never will. Your text went to the debug log.

There are exactly two ways to put text in front of the agent from any other event:

  1. Exit 2 and write to stderr. On exit 2, Claude Code ignores stdout and any JSON in it, and feeds stderr back to the agent as an error message.
  2. Exit 0 and print a JSON object carrying hookSpecificOutput.additionalContext.

Note the asymmetry in the first one. Exit 0 means stdout matters and stderr does not. Exit 2 means stderr matters and stdout is discarded entirely. Getting this backwards is why a hook can look completely correct and still be mute.

Any other exit code is a non-blocking error. The transcript shows a <hook name> hook error notice with the first line of stderr, execution continues, and the full stderr lands in the debug log.

Diagram of Claude Code hook exit codes: exit 0 sends stdout JSON to the agent only on three events, exit 2 blocks the action and sends stderr to the agent, any other exit code is a non-blocking error written to the debug log.

Which channel reaches the agent, per exit code. The dashed path is the one people assume exists and does not.

Exactly half of them can block

Fifteen events stop the action on exit 2. Fifteen ignore it and carry on.

Can block: PreToolUse, PermissionRequest, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange, PostToolBatch, PreCompact, Elicitation, ElicitationResult, WorktreeCreate.

Cannot block: PostToolUse, PostToolUseFailure, PermissionDenied, StopFailure, Notification, SubagentStart, SessionStart, Setup, SessionEnd, CwdChanged, FileChanged, PostCompact, WorktreeRemove, InstructionsLoaded, MessageDisplay.

The practical consequence: a guardrail belongs on PreToolUse, never on PostToolUse. PostToolUse fires after the tool succeeded. Exiting 2 there does not undo the write, it just prints an error while the damage sits on disk. If you want to stop rm -rf you have exactly one place to do it.

PostToolBatch blocking while PostToolUse does not is worth a second look. It means a batch-level check can still stop the turn after parallel edits land, which is the closest thing to a post-write veto the system offers.

Matchers are exact, until suddenly they are not

The matcher field switches evaluation strategy based on its own characters, and nothing tells you which path it took.

MatcherEvaluated as
"*", "", or omittedmatches everything
Only letters, digits, _, -, spaces, ,, |exact string, or list of exact strings split on | or ,
Anything elseunanchored JavaScript regular expression

Unanchored is the trap. The reference is explicit that the regex is tested with RegExp.prototype.test, which succeeds on a match anywhere in the value. So Edit.* matches Edit and NotebookEdit. If you meant one tool, write ^Edit$.

Two version-dependent behaviours worth knowing before you debug the wrong thing:

  • Comma separators and whitespace tolerance need Claude Code v2.1.191 or later.
  • Hyphens joined the exact-match character set in v2.1.195. Before that, a matcher like code-reviewer was treated as an unanchored regex, so it also fired for senior-code-reviewer.

In AgentsRoom we scope our own file-attribution hook with Write|Edit|MultiEdit|NotebookEdit, which stays on the exact-string path and matches those four tools and nothing else. The lifecycle hooks we install carry no matcher at all, because they always concern us.

Six places can define hooks, and they merge

The instinct is to look for a precedence order. There isn't one, and that is the interesting part.

LocationScope
~/.claude/settings.jsonall your projects, local to your machine
.claude/settings.jsonone project, committable
.claude/settings.local.jsonone project, gitignored by Claude Code
Managed policy settingsorganization-wide, admin controlled
Plugin hooks/hooks.jsonwhile the plugin is enabled
Skill or agent frontmatterwhile the component is active

From the reference:

Hook entries merge across settings levels rather than replacing each other: user, project, and local settings add their own hooks without removing managed ones, and the disableAllHooks setting can't disable managed hooks from outside managed settings.

So a project hook never overrides a global one, it stacks on top of it. Six sources, all additive. A PostToolUse formatter defined in your user settings and again in the project runs twice per edit, and the only symptom is that things feel slow.

Diagram showing the six Claude Code settings locations that can define hooks, all merging additively into a single hook set rather than overriding each other.

Six sources, one merged set. Nothing here overrides anything.

This also explains why .claude/settings.local.json is the right place for a tool to install a hook into someone's project. It is project-scoped, Claude Code gitignores it, and it is loaded with no CLI flag. That is where AgentsRoom writes its entries, so a user's committed .claude/settings.json is never touched and their colleagues never inherit a machine-specific path.

What running hooks in production taught us

AgentsRoom installs hooks into every project it opens, to track agent status deterministically and attribute edited files to the right agent. A few things only show up at that scale.

Unknown event names are silently ignored. This is not in the documentation, and we depend on it. When we add a new lifecycle event to our installer, users on an older CLI get a settings.local.json containing an event name their binary has never heard of. Nothing breaks, nothing warns, the entry is skipped. That is what makes the installer safe to ship ahead of a CLI release. It is also, inevitably, why a typo produces total silence rather than an error.

agent_id is how you know you are inside a subagent. The field is present only when the hook fires inside a subagent call. This matters more than it sounds: Stop fires when a subagent finishes its turn, not just the main agent. A naive "mark the session done on Stop" rule marks the whole session finished the first time any subagent returns. We skip turn events carrying agent_id for exactly this reason.

Do not read transcript_path for the current turn. The reference warns that the transcript is written asynchronously and may lag the in-memory conversation, so the most recent messages may not be there yet when your hook fires. Stop and SubagentStop receive last_assistant_message precisely so you never have to race the file.

Hooks are the only reliable status signal. Before hooks, we scraped the PTY to work out whether an agent was thinking, waiting or done. That breaks the moment the CLI renders through the terminal's alternate screen buffer, which is what /tui fullscreen does. Hooks fire identically under every renderer. If you are building anything that observes an agent from the outside, this is the layer to build on, and the scraping stays as a fallback at best.

async: true costs nothing. A hook command can declare async: true, and the agent does not wait for it. Our hook POSTs to a local endpoint with a 2-second cap and returns; the agent's turn latency is unaffected even when the receiving app is closed. If your hook only observes and never decides, make it async and stop paying for it.

Never let a hook write junk to the terminal. Our script swallows every exception, including at top level. An unhandled traceback from a hook does not just fail quietly, it prints a Python stack trace into the user's terminal session in the middle of their work.

Timeouts

Defaults are generous, with three exceptions that are not:

Hook typeDefault timeout
command, http, mcp_tool600 s
prompt30 s
agent60 s
UserPromptSubmit (command, http, mcp_tool)30 s
MessageDisplay (command, http, mcp_tool)10 s
SessionEnd1.5 s shared across all hooks, raised to match a longer per-hook timeout, up to 60 s

The SessionEnd budget is the one that surprises people. It is a shared budget, not a per-hook allowance, so three cleanup hooks are splitting 1.5 seconds between them unless you raise it explicitly.

The short version

  • 30 events exist. Six are famous.
  • stdout reaches the agent on UserPromptSubmit, UserPromptExpansion and SessionStart only. Everywhere else, use exit 2 with stderr, or additionalContext in JSON.
  • 15 events block on exit 2, 15 ignore it. Guardrails go on PreToolUse.
  • Matchers are exact strings until a special character turns them into an unanchored regex.
  • Six settings sources merge additively. Nothing overrides anything.
  • A misspelled event name fails completely silently.

If you want to watch these events fire rather than reason about them, that is what we built: AgentsRoom shows every hook trigger per agent, per project, per run, across dozens of parallel agents and subagent sessions. The hooks you configure in your own settings keep working exactly as written, because it runs the real CLI.

Keep reading

Download AgentsRoom

Run your AI agents (Claude, Codex, Antigravity CLI, OpenCode, Aider, Grok Build, Mistral Vibe, Kimi Code) on all your projects, from a single window.

FreeDownload AgentsRoom

Companion app: monitor your agents on the go

Bring your own: Claude, Codex, Antigravity CLI, or other AI provider.

Get the extension
Chrome Web Store

Push bugs and requests straight to your public backlog.

A glimpse of AgentsRoom in action.

Multiple projects
Multi-provider
Multiple agents
Live status
File diff & commit
Mobile companion
Live preview
Agent teams
Browser automation
Backlog-driven dev
Prompt Library
Skills Library
View all features