Sweep 19 Sep 2026 · 02:36Z Build v2.1.278 500 read Stable v2.1.267 Latest v2.1.278 Next v2.1.278 Feeds RSS JSON llms.txt Unofficial
Mods · 03 of 10

Tiers, order and next()

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

Every hook on an event composes into one function, and the composition is a fold. The cheat sheet draws it three ways because people keep reading it as a filter chain, which it is not:

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

A is outermost. It receives the event first, and whatever it returns is the answer. Everything beneath it, including core, runs only because A called next. On the way down each link may refine the question; core answers it; on the way up each link may refine the answer. Earlier registration wraps more, so position is authority.

The five tiers

Ordering between plugins is by tier, and the declarations state 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 Your mods Middle.
append Organisation policy, last Beneath every user plugin.
builtin Anthropic's own mods Beneath that.
core The engine Innermost. Does the thing.

Two tiers are managed-only and a plugin you install lands in user. On a managed machine, or a Team or Enterprise plan, sec-default sits in prepend, which is why a user plugin on such a machine cannot touch classic hooks, prompt sections, settings reads, or an organisation-provided tool's description. An organisation that sets prependPlugins owns that tier and may list sec-default@builtin there or leave it out.

Within a tier, ordering is declared through dependencies and topologically sorted. A hook cannot move itself in the chain, and there is no priority number. Anthropic's position on this, from the issue thread, is that ordering is a property of the graph rather than of a plugin's opinion of its own importance.

next

next is the rest of the chain beneath you, reified as a function. The declarations describe it precisely enough to 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 fall out of that, and three of them bite.

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

Calling it twice runs everything beneath twice. This is supported and intended. poteat's words in the thread: "completely supported; for example if you want to do a retry, backoff, etc." On tool.call that means the tool runs twice.

Not calling it ends the chain there, with your return value as the answer. On tool.call that is how you stop a tool.

Returning nothing is a failure, and a failure is not a stop. That distinction is the subject of the second half of this page.

The two axioms

The cheat sheet pairs them, and together they are the entire contract for what next means on any given 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, core's side effect is running the tool. Which makes the most misunderstood thing in this runtime unavoidable, so here it is in Anthropic's own words rather than mine:

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 test is const approved = result.deny === undefined;. It reads the result, tells the model what happened, and has no way to un-run a tool that already ran. So this code 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
})

The guard version returns before touching next:

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

  return next(e)
})

On an event whose core has no side effect, tool.describe or prompt.section, none of this applies and calling next twice is just two evaluations.

next.to

next.to(e, tier) skips down to a tier instead of to the link immediately beneath. It is managed-only: TargetTier = Exclude<Tier, 'prepend' | 'user'>, and the reachable jumps are narrow.

Your tier You may jump to
prepend append, builtin, core
append core
user nothing. next.to is unusable.
builtin nothing meaningful

If you are writing a mod you install yourself, this whole section is background. sec-default's README says the consequence plainly: loading it with --plugin-dir seats it in user, where its next.to calls are refused, so it becomes a plugin that can only pass.

The interesting use is not skipping. It is asking two questions. sec-default's tool.list hook calls 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. Nothing else in the runtime demonstrates so clearly that next is a continuation rather than a control-flow keyword: the same chain, evaluated twice, from two different depths, concurrently.

next.origin

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

It is set by the host alone and, in the declarations' words, "nothing a plugin writes reaches it". You cannot spoof it, and next.to preserves it. That makes it the identity to check when your hook is deciding whether to trust the thing that called it.

next.trace

After you await next(e), next.trace holds one entry per lower link with its plugin, tier, the event it saw, its result, its self time, and an outcome. Only the latest next() call is traced, so a retry overwrites the first attempt's trace.

The seven outcomes are the whole failure model expressed as data, and they are worth a table:

Outcome Meaning
returned Its result stood.
passed It returned, by reference, what its last next() resolved to.
skipped It failed before next, or a next.to above continued beneath its tier, and what is below ran in its place.
kept It failed after next, and that run's result stands.
expired Its budget ran out.
caught It threw or overran, and its .catch handler's result stands.
rejected The link rejected. The deepest such entry is where the rejection came from.

ms is self time with next() taken out, so the numbers sum rather than nest. When an entry is skipped because something jumped over it, reason reads bypassed by <plugin>.

The kept and skipped split is the one to internalise. Failing before you called next means the chain runs without you. Failing after means your work stands and only your refinement is lost.

next.signal

An AbortSignal to pass into anything long-running. It aborts for three reasons: the person interrupted, a hook above yours settled first, or you ran out of budget. The second one is easy to forget and it is the reason a hook doing its own background work should honour the signal rather than assume it owns the dispatch.

What failure actually does

Three things make a hook fail, and there is no fourth:

  • It throws.
  • It overruns its budget. 10,000 ms by default.
  • It returns the wrong shape, including returning nothing.

There is a fourth way to break, which is not failure but hanging: a hook that spins without yielding wedges the worker, and the engine notices at 5,000 ms with no answer to a heartbeat within 5000ms: the hooks worker is wedged.

All of them fail open. The hook is skipped and everything beneath it runs in its place. I measured this five ways at 2.1.272, with a marker file on disk as ground truth for whether the tool actually ran:

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 as it happens, and the wording is not ambiguous:

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)

The onion is not a fence. A security guard whose hook throws is not a closed gate, it is an absent one, and the only trace is one dim line in a debug log nobody is tailing. This has its own section on the gotchas page, with the code, because it is the single most consequential thing on this site.

The census is the redeeming feature. Every hook that starts gets a settle line, whatever happens to it:

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

So absence is observable. If you are auditing whether a guard ran, you are looking for a line rather than reading between them.

.catch

.catch is how you make a failing hook decide its own fate instead of 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 handler is the one on the cheat sheet and it reads as a sentence. If the hook already called next, let that stand. If it never got that far, the check did not complete, so deny and say why. A guard with those three lines fails closed.

The handler receives Caught:

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

It runs on a grace budget of its own with the same next, and that next is replay-safe. The declarations: when called, next(e) resolves to what the hook's last call settled to, nothing beneath running again, the argument unread; when not, it runs the hooks beneath once and a later call replays. So the handler cannot accidentally double a side effect that already happened.

Inside an ordinary hook, next.error is undefined, so its presence is how a shared helper knows a handler is running.

Three things throw rather than failing gracefully:

  • Two .catch on one registration.
  • .catch attached after register has returned. This is why an async register is a bad idea.
  • .catch on engine.create, which has no budget at all, because its failure is the load's failure.

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

Serial, and why

Function hooks on one event run strictly in series. Measured at 2.1.260: eight function hooks each sleeping 300 ms settle in 2,427 ms; eight settings-file command hooks doing the same settle in 640 ms, because those run in parallel.

That is the price of the onion. B cannot start before A decides to call it, because A might not.

The overhead itself is small. Twenty-five pass-through function hooks add 5 ms over having none. One command hook sleeping 50 ms turns a 15 ms dispatch into 166 ms, because a settings hook is a process launch and a function hook is a function call.

You can get concurrency inside your own hook by not awaiting immediately:

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

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

This is wrong on tool.call and I am 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 has happened. poteat's framing: you cannot parallelise guards over tool.call, because its core is the action. On an event whose core is pure the pattern is fine and it is the only way to overlap work.

Streaming

One event streams, turn.step, and it is the only one the type system lets you yield from:

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

yield* next(e) forwards every chunk and evaluates to the result. Each call runs what is beneath afresh, same as the non-streaming case. The chunk union covers text, thinking, tool, input, stop and engine chunks, so a hook can watch the model produce a turn token by token and rewrite as it goes.

Recursion

A hook never sees the dispatches it raised. Its own $ calls, its next, the subagent it spawned: none of them come back round to it. Its sibling hooks see all of them, and so does everyone else.

The guard is per plugin rather than per hook. That is the part with a real consequence. A plugin providing a noun through engine.create cannot consume its own noun, anywhere, in any hook:

audit.record skipped: re-entry (its own frame is being dispatched; origin auditprobe-c)

Since the loader also refuses passing $ to a shared helper, a plugin that both provides and uses a capability ends up with two copies of the implementation, one behind the noun and one inlined. That is a genuine sharp edge and there is no way around it today.

The diff mod belts and braces this anyway. Its prompt.submit hook keeps a carrying flag and returns early if it is already handling the same prompt, even though the engine's own guard covers it. Cheap, and it makes the invariant local.