Skip to content

Deep Dive: The hooks were valid JSON. They never fired.

For six months I shipped a repository of Claude Code configurations with a test suite that passed on every commit. In August I checked the hook examples against the official documentation for the first time since writing them. Every single one used a matcher that could never match anything.

The repo had been public since 2026-01-28. The bug shipped in the initial commit and survived eight releases.

What the config said

A Claude Code hook fires when its matcher matches. My formatter examples looked like this:

{
  "matcher": "Write(*.ts)|Edit(*.ts)",
  "hooks": [{ "type": "command", "command": "npx prettier --write \"$FILE\"" }]
}

That reads like a filter: run on TypeScript writes and edits. It is the shape permission rules use elsewhere in the same settings file, which is where I got it from.

Hook matchers do not work that way. The matcher is tested against the tool name, and the documentation is specific about how:

How a matcher is evaluated depends on the characters it contains. Only letters, digits, _, -, spaces, ,, and | are an exact string or a list. Contains any other character: JavaScript regular expression, unanchored.

Write(*.ts) contains parentheses, so it goes down the regular-expression path, where it is not even a valid pattern: the * has nothing to repeat. It never matches. The hook is loaded, parsed, accepted, and silently never runs.

The correct version scopes the tool name in the matcher and the file path in a handler-level if:

{
  "matcher": "Edit|Write",
  "hooks": [{ "type": "command", "if": "Edit(*.ts)", "command": "..." }]
}

Why the tests passed

I had a validator. It walked every hook file, parsed the JSON, and checked each event name against a schema. It reported zero errors for six months, and it was right to.

The schema said this:

"matcher": { "type": "string" }

"Write(*.ts)|Edit(*.ts)" is a string. So is "tool == \"Bash\"", which I had used in three other files. So is any arrangement of characters somebody types into that field. The validator was asking whether the config had the right shape. Every one of these had the right shape and the wrong meaning.

The same gap hid two more problems in the same files. Three hooks used "type": "message", a handler type that does not exist; the real set is command, http, mcp_tool, prompt, and agent. One config key, sandbox.network.denyExternal, is not a real key at all. I wrote configuration that looked plausible and never checked.

What I changed

The fix to the configs was mechanical once the rule was clear: 44 files, mostly matchers moving from the permission-rule form to a tool name plus an if.

The useful change was to the validator. It now reads the valid handler types out of the schema and fails on anything outside that set, and it warns when a matcher contains the markers of the two mistakes I made: an expression (==, tool_input) or a permission-rule form (Name(...)). Both were previously invisible, because both are valid strings.

That is the part worth stealing. A schema checks that a field is a string. It cannot check that the string means what the author thought it meant. If a config value has semantics, something has to encode those semantics or nobody finds out until the feature quietly does nothing.

The part I got wrong twice

There is a second failure here that is less comfortable.

In June and again in July I had done what I described as verification passes on this repo. In July I committed a changelog entry that said "re-verified the GA model lineup." I had not re-verified anything. I had compared the repo against a cached reference table and restamped the dates.

The August pass, which actually fetched the live documentation, found the default model in the shipped settings had been a full generation out of date through both of those "verifications." The cached table I trusted was itself older than the thing it was describing.

Reading a page you have already read is not verification, and neither is checking against a copy of it. The only pass that found anything was the one that made a network request.

What this cost and what it did not

I want to be accurate about the blast radius. These were example configurations for people to copy, in a repo with a handful of stars. The failure mode is silent rather than destructive: a formatter that does not run, not a command that deletes something. Nobody filed an issue, which is consistent with a bug whose symptom is nothing happening.

It is worth writing up because the failure was invisible to every check I had, for six months, in a repo whose entire purpose is to be a reference for how to configure this tool correctly.

Current state, verified on 2026-08-09: 11 test suites pass, 158 files validate with zero errors and zero warnings, and the inventory counts in the README are asserted in CI so they cannot drift from the filesystem again. The documentation claims were checked against the live docs on 2026-08-02, against CLI v2.1.220. The CLI has moved well past that version since, so that check is already due again. The date sits in the file next to the claim, which is the only reason I can tell you how stale it is.

Repo: https://github.com/peopleforrester/claude-dotfiles