Claude Code Hooks Simulator and Matcher Tester
Paste the hooks block out of your settings.json, choose an event and a tool, and this page compiles every matcher the way Claude Code does, works out which hooks fire and how long the CLI waits for them, resolves the exit-code and JSON precedence into a single verdict, and lints the config. Everything runs locally in your browser.
How Claude Code actually routes a hook event
Step 1 — matcher compilation
Each entry under an event is a group: one matcher plus an array of hooks. For the tool events (PreToolUse, PostToolUse), the matcher is tested against the tool name as a regular expression, and it is anchored — Edit matches only Edit, not NotebookEdit or MultiEdit. This page shows the anchored form it applies, ^(?:matcher)$. Matching is case sensitive. A matcher of *, an empty string, or a missing matcher key matches every tool. Alternation works exactly as you would expect: Edit|Write|MultiEdit compiles to ^(?:Edit|Write|MultiEdit)$.
MCP tools are exposed as mcp__<server>__<tool>, so a matcher for a whole server has to be written as a regex — mcp__github__.*. Writing mcp__github__* is the single most common hook bug: in regex, * is a quantifier applied to the preceding character, so that pattern means "mcp__github_ followed by zero or more underscores" and it will never match a real MCP tool name. The simulator prints both readings so you can see the failure.
Not every event matches on tool names. Stop, SubagentStop, and UserPromptSubmit have no matcher target at all — every group under them fires and any matcher string you write is inert. SessionStart matches the session source (startup, resume, clear, compact), PreCompact matches the compaction trigger (manual, auto), and Notification matches the notification type. Putting Edit|Write on any of those is a silent no-op or a silent match-everything, which is why the linter calls it out.
Step 2 — firing order and the wait
There is no ordering guarantee. Every group whose matcher hits runs concurrently, and every hook inside a group runs in parallel with its siblings, so the number of processes spawned is the total count of matching hook entries, not the number of groups. Because they are parallel, the wall-clock the CLI spends is the maximum of the individual timeouts, not the sum:
wait(event) = max( timeout_i ) over every matching hook i timeout_i = hook.timeout or 60 seconds when unset
60 seconds is the per-command default. A timeout applies to one command only — one slow hook does not extend the budget of the others, it just becomes the critical path. The simulator marks which hook is on that critical path.
Step 3 — decision precedence
A hook communicates back in two ways, and the richer one wins. The simple channel is the exit code:
- Exit 0 — success.
stdoutis shown in the transcript (Ctrl-R) and Claude never sees it, with two exceptions: forUserPromptSubmitandSessionStart, stdout is injected into the model's context. - Exit 2 — blocking error, and
stderris fed back to Claude. What it blocks depends on the event:PreToolUseblocks the tool call,StopandSubagentStopblock Claude from stopping,UserPromptSubmiterases the prompt (and there the stderr goes to the user, not the model). OnPostToolUsethe tool has already run and cannot be undone, so the stderr is feedback rather than a block. OnSessionStart,PreCompact, andNotification, exit 2 blocks nothing. - Any other non-zero exit — non-blocking error. stderr is surfaced to the user, and execution continues as if the hook had not spoken.
The richer channel is structured JSON on stdout. If stdout parses as JSON, it overrides the exit code. hookSpecificOutput.permissionDecision on PreToolUse takes allow, deny, or ask; decision: "block" with a reason is the equivalent on PostToolUse, Stop, and UserPromptSubmit; hookSpecificOutput.additionalContext injects text into the model's context; and continue: false with a stopReason halts the whole turn and outranks every other field. If stdout does not parse as JSON, it is treated as plain text and the exit code governs after all.
When several hooks answer at once, the resolution is deny > ask > allow. One denial from any hook blocks the call no matter how many others allowed it, and an allow from a hook bypasses the normal permission prompt rather than merely permitting it. If no hook expresses a decision, the call falls through to the usual permission system.
Step 4 — the latency you actually pay
A tool call is wrapped by two hook events, so the worst-case added wall clock per call is:
added_latency = max(PreToolUse timeouts that match the tool)
+ max(PostToolUse timeouts that match the tool)
The panel computes this from your config for the tool you selected. It is a ceiling, not a measurement — hooks that finish fast cost nothing — but it is the number that matters when a hook hangs, because that is exactly how long every single matching tool call will stall before the CLI gives up on it. Two 60-second defaults on a tool Claude calls forty times in a session is a 40-minute worst case, which is why explicit short timeouts on frequently matched tools are worth the keystrokes.
What the linter checks
- Permission-rule syntax (
Bash(npm run build)) used where a matcher belongs — the parentheses are regex groups, not a literal command, and matchers only ever see a tool name. - Glob-style
*used as a wildcard after a literal character, themcp__server__*trap. - Matchers that fail to compile as a regex at all.
- Matchers set on events that ignore them, or set to a tool name on events that match a source or trigger instead.
- Missing
"type"— command hooks require"type": "command"alongside"command". - Relative script paths, which resolve against whatever the CLI's working directory happens to be. Use
$CLAUDE_PROJECT_DIR, which Claude Code sets to the project root for every hook process. - Timeouts that are absent, zero, negative, or non-numeric, plus timeouts long enough to dominate the budget.
- Self-modifying hooks: a
PostToolUsehook onEditorWritethat writes files itself.