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.renderprompt.sectionprompt.contextprompt.attachmenttool.describecommand.describeconfig.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.