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:

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

Related Tools