Fixing Claude Code Hooks: The New Matcher Format
Correction, 2026-10-03: An earlier version of this post said matcher must be a valid regex and that an empty string does not work, and its fixed example put a matcher on a Stop hook. The current Claude Code hooks reference (checked 2026-10-03) says "*", "" or an omitted matcher all match every occurrence, and that Stop does not support matchers: a matcher there is silently ignored. The matcher rule and the example below have been corrected.
Claude Code recently changed their hooks format, and if you haven’t updated your project settings, you’ll see this cryptic error:
hooks: Expected array, but received undefined
Files with errors are skipped entirely, not just the invalid settings.
The Problem
The old hook format put command at the top level:
{
"hooks": {
"Stop": [
{
"matcher": "",
"command": "bash .claude/hooks/my-script.sh"
}
]
}
}
This silently breaks - your entire settings file gets skipped.
The Fix
The new format requires a nested hooks array with explicit type:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/my-script.sh"
}
]
}
]
}
}
Key changes:
matcher- Filters when the hook fires."*",""or leaving it out matches everything. A plain tool name such asBash, or a list such asEdit|Write, matches exactly. Anything with other characters is treated as a regex.Stopand several other events ignore the matcher, so the example above leaves it out; it matters on tool events such asPreToolUseandPostToolUse.hooks- Now an array inside each matcher group (the object that can hold amatcher)type- Required field, set to"command"for shell commands
Quick Migration
Find all your .claude/settings.json files and update them:
# Find all Claude settings files
find ~/projects -name "settings.json" -path "*/.claude/*" 2>/dev/null
Then update each one to the new format.