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 · 05 of 10

The event catalogue

Every event you can hook, what each one carries, and how a matcher narrows it.

Read against 2.1.286 Anthropic's page: React to events with a mod 21,129 characters 7 sections events.json, paged

This page is the map of every event at 2.1.286. The exact shape of each one is in the declarations, which Claude Code writes into .claude-plugin/types/ beside your mod each time it loads it (Anthropic's create page lists the files). The /plugin-types command that used to write them on request is gone at 2.1.287. At 2.1.286 they're 14,711 lines, which is too much for a web page, and a lot of that is tool input shapes you already know from using Claude Code.

There are 43 engine events in the 2.1.286 declarations, which the engine raises itself, and every verb on $ is an event too. Four are new since 2.1.283: telemetry.log and telemetry.mark arrived in 2.1.284, session.append in 2.1.285 and prompt.compose in 2.1.286. They're all in the tables below.

Engine events

Tools

Event When Core's side effect
tool.call A tool is about to run Yes, running it
tool.check Permission is being decided No
tool.describe The tool's description is being put together for the model No

tool.call is the one with a side effect, so it's the one where not calling next is how you stop something. tool.check lets you have a say in the permission decision without owning the call, and it's the safer place for a policy that only wants a say. Its arguments are { tool, input, tool_use_id } and it answers { decision: 'allow' | 'ask' | 'deny', reason?, rule? }.

tool.call hands you the tool's input flat, where tool.check nests it under input. A Bash call's command is e.command, and at 2.1.286 a hook that read e.input.command threw TypeError: undefined is not an object (evaluating 'e.input.command').

To stop a call, return { deny: reason } from tool.call without calling next. At 2.1.286 the model got the reason as an error result, <tool_use_error>modsA tool.call says no</tool_use_error> with is_error: true, and an empty reason gave it <tool_use_error></tool_use_error>. 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 no reason reads Error: Hook PreToolUse:<tool> denied this tool, a wording the 2.1.285 bundle added and 2.1.286 still has. The test kit on the testing page produces that at 2.1.286. A tool.call deny never did. I couldn't see a classic deny in a live session either, because on this managed box cc-plugin-sec-default bypasses an installed plugin's classic hooks (more under classic.* below), and still did at 2.1.287, so the live wording is unverified.

A tool.call raised inside a subagent carries agentId, and $.agent.list() turns that into a name, a parent and a type. Which plugin raised something and which agent loop it's in are two separate questions.

Prompts and turns

Event When
prompt.edit The person edits the prompt box, a key or a paste. New in 2.1.283.
prompt.submit A prompt is being sent
prompt.fill Something is writing the prompt box
prompt.suggest Dim suggestion text is being proposed into it
prompt.section A section of the system prompt is being built
prompt.compose The whole system prompt is being put together from its sections. New in 2.1.286.
prompt.context Context is being attached to a prompt
prompt.attachment The engine is injecting a message of its own for the model, a reminder or a mentioned file. New in 2.1.283.
turn.start A turn begins
turn.step The model is producing a step, streamed
turn.complete A turn settles, and usage numbers become real

On prompt.submit, e.context is string[] | undefined and it only adds. What you add gets appended and doesn't replace anything. The result can also be { drop: reason }. The diff mod budgets what it adds against a character limit, minus whatever other plugins already added, and when its part won't fit it drops its own part and keeps the prompt, then tells the person through $.ui.status. Any mod that attaches context should work the same way.

prompt.edit fires on every edit, with the draft before it and the splice (start, end, inputText). A burst of keys counts as one edit. You can rewrite inputText on the way down or the box on the way up, and returning { text: e.text, cursor: e.cursor } without calling next swallows the edit.

prompt.attachment fires once for each message the engine injects by itself. Return { text: null } to leave one out. The answer is remembered per attachment for the whole process, so it's asked again only on resume or after $.ui.invalidate('prompt.attachment'), and the transcript still keeps the engine's own copy.

prompt.section's answer is cached too. At 2.1.286 my hook on env_info_simple ran once across a three-turn session, and every request carried its first answer.

prompt.compose gives you the whole system prompt from next(e) as a list of sections, each { id, text, scope }, in the order they're sent. The declarations say you can add, replace, reorder or drop them, or skip next and write the list yourself. I measured adding a section, and a list in the wrong order being thrown out. It runs every turn. At 2.1.286, though, only the first answer of a session reached the API in my runs. Anthropic's events page says changed section text invalidates the prompt cache (rewrite or add to a prompt), which suggests a changed answer gets sent, and I haven't re-run mine at 2.1.287 (more under "Events you can ask to rerun" below). Gotcha 10 has that and the cache rules, so read it before you ship one.

Sessions and agents

Event When
session.start A plugin loads into a session. Where most mods set themselves up.
session.receive Something arrives in the session
session.send A plain-text message is leaving for another agent or session. New in 2.1.283.
session.measure Usage moved, after a turn or when a rate limit moves a whole point. New in 2.1.283.
session.compact The transcript is being compacted
session.attach A surface attaches
session.detach A surface detaches
session.end The session ends. New in 2.1.283.
session.append A row is about to be stored in a conversation: a prompt, a response block, a tool result, a notice. New in 2.1.285.
agent.offer A subagent is being offered to the model
agent.spawn A subagent is being started

session.start fires once per process for each plugin, before the first prompt. After that it fires again only when that plugin loads fresh: it's enabled, the worker restarts, or a hot reload touches its module. It never fires for /clear. The first one is awaited, so a tool you register there is listed by turn one.

session.end fires once on exit, /clear, resume, logout, a signal or the end of a -p run, after the settings-file SessionEnd hooks, and e.reason says which. The whole chain shares one short time limit so quitting stays fast, and the lifecycle page explains how that works.

session.measure is how you watch usage without polling. It hands you the same figures $.session.usage() would, pushed as they change, so you compare them with your own threshold there.

session.append fires for every row a conversation keeps, before it's stored, main conversation and subagents alike. e carries message (type, name, role, isMeta and content), door, origin, uuid and, in a subagent, agentId. Only content is yours to change, and next({ ...e, message }) rewrites it for the model and the transcript file both. door is what a matcher narrows on, and the declarations list eleven: prompt, command, response, tool-result, tool-message, delivery, attachment, hook-context, note, compaction and notice.

At 2.1.286 one short -p run showed me four of them. note was my plugin's own $.session.append. prompt was the -p prompt, with origin { kind: "unclassified" }, and a plugin's $.prompt.submit, with the plugin's origin. attachment rows came from the engine with names like date, environment, model, skill_listing and deferred_tools_delta. response rows had origin { kind: "model", model }, and the thinking block and the text block arrived as separate rows.

A hook on session.append answers with what next(e) resolves to, { message, uuid }. The declarations say a hook that answers without next, or with another row, is skipped, and that { deny } is allowed only on a plugin's own append. I haven't tried either. My plugin's session.append hook did see the rows its own session.start hook appended, so if one of your hooks appends and another rewrites rows, the rewriter sees the appended ones too.

session.send covers the SendMessage tool and $.session.send. You can rewrite the text, readdress it, or return { isDelivered: false, reason } without calling next to refuse it.

Commands, config, skills

Event When
command.run A slash command runs
command.describe Its description is being put together
config.set A configuration row is being written
config.describe A row's description is being put together
skill.prompt A skill's prompt is being built
plugin.register A plugin is being admitted, where an organisation governs
attribution.text Attribution text is being generated

Since 2.1.283 a command.run hook with a matcher like { command: "hello" } only receives that command.

plugin.register is where admission happens. A hook there sees each plugin's list of capabilities, the same one claude plugin validate prints, and can refuse the plugin. That's all the governance there is. Organisations govern by deciding what gets in, and don't sandbox it afterwards, because once a plugin is in it's trusted code with everything the process can reach. Anthropic's admin page says the same from the other side. Deny rules and managed PreToolUse hooks cover Claude's tool calls, and a mod's own $.fs and $.process calls go round both, so with Read(.env) denied a mod can still read that file (know what happens by default).

attribution.text has four kinds, commit, pr, exemption and remedy, and it's the text Claude attaches to work it did. The declarations' own example blanks the commit text with on("attribution.text", { kind: "commit" }, () => ({ text: "" })).

Telemetry

Event When
telemetry.log A record is about to go to Anthropic's analytics or to the OpenTelemetry collector. New in 2.1.284.
telemetry.mark One use of a feature is being marked ok, sad or bad. New in 2.1.284.

Both return { value: undefined } from next(e), and { deny } rejects whoever called. An installed plugin can hook telemetry.log only by naming the collector, on("telemetry.log", { to: "collector" }, hook), and it can't hook telemetry.mark at all, since a mark always goes to Anthropic. Anything else gets the module refused at load, and the engine page has the exact error from 2.1.286. It also says why an installed plugin can't call $.telemetry in the first place. Anthropic's reference page says you hook these by name or as telemetry.*. At 2.1.287 validate still refused telemetry.*, telemetry.mark and a telemetry.log hook with no to, and the engine page has that run too.

A collector record carries to: "collector", event, attributes, loggedAt and sometimes span, and the declarations say it leaves named claude_code.<event>. e.to is pinned, so a hook can rewrite what a record carries and it still goes where it was going. At 2.1.286, on a box where managed settings turn OpenTelemetry export on, my collector hook saw user_prompt, api_request and assistant_response for each turn.

The screen

Event When
ui.render A component is being drawn
ui.resolve Elements are being resolved for a surface
ui.press A button is pressed
ui.input Text is entered
ui.select A selection is made
ui.message A Client surface module posts a message
ui.scroll Something scrolls
ui.focus Focus moves

The details and examples are on the UI page.

engine.create

This is the odd one out. It builds $, you can't reach it as $.noun.event, it has no time budget, and .catch on it throws. The engine page covers it.

Operation events

Every verb on $ is also an event, so anything one plugin does, another plugin can hook. Leaving out the verbs that are already engine events above, and $.plugin, which is plain data, that's 60 of them at 2.1.286:

Noun Events
model complete fork classify
session id cwd root repo model surface surfaces turns messages usage version authorize
prompt read
tool list register
command list register
agent list register
ui log notice toast status open close panes copy invalidate blit
fs read write list exists stat ancestors
store get set delete keys
state get set
clock now sleep after every
turn abort
config list
audio play speak
http fetch
process run spawn
settings read
env get set
mcp call connect

$.ui.ask isn't in the table because it's a tool.call of AskUserQuestion underneath, so you hook it there. process.spawn streams like turn.step, so a hook on it is an async generator that sees each piece of output.

flag.value was asked for in the issue thread and still doesn't exist in the 2.1.286 declarations. The only flag anywhere in them is SettingsSource = 'user' | 'project' | 'local' | 'flag' | 'policy', and that's about where a setting came from.

The fs names changed between 2.1.263 and 2.1.272 (readFile became read, and so on), so a hook on fs.readFile today hooks nothing.

classic.*

Claude Code's older hooks, the ones set up in settings.json that run a command and talk JSON over stdin and stdout, are all still here. Each one is wrapped one to one:

every settings hook is wrapped 1:1 as classic.<Event> with its exact JSON in and out; the configured shell hooks are core for that seam.

So classic.PreToolUse is an event. The JSON going in is exactly what your shell hook would have read on stdin, and the JSON coming back is what it would have written. The configured command hooks are core for that chain, which means they run beneath every function hook on it, and a function hook can rewrite what they see and what they said.

You can glob on classic. sec-default's own register.ts opens with on('classic.*', ($, e, next) => next.to(e, 'append')), which confirms it and also sums up what an organisation wants from those hooks.

At 2.1.286 that's more than a summing up. Where sec-default loads, an installed plugin's classic.* hook doesn't run. My classic.PreToolUse hook never fired, and the debug log said modsa-deny: classic.PreToolUse bypassed by cc-plugin-sec-default (tier user); beneath runs. The same log said why it loaded here: cc-plugin-sec-default@builtin seated outermost: this machine has managed settings. The bundle's description of sec-default says it also seats for Team and Enterprise organizations unless managed prependPlugins is set. This box is the only one I tested on, and it has managed settings, so I haven't seen one of my own classic.* hooks run at 2.1.286 or 2.1.287.

Anthropic's events page documents classic.Stop for any mod with no caveat (hook the settings hook events), and their admin page says the guard adds no restrictions beyond what the organisation manages. At 2.1.287 on this box the guard still skipped both kinds. A --plugin-dir mod's classic.PreToolUse hook, which writes a file and denies Bash, and its classic.Stop hook, which writes another, wrote nothing, Bash ran echo hi, and the log said:

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

Where a PreToolUse hook from settings runs depends on which file it's in. One from managed settings runs before the first mod's tool.call hook, and if it blocks, no mod sees the call. If a mod rewrites the call, the managed hooks run again on the new one. PreToolUse hooks from every other settings file and from plugins' hooks/hooks.json run after the last mod calls next, as part of core, so the tool.call chain wraps those completely, and a mod that answers without calling next keeps them from running. That's Anthropic's where settings hooks run in the order. My earlier line here said the chain wraps PreToolUse completely, which holds only for that second group. You don't have to pick one of the two, and using both is normal.

Globs

on("tool.*", hook)        // every tool event
on("classic.*", hook)     // every settings-hook seam
on("*", hook)             // literally everything, including every $ op
on("!tool.call", hook)    // everything except that one

When a pattern matches several events, e narrows to the union of their types. So a glob hook is written against a union, and it needs next.is("tool.*", e) or a check on some field to get anything done. next.event tells a glob hook which event it's in.

* doesn't quite mean everything, though. The declarations say neither * nor a negation selects telemetry.log or telemetry.mark for an installed plugin, which has to name them. Naming them isn't enough either. At 2.1.287 claude plugin validate refuses telemetry.*, telemetry.mark and a telemetry.log hook with no to, so the only telemetry hook an installed plugin can register is on("telemetry.log", { to: "collector" }, hook).

on("*") hooks every operation as well, so it sees every other plugin's $ calls, from the mods outside it and the ones inside alike. Anthropic's reference page says a hook only intercepts calls from the mods that run after it, and at 2.1.287 the inner of two mods saw the outer one's call, which the lifecycle page shows. That makes install order a question of trust, and not just preference.

Events you can ask to rerun

$.ui.invalidate(event) tells the engine that what one of these produced earlier is out of date:

  • ui.render
  • prompt.section
  • prompt.context
  • prompt.attachment
  • tool.describe
  • command.describe
  • config.describe

ui.render redraws at most ten times a second, or thirty for the pane that's showing. The others throw away their cached answer and get asked again next turn.

It's what lets a mod change how the model sees a tool while the session is running. Change what your tool.describe hook returns, call $.ui.invalidate('tool.describe'), and the next turn picks it up.

prompt.compose isn't on the list. At 2.1.286, invalidating prompt.section after every turn didn't get a new composed prompt sent either. Only /clear did. Every one of those runs had a compose hook, so I don't know yet whether a pinned prompt also keeps another plugin's invalidated prompt.section from reaching the model. Anthropic's events page says changed section text invalidates the prompt cache. My 2.1.286 run saw the first render pinned until /clear, and I haven't re-run it at 2.1.287.

What still has no event

Three gaps from 2.1.272 closed in 2.1.283. The prompt box has prompt.edit, the engine's own reminders have prompt.attachment, and usage thresholds have session.measure, so a quota guard doesn't have to poll any more.

The permission dialog is still out of reach. The declarations say it "is drawn by the engine alone, since its answer authorises an action", and ui.render can't touch it. You can add context next to it with $.ui.notice, and that's all.

Why use hooks at all

The alternative to a hook is asking the model to report what it did. I measured both on the same work at 2.1.272. Events written by hooks arrived 161 of 161 times. Events the model was asked to log itself arrived 0, 0 and 5 of 53, which is 9% across three runs.

A hook records something that happened. Asking the model to record it is a request it can ignore.

Feedback