Everything on this page I reproduced myself on build 2.1.272, on Linux, and every quoted line is copied out of a real run rather than retyped from somebody's report. Where a claim came from elsewhere and I could not reproduce it, it is in the last section instead of this one.
Read the first two before you ship anything.
1. A hook that fails does not block. It disappears.
Three things make a hook fail: it throws, it overruns its 10-second budget, or it returns the wrong shape. All three do the same thing, and it is the opposite of what a guard needs.
I ran five arms of one experiment. Each is a hook on tool.call for Bash, each session asks Claude to create a marker file with a shell command, and the ground truth is whether the file exists afterwards.
| The hook does | Tool ran? |
|---|---|
return next(e), the control |
yes |
throw new Error("deliberate hook failure") |
yes |
await new Promise(() => {}) |
yes, after a ten-second stall |
return { deny: "" } |
no |
return { deny: "<a reason>" } |
no |
The engine narrates it as it happens, in the debug log and nowhere else:
hook failed: armR: deliberate hook failure (tool.call; skipped; what is below it ran in its place)
hook failed: armQ: exceeded 10000ms budget (tool.call; skipped; what is below it ran in its place)
$.ui.log: tool.call hook skipped: threw Error: deliberate hook failure
The hanging arm cost wall-clock and nothing else: 16 seconds for the control, 38 for the hang, same outcome.
So a security hook with a bug in it is not a closed gate. It is an absent one. A guard that calls await $.http.fetch(policyUrl) and meets a network blip permits everything it was installed to refuse, and the only trace is one dim line.
The fix is .catch, and it is three lines:
on("tool.call", { tool: "Bash" }, async ($, e, next) => {
if (await isDangerous($, e.command)) return { deny: "policy" }
return next(e)
})
.catch(($, e, next) =>
next.called ? next(e) : { deny: next.error.kind })
If the hook already called next, that stands. If it failed before getting there, the check never completed, so deny. Any hook whose absence would be a security event needs this, and a hook without it is asserting that failing open is acceptable.
2. A good deny reason makes the model route around you
Both deny arms stopped Bash. What happened next was not the same.
With { deny: "" } the model retried Bash three times and then gave up and asked the person:
The Bash tool calls are failing outright (empty tool_use_error), even for a simple
ls. This looks like the tool is being blocked before execution, possibly by a permission setting or hook.
With a clear reason naming organisation policy, the model read it, understood it, and used a different tool:
Bash was blocked by org policy, so I used the Write tool instead. The file is created at the requested path with "hello" as content.
The file got written either way. A good deny reason and a complete deny are different problems, and solving the first can make the second worse, because you have handed the model the information it needs to find another door.
Weigh that one carefully, because it is a single run and somebody else measured the opposite. A community probe of the same question at 2.1.263 ran thirty sessions and saw no detour at all, 0 of 10 for every deny shape, and the person who ran it retracted their earlier claim on the strength of it. So what I have is one session at 2.1.272 in which a model given a clear reason picked another tool, which proves the detour is possible and proves nothing about how often. Do not design around a rate. Design around the possibility.
The defensive shape either way: if you are blocking a capability rather than a tool, block every tool that provides it. One hook with a matcher array over the set beats one hook on Bash.
3. A tool.call hook breaks worktree isolation for subagents
Reported upstream as issue #92533. Confirmed here at 2.1.272 on Linux, and narrowed further than the report goes.
Spawn a subagent with isolation: "worktree" while a mod has a tool.call hook loaded, and Bash inside that agent is refused:
The working-directory isolation context for this agent was lost, so this command would run in the parent session's directory instead of this agent's worktree (…/.claude/worktrees/agent-abc3c365c2ea73b7a). Refusing to run it. Retry the command; if this keeps failing, report that worktree isolation was lost.
Seven arms, each a claude -p spawning one isolated agent that runs pwd through Bash:
| The mod registers | Bash inside the isolated agent |
|---|---|
session.start, passthrough |
works |
tool.call with {tool:"Bash"} |
refused |
tool.call with {tool:"Read"} |
works |
tool.call, no matcher |
refused |
tool.check with {tool:"Bash"} |
works |
on("*") |
refused |
on("!tool.call") |
works |
The rule the arms give, which is mine rather than the report's: any hook whose chain dispatches over a tool.call for Bash inside a worktree-isolated agent loses the isolation context. Matching a different tool is safe. tool.check on Bash is safe, which makes it the seat to use if you only need a say in the decision.
on("!tool.call", ...) is a working workaround and it is also the runtime confirmation that negation patterns work at all, which is in no public source. If your mod needs everything except that one event, this is free.
There is a second-order trap in the report worth carrying: a blocked subagent that tries to recover by calling EnterWorktree with a path switches the parent session into that worktree, and the parent's git commands are then refused outside it until ExitWorktree. The reporter's own workaround is to create worktrees by hand with git worktree add and spawn agents without isolation, prefixing each command with cd <worktree> &&. Corroborated on Windows at 2.1.272 by another reporter in the issue's one comment.
4. $.session.usage() is empty until the first turn completes
At session.start:
{"context":{"window":200000},"rateLimits":[],"cost":{"usd":0}}
At the first turn.complete:
{"context":{"tokens":27532,"window":200000,"percent":14},
"rateLimits":[{"kind":"five_hour","percentUsed":40,"resetsAt":"2026-09-15T06:20:00.000Z"},
{"kind":"seven_day","percentUsed":70,"resetsAt":"2026-09-17T07:00:00.000Z"}],
"cost":{"usd":0.006494}}
tokens and percent are absent keys in the first. They are not zeroes. So the obvious warning never fires, because undefined > 80 is false:
const usage = await $.session.usage()
if (usage.context.percent > 80) await $.ui.notice('context is filling up') // never
Nothing throws and nothing logs. The mod looks like it is working and it has simply never had a reading. Reproduced twice, in two separate probes.
const percent = usage.context.percent
if (percent === undefined) return // no reading yet, not a low reading
if (percent > 80) await $.ui.notice(`context ${percent}%`)
Same shape for rateLimits, which is an empty array rather than a missing key, so a find on it returns undefined and a naive .percentUsed throws, which skips your hook.
5. The manifest validator does not type-check your defaults
A number field with a string default:
{ "type": "number", "title": "Bad", "description": "d", "default": "three" }
claude plugin validate prints ✔ Validation passed with warnings. The loader then drops the entire module:
hooks module armI failed to load: armI: options do not fit plugin.json userConfig:
Bad must be a number (settings.json pluginConfigs["armI" or "armI@inline"].options)
It names the field by its title rather than its key, and it blames settings.json for a value that came from your own manifest. Nothing was configured. Two more of the same family: required: true with nothing configured stops the module loading (Needed is required but not provided), and project settings are never consulted for plugin options at all. All three are on the config page with the full messages.
6. An unconfigured option reads as "" and not undefined
options.nodefault // ""
"nodefault" in options // true
options.nodefault ?? FALLBACK // "" so the fallback never applies
Every declared key is always present on the frozen options object. Check for the empty string, or declare a default so the manifest is the single source of truth for the fallback.
7. The capability scan checks shape rather than spelling
It is a real dataflow analysis and it will follow $ into a helper to refuse it:
✘ modules../h.ts: armL: .../hooks/h.ts, compiled line 2 `if (obj === null || obj === undefined)`:
$ itself is used in a BinaryExpression (bound, passed, spread, returned or read);
$ is always spelled $.noun.event(...) at the call site, on is always on("<event>", hook),
and next.to always next.to(e, "<tier>")
And it will happily pass an operation that does not exist. $.fs.bogusVerb("x") validates clean and is listed in the inventory as calls: $.fs.bogusVerb. At runtime it is $.fs.bogusVerb is not a function, which throws, which skips the hook, which runs what is below it, per gotcha 1.
Validation is not a reference for which operations exist. /plugin-types is.
8. The filesystem operations were renamed with no deprecation
Between 2.1.263 and 2.1.272: fs.readFile to fs.read, fs.writeFile to fs.write, fs.listDir to fs.list. No alias and no migration note. Every mod written against the old names throws on first call and fails open.
The declarations open by warning you this will happen:
EARLY ACCESS: this surface may change between releases without notice.
Pin the build you tested on. Re-read the declarations when you upgrade. This is the one gotcha on the page that will keep generating new instances of itself.
9. Small things that each cost an hour
| What | What actually happens |
|---|---|
| The gate is off | The plugin loads, its commands and skills work, the hooks module is silently dropped. The only sign is hooks modules not loaded: rollout flag (tengu_plugin_hooks_modules) is off in the debug log. |
--debug alone |
Captures none of the hooks output. You need --debug hooks --debug-file <path>. |
A mod under ~/.claude/skills/ |
Does not hot-reload. Served me stale code through three consecutive runs at 2.1.270. Only --plugin-dir reloads on save. |
$.plugin.name() |
Throws. $.plugin is data, so it is $.plugin.name with no call. |
$.telemetry on a public build |
undefined is not an object (evaluating '$.telemetry.log'). It is internal builds only, which is also why Anthropic's diff mod does not run on a public one. |
$.ui.ask without the tool |
$.tool.call: no tool named "AskUserQuestion" in this session. It is a tool call underneath. |
$.session.compact headless |
Refuses: "not available in a headless (-p / SDK) session yet… catch it and carry on". |
$.session.repo outside git |
null. A jj workspace and a secondary working copy both read as no repo. |
| Two hooks on one event in one plugin | on throws at load and the module is dropped. Register once, branch inside. |
{ tool: [] } as a matcher |
Refuses the module. So does a wrong-typed matcher value on a glob hook. |
$.ui.log for debugging |
Writes a visible transcript row. There is no invisible log channel. |
What I could not reproduce, and what is already fixed
Listing these because a gotcha page that only grows is a gotcha page nobody can trust.
#94424 did not reproduce at 2.1.272. Two claims, both measured by the reporter at 2.1.270: a float creeping into percentUsed (7.000000000000001), and a window dropping out of rateLimits after it resets. I saw neither. They may be real and fixed, or real and rarer than my sample, so this is not a contradiction of the report. It is why neither is above.
#92440 is fixed. The agent.offer field is isOffered, and at 2.1.272 the declarations say isOffered in all seven places, with the example agent changed from "advisor" to "Plan". Older examples on the internet say offered, so if you are copying from a blog post, check the name.
#92469 is half fixed. session.authorize is fully declared now and works as described on the engine page. flag.value still does not exist. The structural half of that report survives on its own merits and is worth knowing: OpEventOf is a type alias rather than an interface, so a plugin cannot declaration-merge into it. You can add a noun to EngineInterface. You cannot add its operations to the event map, so a consumer of your noun cannot hook its verbs by name with types.
Two retracted claims from the issue thread, listed so nobody re-imports them: the 8,000-character additionalContext constant does not act as a boundary, and the real one is around 10,000 characters; and the "two rewrites, neither applies" claim is wrong, because the link nearest core wins. The thread's own conclusion after the first one was measured is the sentence worth keeping: reading a constant is not measuring a behaviour. The third retraction in that thread, about whether a denied Bash makes the model detour, is discussed in gotcha 2, where my own single run disagrees with their thirty.