Hook Chain

Evidence Claude Code 2.1.296 · October 12, 2026

Field tests

The docs make a lot of specific claims about how mods behave when things go wrong. We wrote a small mod for each claim and ran it. Most claims held. Two didn't, and one of our own mistakes taught us more than the experiment it was part of.

A tidy laboratory workbench: a small circuit board shaped like a puzzle piece under a magnifying lamp, a rack of test tubes topped with check-mark and cross flags, a stopwatch, a notebook of results tables, and a friendly robot in safety goggles writing on a clipboard.

Summary

ClaimVerdictWhat we saw
A guard that throws is skipped and the call goes on, unless it has a .catchConfirmedrm -rf build reached core through a throwing guard; with .catch the call was refused
A guard that runs past its 10 s budget is skippedConfirmed livetool.call hook skipped: ran past its 10s budget, and the Read ran at 10.02 s
Prepend, then user mods, then core; within a module, on orderConfirmedls >prepend >first >second >other-user, results unwound in reverse
Observe, rewrite, answer, post-process, retryConfirmedCore never saw the rewritten or answered commands, and saw the retried one twice
A module whose use of $ can't be read is refusedConfirmedValidate exits 1 and the engine won't load it: $ itself is bound to a name
Failing after next keeps the result; nothing runs twiceConfirmedCore ran the command once, and its result came back
A tool.call guard doesn't see @-mentioned filesConfirmed (kit)The Read was refused, while the @.env mention went to its own event
No timer globals such as setTimeoutContradictedsetTimeout exists and fires, in the kit and in the live hooks worker
Register each event once per matcherPartly contradictedOnly duplicates without a matcher are refused; 20 identical matchers loaded and all ran

How we tested

Most experiments use the test kit, claude plugin test. It runs the real engine with no session, model or terminal, and the hooks a test registers sit beneath the mod as a stand-in for Claude Code's own behaviour. That makes it good for testing the chain, and unable to show what a live session does around it. For the two claims the kit can't reach, we used a live headless session: a mod whose prompt.submit hook drops the prompt, so claude -p runs the hooks and exits without calling a model.

Every mod, test and output is in experiments/ on GitHub, with the full write-up in RESULTS.md. The outputs below are trimmed but not edited.

1. A broken guard fails open

This is the claim that matters most for anyone writing a guard. The docs say a hook that throws, times out or returns the wrong shape before calling next is skipped, and the next handler runs in its place. For a guard, that means the thing it was guarding happens.

on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
  if (e.command.includes('rm')) throw new Error('guard bug')
  return next(e)
})

on('tool.call', { tool: 'Write' }, async ($, e, next) => {
  throw new Error('guard bug')
}).catch(($, e, next) => (next.called ? next(e) : { deny: `guard failed (${next.error.kind}), so the call was refused` }))
E1 bash result: {"result":"core ran it"}
E1 write result: {"deny":"guard failed (throw), so the call was refused"}
E1 reached core: ["Bash rm -rf build","Read"]

The rm -rf build went straight through. claude plugin validate tells you about this before anything runs. It lists every hook at a site that can refuse something, and says whether it has a handler:

❯ ./register.ts gating hook without .catch: tool.call{tool=Bash}
❯ ./register.ts gating hook with .catch: tool.call{tool=Write}

Rule: every hook that refuses things gets .catch(($, e, next) => next.called ? next(e) : { deny: '…' }). Then grep validate's output for without .catch in CI.

2. A slow guard is a missing guard

The kit couldn't test the time budget, because its own per-test limit (5 s) is shorter than a hook's (10 s). The test died first with timed out after 5000 ms. So we tested it live. live-probe registers a Read guard that waits 11 seconds and then denies. Its prompt.submit hook makes a Read through $.tool.call, which runs every hook except the calling one.

$ HC_PROBE_OUT=/tmp/out.json claude -p x --plugin-dir experiments/live-probe
live-probe: tool.call hook skipped: ran past its 10s budget
Prompt dropped by a hook: live-probe: measurements written; no model call

In three runs, the Read came back with the file's contents after 10,015 to 10,030 ms. The guard's deny arrived too late to count. The budget covers the hook's own time. Waiting on next or on a $ call doesn't count against it, but our guard waited on a plain timer, and that does. So does $.clock.sleep, according to the reference. See the latency ladder for what that means for a guard that asks a model.

3. Order: outermost first, unwound in reverse

The mod under test registers two Bash hooks. The test loads one inline mod at the prepend tier and one more user mod. Each hook tags the command on the way in and the result on the way out.

core saw "ls >prepend >first >second >other-user"
  <other-user <second <first(origin=engine/core) <prepend

This is the order in the hook chain: an organization's prepend mods wrap yours, yours wrap the mods after them, and core sits at the centre. Within a module, on order decides. next.origin on a call the test raised reads engine/core, which is how the reference says Claude Code identifies itself.

4. The five moves

One hook picks its move from the command text:

E3 core saw: ["observe ls","echo rewritten","post ls","retry make","retry make"]
E3 rewrite rm -rf / -> "ran: echo rewritten"
E3 answer pwd -> "answered by the mod (seen 3)"
E3 post ls -> "ran: post ls [annotated]"

Core never saw rm -rf / or answer pwd. A rewrite can't be seen by anything downstream, including later mods. That's why the order matters, and why an organization's guard sits outermost.

5. What static analysis refuses

Claude Code reads each module's source before loading it, and lists every event it hooks and every $ call it can make. To make that list trustworthy, it refuses code it can't read. Each of these fails validate with exit status 1:

PatternMessage (trimmed)
const { ui } = $$ itself is bound to a name (bound, passed, spread, returned or read)
const ui = $.ui$.ui is used as a value (a noun of $ bound, passed or read)
const note = ($, t) => … inside register$ is passed to "note", which is not a function declared at the top of this file
for (const ev of EVENTS) on(ev, …)the event name passed to on() is not a string literal
await import('./other.ts')a dynamic import()

The engine refuses the same module at load: hooks module did not load: … $ itself is bound to a name. A clean module gets an inventory instead, with calls through a top-level helper attributed to it: calls: $.env.get, $.store.set, $.ui.toast (via note) and env reads: HOME. Read that inventory before you install someone else's mod.

6. Failing after next

A Bash hook that throws while post-processing, and a Read hook that answers with a shape tool.call doesn't accept:

E5 bash: {"result":"core ran Bash"} read: {"result":"core ran Read"} core calls: ["Bash","Read"]

Core ran Bash once, and its result stood. The wrong-shape Read answer was skipped, and core ran the Read. A post-processing bug can't run a command twice. A wrong-shape bug in a guard fails open, just like a throw.

7. The second door: @-mentions

Files named after @ in a prompt raise prompt.mention, not tool.call. We wrote the secrets guard everyone writes first, refusing a Read of .env, and then raised both events:

E6 Read tool .env: {"deny":"secret-guard: /work/.env looks like a secret"}
E6 @right/.env (deny shape): {"deny":"secret-guard: @right/.env looks like a secret"}
E6 @wrong/.env (bad shape): {"deny":"catch handler answered (throw)"}
E6 @other/.env (no mention hook): {"type":"file"}

The tool.call guard never fired for the mention. Only a hook on prompt.mention stopped it. In the kit the test raises both events itself, so this shows the events are separate. It can't show that a live session raises no tool.call for an @ read. That part comes from the 2.1.295 reference.

The @wrong row is our own mistake, kept on purpose. Our first refusal returned { type: null, deny }, which isn't a shape prompt.mention accepts. Its first .catch returned the same wrong shape, so the hook was skipped and the file was read, with no error anywhere we were looking. A wrong shape reaches .catch as next.error.kind === 'throw'. Make sure the handler's own answer has the right shape, and test the refusal path rather than only the happy path.

8. Two places the docs and the engine disagree

Timers exist

The mods API page says "the hooks module itself has no Node.js APIs, no timer globals such as setTimeout". In 2.1.296, setTimeout and setInterval are functions, and a 20 ms timer fires after 20 to 21 ms. That's true both in the kit and in the live hooks worker, and validate doesn't flag it. process, fetch, require, WebSocket and XMLHttpRequest are absent, as documented. Treat the timers as undocumented. They may disappear in a later release, a pending one presumably dies with the module on a reload (we didn't test that), and waiting on one counts against the hook's budget, as section 2 showed. $.clock.every and $.clock.after are the supported way to do this.

Duplicate matchers load

The events page says "Register each event once per matcher". Registering tool.call twice with no matcher is refused: on("tool.call") is registered twice without a matcher; the first is at …:2. Registering it twice with the same matcher, { tool: 'Bash' }, passes validation, loads, and both hooks run. Our latency mod stacks 20 of them, and counted 124,000 hook runs for 6,200 calls.

Smaller things worth knowing

Next: the latency ladder uses the same experiments to put numbers on the chain, from a pass-through hook to a human answering a question.