Originally published at https://aicoding-guide.com.
When you write hooks in settings.json, it is not obvious what belongs in matcher. You can tell that a tool name like Bash works, but whether regex is allowed, and what SessionStart compares against, is something you normally discover by trial and error.
The short version: what a matcher compares against depends on the event, and the way you write the value silently decides whether it is treated as an exact match or a regex. This article lays out both rules.
Key point
What you will learn
- How Claude Code decides between exact match, regex and wildcard
- What the matcher is compared against for each event
- How to target MCP tools, and which events ignore a matcher entirely
How the value decides the matching mode
A matcher is evaluated in one of three modes, and the characters you type decide which one.
| Value | Evaluated as | Example |
|---|---|---|
"*", "", or omitted |
Match all | Fires on every occurrence |
Only letters, digits, _, -, spaces, ,, `\ |
` | Exact string, or a list of exact strings |
| Contains any other character | JavaScript regex, unanchored |
^Notebook, mcp__memory__.*
|
This is the easiest part to get wrong. Bash is an exact match, but ^Bash$ contains anchors, so it is treated as a regex. Both happen to give the same result here, but if a name you write happens to contain . or *, it silently becomes a pattern.
Regexes are tested with RegExp.prototype.test(), which means matching anywhere in the value. A regex Edit also matches NotebookEdit. Write ^Edit$ when you want the whole string.
Matchers are case-sensitive
bashdoes not match theBashtool. Use the exact spelling from the tools reference.
Two behaviors depend on your version:
- Exact matching for names containing hyphens requires Claude Code v2.1.195 or later. On earlier versions a name like
code-revieweris evaluated as an unanchored regex, so write^code-reviewer$ - Comma-separated alternatives (
Edit, Write) require v2.1.191 or later
What each event matches against
The matcher is not a tool-name field. Each event compares it against something different.
| Event | Matcher filters | Example values |
|---|---|---|
PreToolUse / PostToolUse / PostToolUseFailure / PermissionRequest / PermissionDenied
|
Tool name |
Bash, `Edit\ |
{% raw %}SessionStart
|
Session start reason |
startup, resume, clear, compact, fork
|
Setup |
CLI flag that triggered setup |
init, maintenance
|
SessionEnd |
Session end reason |
clear, resume, logout, prompt_input_exit, other
|
Notification |
Notification type |
permission_prompt, idle_prompt, auth_success
|
SubagentStart / SubagentStop
|
Agent type |
general-purpose, Explore, Plan, custom names |
PreCompact / PostCompact
|
What triggered compaction |
manual, auto
|
PreModelSwitch / PostModelSwitch
|
Canonical model name | claude-opus-5 |
ConfigChange |
Configuration source |
user_settings, project_settings, local_settings, policy_settings, skills
|
DirectoryAdded |
How the directory was added |
slash_command, register_repo_root
|
FileChanged |
Literal filenames to watch | `.envrc\ |
{% raw %}StopFailure
|
Error type |
rate_limit, overloaded, authentication_failed, server_error
|
InstructionsLoaded |
Load reason |
session_start, nested_traversal, path_glob_match, include, compact
|
UserPromptExpansion |
Command name | Your skill or command names |
Elicitation / ElicitationResult
|
MCP server name | Your configured MCP server names |
For a worked example on the last-but-two row, see Log which CLAUDE.md files Claude Code loads.
Events that ignore the matcher
These events do not support a matcher. Adding one is not an error — it is silently ignored.
UserPromptSubmitPostToolBatchStopCwdChangedTeammateIdleTaskCreatedTaskCompletedWorktreeCreateWorktreeRemoveMessageDisplay
If a hook seems to fire every time no matter what you narrow it to, check this list first. Because nothing errors, this misconfiguration is easy to miss.
A working example
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-bash.sh"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "/path/to/lint-check.sh"
}
]
}
],
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "echo started >> ~/session.log"
}
]
}
]
}
}
Check tool-name spellings in the official tools reference. The ones you will reach for most are Bash, Read, Edit, Write, Glob, Grep, WebFetch, WebSearch, NotebookEdit, TodoWrite, Agent and Skill.
Targeting MCP tools
MCP server tools are named mcp__<server>__<tool>.
| Goal | Matcher |
|---|---|
| Every tool from one server | mcp__memory__.* |
| A server name containing a hyphen | mcp__brave-search__.* |
| One tool name across all servers | mcp__.*__write.* |
| A plugin-bundled server | mcp__plugin_my-plugin_db__.* |
Don't drop the trailing .*
mcp__memoryon its own is an exact match and will not matchmcp__memory__create_entities. Append.*when you want everything under a server.
When the matcher isn't specific enough
A matcher only narrows as far as the tool name. To filter on the tool's arguments — "only TypeScript file edits", say — use the if field on the hook itself.
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"if": "Edit(*.ts)",
"command": "./check-typescript.sh"
}
]
}
if uses the same rule syntax as permissions, covered in Claude Code permissions in settings.json. It is evaluated only on tool events and ignored everywhere else.
Glossary
Unanchored matching: a regex succeeds if it matches anywhere inside the value. Add^and$when you need the whole string to match.
For writing the hook script itself, see Run lint and format automatically after every edit.
Summary
- The characters in the value decide between exact match, regex and match-all
- Any symbol turns the value into an unanchored regex, so
Editalso matchesNotebookEdit - Matchers are case-sensitive; hyphen exact-matching needs v2.1.195+, commas need v2.1.191+
- What the matcher compares against varies by event and is often not a tool name
- Ten events, including
StopandUserPromptSubmit, ignore the matcher silently - For MCP tools, append
.*as inmcp__<server>__.*
Top comments (0)