Follow Discord
Sweep 06 Nov 2026 · 01:32Z Build v2.1.319 537 read Stable v2.1.311 Latest v2.1.318 Next v2.1.319 Feeds RSS JSON llms.txt llms-full.txt Unofficial
Mods · 03 of 10

Tiers, order and next()

The five tiers, how a hook wraps the ones below it, and everything a hook can do with next().

Read against 2.1.286 Anthropic's page: React to events: run alongside other mods 26,476 characters 12 sections lifecycle.json, paged

Every hook on an event gets composed into one function, and that composition is a fold. The cheat sheet draws it three different ways because people keep reading it as a filter chain, which it isn't:

X = A ∘ B ∘ C ∘ core = A(B(C(core(⊥))))

A is outermost. It gets the event first and whatever it returns is the answer. Everything beneath it, core included, only runs because A called next. On the way down each hook can change the question, core answers it, and on the way back up each hook can change the answer. Whatever registered earlier wraps more, so position is authority.

The five tiers

Plugins are ordered by tier, and the declarations put the rule in one sentence: "outermost first: earlier is outer is more authority, and same-event hooks nest in this order and no other way."

Tier Who Position
prepend Organisation policy, first Outermost, wraps everything
user Everything a person installs Middle
append Organisation policy, last Beneath every user plugin
builtin Anthropic's own mods Beneath that
core The engine Innermost, does the thing

prepend and append are the organisation's. A mod an administrator lists in prependPlugins or appendPlugins lands there, and anything you install lands in user. Anthropic's admin page adds a case I'd missed. A mod that counts as the organisation's runs in prepend even when neither list names it (their admin page, and the order mods run in).

On a machine with managed settings, or on a Team or Enterprise plan, sec-default sits in prepend too, which is why a user plugin there can't touch classic hooks, prompt sections, settings reads, or the description of a tool the organisation provides. At 2.1.286 I had a user mod deny Bash from classic.PreToolUse on my managed machine, and the command ran anyway. The log said classic.PreToolUse bypassed by cc-plugin-sec-default (tier user); beneath runs.

Anthropic's admin page says the guard protects what the organisation manages and "adds no other restrictions" (know what happens by default). At 2.1.287 it still skips every classic hook a user mod registers. I loaded a --plugin-dir mod whose classic.Stop hook writes a file and whose classic.PreToolUse hook writes a file and denies Bash, then had Haiku run echo hi. Neither file was written, the model reported "The command printed: hi", and the log said:

cc-plugin-sec-default@builtin seated outermost: this machine has managed settings
classicprobe: classic.PreToolUse bypassed by cc-plugin-sec-default (tier user); beneath runs
classicprobe: classic.Stop bypassed by cc-plugin-sec-default (tier user); beneath runs

That's one managed machine. I haven't run a user mod's classic hooks where the guard doesn't load.

An organisation that sets prependPlugins owns that tier and can list the mod in it or leave it out. Since 2.1.285 its name is cc-plugin-sec-default@builtin. The prependPlugins description in the 2.1.286 binary still says to list it as sec-default@builtin, "the id every release reads", for as long as any machine in the organisation may run a release from before the rename, and that a release that knows the new id reads either. I haven't tested either spelling in prependPlugins.

At 2.1.286 it also has two switches an organisation sets in managed settings, under pluginConfigs["cc-plugin-sec-default@builtin"].options. With allowManagedModsOnly on, it refuses every user tier plugin at plugin.register, with mods are limited to your organization's by policy (allowManagedModsOnly); <plugin> was not loaded, and if its own check fails before deciding, the plugin is refused anyway. That check is the judged by cc-plugin-sec-default: admitted line in the census on the anatomy page: it's sec-default reading the policy and letting the plugin in. The other one works the opposite way round. The deny rules in the person's settings hold over installed plugins unless allowModsToOverrideDenyRules is true. When a user or prepend plugin's tool.check answer is looser than the one beneath it and a deny rule matches, the rule wins, and the person sees <plugin> tried to lift a deny rule in your settings from a <tool> call (<rule>); the deny rule holds over the plugins you install (allowModsToOverrideDenyRules). If that check throws, a loosened answer becomes the deny rules in your settings could not be checked for this call, so it is refused. And with a managed allowedMcpServers list, a user plugin's $.tool.register gets allowedMcpServers (managed): plugins outside policy may not add tools. All of this paragraph is read from the 2.1.286 bundle, and I haven't triggered any of it.

Inside the user tier the order comes from dependencies, sorted so a plugin sits outside the ones it depends on. At 2.1.287 I passed innerb first and outera, which lists innerb under dependencies, second, and the log still put outera outside: hooks module order: dependencies register cc-plugin-sec-default@builtin, outera@inline, innerb@inline, cc-plugin-agents-md@builtin, cc-plugin-telemetry@builtin. There's no priority number and a hook can't move itself. Anthropic's position in the issue thread is that ordering belongs to the dependency graph, and a plugin doesn't get to decide how important it is.

The organisation's tiers are ordered by hand. Mods in prependPlugins and appendPlugins run in the order the list gives them (Anthropic's settings table). Inside one module, hooks on the same event run in the order register called on, and calling on twice for one event with no matcher fails the load with on("session.start") is registered twice without a matcher (filter which events a hook handles). I've taken both from Anthropic's pages and haven't run either.

next

next is the rest of the chain below you, handed over as a function. The declarations describe it well enough to just quote:

The rest of the chain, as one hook receives it: made once per dispatch per hook, frozen… Each call runs the hooks below again; core is the last, and below it next rejects. Called with no argument it rejects, naming the hook. A hook that returns without calling it ends the chain; returning nothing is a failure.

Four things follow from that, and three of them will bite you.

next(e) always needs an argument. next() rejects. You pass the event down, changed or as you got it.

Calling it twice runs everything beneath twice. That's supported on purpose. poteat in the thread: "completely supported; for example if you want to do a retry, backoff, etc." On tool.call it means the tool runs twice.

Not calling it ends the chain right there, and your return value is the answer. On tool.call that's how you stop a tool.

Returning nothing is a failure, and a failure doesn't stop anything. The second half of this page is about why.

The two axioms

The cheat sheet pairs these, and between them they cover what next means on any event:

core has a side effect: no next = it did not happen, next twice = it happened twice

core has no side effect

On tool.call the side effect is running the tool. That leads straight to the most misunderstood thing in this runtime, so here it is in Anthropic's words:

The return of 'deny' here is not somehow making the tool not get called. To make the tool not get called, don't call next(e). When you hook onto tool.call, you are responsible for calling the tool.

The engine's whole check is const approved = result.deny === undefined;. It reads your result, tells the model what happened, and has no way to undo a tool that already ran. So this writes the file and then lies to the model about it:

on('tool.call', { tool: 'Write' }, async ($, e, next) => {
  const r = await next(e)                       // the file is on disk now

  if (isSecret(e.file_path)) return { deny: "not allowed" }   // too late

  return r
})

A real guard returns before it touches next:

on('tool.call', { tool: 'Write' }, async ($, e, next) => {
  if (isSecret(e.file_path)) return { deny: "not allowed" }

  return next(e)
})

On tool.call the settings-file hooks from managed settings run before any of this, and if one of them denies, that deny is the call's result. If a mod rewrites the call, those managed hooks run again on the rewritten one. PreToolUse hooks from every other settings file and from plugins run after the last mod calls next, as part of core, so a guard like the one above that answers without calling next keeps them from running at all. That's from Anthropic's where settings hooks run in the order, and I haven't tested the rerun.

At 2.1.286 the model gets your deny reason as an error result, <tool_use_error>denybash: echo is not allowed here</tool_use_error> in my run, and the debug log says answered tool.call without next() ... nothing beneath it ran for this dispatch. A deny from a classic.PreToolUse hook lands differently. The call resolves as an error whose text is Error: and the reason, and a deny with an empty reason reads Error: Hook PreToolUse:Bash denied this tool. I got those two from the test kit at 2.1.286, where { deny: "" } resolved to { result: "Error: Hook PreToolUse:Bash denied this tool", text: "Hook PreToolUse:Bash denied this tool", isError: true }. I couldn't see them in a live session, because on my managed machine cc-plugin-sec-default bypasses a user mod's classic hooks, at 2.1.287 as well, so treat the live wording as unverified.

On an event with no side effect, like tool.describe or prompt.section, none of this matters and calling next twice is just two evaluations.

next.to

next.to(e, tier) skips down to a tier instead of the hook directly beneath. It's only for managed plugins, TargetTier = Exclude<Tier, 'prepend' | 'user'>, and the jumps you can make are narrow.

Your tier You can jump to
prepend append, builtin, core
append core
user nothing, next.to is refused
builtin nothing useful

Anthropic's reference page says next.to skips to a later tier, append, builtin or core, which would let an append mod jump to builtin as well (the hook function). The declarations' TargetTier allows it too. My table came from reading sec-default, and I haven't run an append mod to see whether that jump is refused, because it needs managed settings, so treat the append row as unverified.

If you're writing a mod you install yourself, this section is just background. sec-default's README spells out what happens: load it with --plugin-dir and it sits in user, where its next.to calls are refused, so all it can do is pass things through.

The interesting use isn't skipping at all, it's asking two questions at once. sec-default's tool.list hook does both:

const [policyTools, allTools] = await Promise.all([
  next.to(e, 'append'),    // the list without the user tier's additions
  next(e),                 // the list with them
])

Then it reconciles the two. It's the clearest demonstration that next is a continuation you can call, the same chain evaluated twice from two depths at the same time.

next.origin

{ plugin: string, tier: Tier }, the link that raised this dispatch. plugin reads "engine" when the engine raised it and "client" when a Client surface module posted a ui.message.

Only the host sets it, and in the declarations' words "nothing a plugin writes reaches it". You can't spoof it and next.to keeps it intact, so it's the thing to check when your hook needs to decide whether to trust whatever called it.

next.trace

After you await next(e), next.trace has one entry per link below you, with its plugin, tier, the event it saw, what it returned, its own time and an outcome. Only the latest next() call is traced, so a retry overwrites the first attempt. On a streaming event each entry also counts the chunks it yielded.

The seven outcomes are the whole failure model written as data:

Outcome Meaning
returned Its result stood
passed It returned, by reference, what its last next() resolved to
skipped It failed before calling next, or a next.to above jumped past its tier, and what's below ran in its place
kept It failed after calling next, and that run's result stands
expired Its budget ran out
caught It threw or ran out of time, and its .catch handler's result stands
rejected The link rejected. The deepest one is where the rejection started

ms is the hook's own time with next() taken out, so the numbers add up instead of nesting. A skipped entry that was jumped over has reason set to bypassed by <plugin>.

kept against skipped is the one to remember. Fail before you've called next and the chain carries on without you. Fail after and your work stands, you only lose your change to the answer.

next.signal and next.budget

next.signal is an AbortSignal for anything long-running. It aborts for three reasons: the person interrupted, a hook above yours already settled, or you ran out of budget. The second one is easy to forget, and it's why a hook doing background work should listen to the signal instead of assuming it owns the dispatch. After the signal fires a hook gets 5,000 ms to wrap up before it's reported as lingering, though the dispatch has already moved on without it.

next.budget tells you how much time you have, as { ms, remainingMs }, read fresh every time you touch it. The clock only counts your own code. It stops while a next(e) or any $ call is in flight (a $.clock wait is the exception), so a slow chain below you or a minute-long $.model.complete costs you nothing:

if (next.budget.remainingMs < 2_000) return next(e) // skip the slow path

session.end is the exception. Claude Code wants exit to be fast whatever's loaded, so the whole chain shares one short wall-clock limit, 1.5 seconds for every session.end hook together in Anthropic's limits table, the clock never stops, and each hook starts with whatever the hooks before it left over.

What failure actually does

A hook fails in one of three ways:

  • It throws.
  • It runs past its budget, 10,000 ms of its own time by default.
  • It returns the wrong shape, and returning nothing counts.

It can also hang, which is its own thing. A hook that spins without ever yielding wedges the whole worker, and by default the engine notices after 5,000 ms with no answer to a heartbeat within 5000ms: the hooks worker is wedged (a hook spinning without yielding).

Installed mods share that one worker, and Anthropic's troubleshooting page says what happens next. When Claude Code can trace a crash or a wedge to one mod it unloads that mod with <mod> was unloaded: it crashed the hooks worker. After three crashes it can't trace to a mod, it turns off every mod that isn't built in, the organisation's included, until someone runs /reload-plugins (it crashed the hooks worker, and off for this session). I haven't triggered either.

Every one of them fails open. The hook is skipped and everything beneath it runs in its place. I measured it five ways at 2.1.272, using a marker file on disk to prove whether the tool actually ran, re-ran the throw and the hang at 2.1.283, and all five again at 2.1.286, with the same result:

The hook does Did the tool run?
return next(e) yes, the control
throw new Error("deliberate hook failure") yes
await new Promise(() => {}) yes, after a 10 s stall
return { deny: "" } no
return { deny: "<a reason>" } no

The engine says so at the time. Since 2.1.280 the debug line leaves out your error message, and the reason goes to the plugin author instead, in a line of its own that names the mod. Where that line shows up depends on the session. Anthropic's troubleshooting page says it's stderr for a claude -p run with --plugin-dir, a dim line in the transcript for an interactive session that hot-reloads a plugin directory, and the debug log alone for a mod you installed from a marketplace. My runs were -p with --plugin-dir, which is why I read it on stderr. The first two lines read the same at 2.1.286:

[ERROR] hook failed: auditone: errorKind=Error errorChars=23 (tool.call; skipped; what is below it ran in its place)
auditone: tool.call hook skipped: threw Error: deliberate hook failure
audittwo: tool.call hook skipped: ran past its 10s budget

A hook that fails after calling next gets skipped; its last next() run's result stands instead.

So the onion doesn't work as a fence. A security guard whose hook throws is an absent gate, and the only trace is a line nobody is watching. The gotchas page has the code for this, and it's the first thing to read before you ship a guard.

The census is what saves you. Every hook that runs gets a settle line, whatever happens to it:

hooks module armR tool.call settled in 238.3ms (worker hop, next() included)

So a missing hook shows up as a missing line. If you're auditing whether a guard ran, you're looking for a line, and you don't have to read between them.

.catch

.catch lets a failing hook decide what happens instead of just vanishing. It hangs off the registration:

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 })

That's the handler from the cheat sheet, and it reads like a sentence. If the hook already called next, let that result stand. If it never got that far the check didn't finish, so deny and say why. Three lines and the guard fails closed.

The handler gets Caught:

export type Caught = { readonly error: HookFailure; readonly called: boolean };
export type HookFailure = { readonly kind: 'throw' | 'timeout'; readonly message?: string; readonly budget: number };

It gets a fresh 1,000 ms of its own and the same next, and that next is safe to call again. In the declarations' words, when called is true next(e) resolves to what the hook's last call settled to, nothing beneath running again, the argument unread, and when it's false it runs the hooks beneath once and any later call replays that. So a handler can't double a side effect that already happened. If the handler itself runs out of time the hook is treated as if it had no handler.

In an ordinary hook next.error is undefined, so a shared helper can check it to tell whether a handler is running.

Three things throw outright:

  • A second .catch on the same registration.
  • A .catch attached after register has finished. The engine does wait for an async register now, but attach every .catch before your first await to be safe.
  • A .catch on engine.create, which has no budget because its failure is the load's failure.

.catch returns void, so it doesn't chain and there's no second handler. A handler returning undefined means the hook is absent, which is just the fail-open default written out by hand.

Serial, and why

Function hooks on one event run strictly one after another. At 2.1.260, eight function hooks each sleeping 300 ms took 2,427 ms, and eight settings-file command hooks doing the same took 640 ms because those run in parallel.

That's what the onion costs. B can't start before A decides to call it, since A might not.

The overhead itself is small. Twenty-five pass-through function hooks added 5 ms over none, while one command hook sleeping 50 ms turned a 15 ms dispatch into 166 ms, because a settings hook launches a process and a function hook is a function call.

You can get some concurrency inside your own hook by not awaiting straight away:

const beneath = next(e)              // starts now
const verdict = await checkPolicy($, e)

return verdict.ok ? await beneath : { deny: verdict.why }

Don't do this on tool.call. I'm only showing it so you recognise it. Starting next(e) starts the tool, so by the time the policy check says no, the thing you were guarding against may already have happened. poteat's point in the thread was that you can't run guards over tool.call in parallel because its core is the action. On an event whose core has no side effect the pattern is fine, and it's the only way to overlap work.

Streaming

Two events stream, still, in the 2.1.286 declarations. turn.step is the model's response arriving in pieces, and process.spawn is a child process's output, since $.process.spawn is a $ call and so an event like every other. Hooks on either are async generators:

export type HookStream<C, R> = AsyncGenerator<C, R> & { readonly result: Promise<R> };

yield* next(e) forwards every chunk and evaluates to the result. A hook that wants to change the stream reads for await (const chunk of stream) and then await stream.result. Each call runs what's beneath from scratch, same as everywhere else. On turn.step the chunks cover text, thinking, tool use, input, stop and engine chunks, so a hook can watch the model write a turn and rewrite it as it goes.

The budget works differently here. A streaming hook's clock only runs while its own code runs, never while it's waiting at a yield. On turn.step that's summed over the whole response, and on process.spawn it starts again at every chunk.

Recursion

A hook never sees the dispatches it raised. Its own $ calls, its next, a subagent it spawned, none of them come back round to it, while other plugins' hooks see all of them, from beneath as well as from above.

Anthropic's reference page says a hook on a mods API call "intercepts calls from the mods that run after it" (mods API calls), and its API page says a mod earlier in the chain can observe your call. Read plainly, only mods outside you would see it. At 2.1.287 a mod inside sees it too. I loaded outera and innerb, with outera listing innerb under dependencies so it runs first. Each hooks store.get and writes a file naming whose call it saw, and each calls $.store.get at session.start. All four files appeared, innerb-saw-outera.txt among them, and the log showed the inner mod's hook running inside the outer mod's call:

hooks module outera@inline, innerb@inline: store.get nested in outera#1
$.fs.write (innerb): /tmp/implB-lee/out/innerb-saw-outera.txt (5 bytes)

At 2.1.283 the guard worked per plugin. Anything a plugin's own code raised skipped every one of that plugin's hooks, and that reached nouns too, so a plugin that added a noun through engine.create couldn't use its own noun from any hook. The log line read:

hooks module audit audit.record skipped: re-entry (the plugin's own code raised it; origin ...)

At 2.1.286 it works per hook. A hook still never sees what it raised itself, but another hook in the same plugin does. I ran a plugin with a session.start hook that writes one file and an fs.write hook that writes a second file whenever it sees the first. The fs.write hook saw the session.start write. Its own nested write landed without coming back to it, and the log said:

hooks module reentry@inline fs.write skipped: re-entry (its own frame is being dispatched; origin reentry#1 > reentry#0)

Nouns come with it. I ran a plugin whose engine.create hook returns { ...beneath, audit }, with record calling beneath.fs.write(...) directly, and whose session.start hook calls $.audit.record(...). The write landed, and the log said $.audit.record (selfnoun -> selfnoun) answered in 55.6ms. The whole-plugin line from 2.1.283 is still in the 2.1.286 bundle, next to the per-hook one, and I haven't seen what raises it now.

The loader still refuses one shape. Hand beneath to a helper, as in record: (entry) => writeRow(beneath, entry), and claude plugin validate fails at 2.1.286 with the value of next(e) at engine.create is passed as an argument. Call beneath's methods inline instead.

Passing $ itself to a helper works only when the helper is a function declared at the top level of the same file, which matches Anthropic's create page. At 2.1.286 validate accepted a makeHost($) declared that way and reported calls: $.command.register (via makeHost), and in a live run the call through it reached the engine and got the engine's normal answer. At 2.1.287 I moved the same kind of makeHost into ./host.js, imported it, and validate failed:

register.js:5: $ is passed to "makeHost", imported from "./host.js": $ is followed only into a function declared in this same file, never across an import

The same helper declared in register.js itself passed with calls: $.ui.log (via makeHost).

The diff mod adds its own guard on top anyway. Its prompt.submit hook keeps a carrying flag and returns early if it's already handling that prompt, even though the engine's guard covers it. It's cheap, and it means you can see the rule right there in the hook.

Feedback