Skip to main content

Hooks and the Hook Chain

A hook is a function that Claude Code calls when an event happens. You register it with on. It can watch the event, change it, or answer it. The events themselves are on the page of their namespace. See the Mod API Overview for the map.

Remove the on call to turn a hook off. disableAllHooks stops installed mods.

Official pages: React to Events, Mods Reference, Mods Overview.

Example​

This mod refuses every Bash command that contains rm -rf.

hooks/register.js
export function register(on) {
on('tool.call', { tool: 'Bash', command: /rm -rf/ }, async () => ({ deny: 'No rm -rf here.' }))
}

Entry Point and Registration​

ItemStatusWhat it doesHow it worksWhere it can be used
register(on, options)DocumentedThe entry point. The module exports it. on registers hooks. options holds the userConfig values with defaults filled in.Runs once when the module loads, and again after a reload. Its return value is dropped. A promise is awaited. $ does not exist yet.The hooks module named in hooks/hooks.json.
on(pattern, hook)DocumentedRegisters hook on the events that pattern selects.pattern is an event name, a glob (*, classic.*), or a negation (!tool.describe). A repeat registration throws. A plugin's registrations nest in order, first outermost.Inside register only.
on(pattern, matcher, hook)DocumentedThe same, narrowed by a matcher. A matcher is a partial of the event input.A leaf matches by === or by RegExp. An array means any of. An object is a partial of an object. Example: { tool: 'Bash', command: /^p4 / }.Inside register only.
on(...).catch(handler)DocumentedSets the handler that runs when the hook throws or overruns its budget.A second .catch on one registration throws. So does one after register returned. An answer from the handler within the grace time stands as the hook result.Inside register only, on the returned registration.
Pattern * and negationDocumented* selects every event. !x selects every event except x.A telemetry event is not selected by * or by a negation for an installed plugin. Hook it by name or with telemetry.*. A glob hook types e as the union of the selected events.Inside register only.

Arguments of a Hook​

A hook is async ($, e, next) => result. Hooks on turn.step and process.spawn are async generators.

ItemStatusWhat it doesHow it worksWhere it can be used
$DocumentedThe Mod API object. Frozen. Holds the core nouns and every noun that the engine.create steps added.Write each call in full, as in $.fs.read('notes.md').Every hook and every closure a hook drew.
eDocumentedThe event input. Deeply frozen plain data.To change it, pass a copy to next, as in next({ ...e, text }). A field marked pinned in the type is refused when changed.Every hook.
next(e)DocumentedThe rest of the chain, as middleware. It runs the hooks beneath, then the engine. It resolves to the event result.Called with no argument it rejects. A hook that returns without next ends the chain. Returning nothing is a failure. On a streaming event (turn.step, process.spawn) it returns a HookStream.Every hook.
next.to(e, tier)DocumentedContinues the dispatch at a later tier (append, builtin, or core). The links between are skipped.Managed hooks only. prepend may name append, builtin, or core. append may name core. No user hook skips the organization.Mods in prependPlugins or appendPlugins.
next.signalDocumentedAn AbortSignal that aborts when the dispatch is abandoned.It aborts when the user interrupts, when a hook above settled first, or when this hook ran out of budget. Pass it to $.clock.sleep and other waits.Every hook.
next.originDocumented{ plugin, tier } of who raised the dispatch. The engine is { plugin: 'engine', tier: 'core' }.Set by the host. A plugin cannot write it. Every hook of one dispatch sees the same origin.Every hook.
next.is(pattern, e)DocumentedA type predicate. It says whether this dispatch event is selected by pattern.Narrows e for a hook on a glob or on *. Under a matcher the narrowing includes it.Glob and * hooks.
next.eventDocumentedThe name of the dispatch event, as a value.Use it in a glob hook to log or switch on.Every hook.
next.traceDocumentedA list of TraceEntry: what settled beneath on the latest next() call.One entry for each link beneath, nearest first. Empty before next is called. Frozen data.Every hook.
next.budgetDocumented{ ms, remainingMs }: the time limit of the hook and what is left.Read fresh at each access. It stands still while a next or $ call is in flight. In a .catch handler it is the grace time. At session.end it reads the exit bound, which never stops.Every hook.
next.errorDiffersIn a .catch handler only. Why the hook failed: kind, message, budget.The docs add the kind re-entry (the event came from a call by that hook or by your mod's code outside its hooks) and next.error.cause..catch handlers.
next.calledDocumentedIn a .catch handler only. True when the hook had called next.In a handler next is replay-safe. When called is true, next(e) returns what the last call settled to and runs nothing again..catch handlers.

Failure Behavior​

A hook that throws, overruns its budget, or returns a wrong shape is skipped. The hooks beneath and the engine run in its place, or its last next result stands. So a guard fails open unless its registration carries a .catch that refuses in its place.

Since 2.1.304, when a plugin that runs first skips one of your mod's hooks, the transcript shows a line naming the event and the plugin: its <event> hook was skipped. "<plugin>" runs first and sent the event on past it. (the program's text). The type file gives this advice at each event where an answer can refuse.

EventStatusWhat happens when the hook failsThe advice
tool.callDocumentedThe tool runs.Add .catch(($, e, next) => next.called ? next(e) : { deny: "no" }).
tool.checkDocumentedThe verdict beneath stands.Add a .catch that refuses.
ui.resolveDocumentedThe hook is skipped.None.
ui.faultDocumentedA Client born in that redraw that fails is heard too, and the engine draws nothing again for it.To go on, ask with $.ui.invalidate.
ui.focusDocumentedThe focus stays where it was.next(e) lands the focus on e.element and redraws.
agent.offerDocumentedThe offer passes through.None.
agent.spawnDocumentedThe subagent starts.Add .catch(($, e, next) => next.called ? next(e) : { deny: "no" }).
prompt.submitDocumentedThe prompt goes through.Add .catch(($, e, next) => next.called ? next(e) : { drop: "no" }).
prompt.mentionDocumentedThe file is read.Add .catch(($, e, next) => next.called ? next(e) : { deny: "no" }).
command.runDocumentedThe command runs.Add .catch(($, e, next) => next.called ? next(e) : { text: "no" }).
command.describeDocumentedThe description passes through.None.
config.setDocumentedThe row is written.Add .catch(($, e, next) => next.called ? next(e) : { deny: "no" }).
config.describeDocumentedThe row passes through.None.
skill.promptDocumentedThe text passes through.None.
attribution.textDocumentedThe text passes through.None.
plugin.registerDocumentedThe module joins.Add .catch(($, e, next) => next.called ? next(e) : { refuse: "no" }).
turn.completeDocumentedThe answer stays as it was.None.
engine.createDocumentedIt fails, and its plugin unloads, when it replaces another plugin's noun, in user also past its own stub, or adds a seated name.None.

Budgets​

ItemStatusWhat it doesHow it worksWhere it can be used
Hook time per eventDocumented10 seconds of the hook's own code.The clock stops during next and $ calls, but a $.clock.sleep counts. HookBudget.ms is 10000. A streaming hook counts its own code only, never a yield or a read beneath.Every hook.
prompt.edit hook timeDocumentedThe docs say 50 milliseconds.HookBudget has one ms value. We could not find the 50 millisecond value in the type file.prompt.edit hooks.
.catch graceDocumented1 second.HookBudget.catchMs is 1000. The handler gets a fresh budget..catch handlers.
Linger after abortDocumented5 seconds.HookBudget.lingerMs is 5000. Past it the hook is reported as lingering. The dispatch had gone on without it.Every hook.
session.end hooks togetherDocumentedThe SessionEnd hook budget, 1.5 seconds by default.The whole chain shares one short wall-clock bound that next.budget reads.session.end hooks.
Not Verified

The docs set the prompt.edit limit of 50 milliseconds. We did not check it in the program.