$ is the first argument to every hook, and it's the only way your code reaches anything outside itself. There's no fetch, no process and no require. Reading a file, running a command, drawing on the screen and asking the model something all go through $. That's on purpose, because a table of functions is something an organisation can take entries out of.
The cheat sheet says it in one line worth remembering: $ is the plugins' capability table, not the agent's. Taking $.http away from your mod doesn't stop Claude using WebFetch. It stops your mod.
Every verb is also an event
This is what holds the whole design together. $.fs.read(path) doesn't just call a function, it raises a dispatch called fs.read that folds through every plugin hooking it before core does the reading. So another mod can log your file reads, rewrite the path, cache the result or refuse. The events page lists every operation that works this way.
Your own calls never come back to you, since a hook never sees the dispatches it raised. Everyone else's hooks do see them, the mods beneath you as well as the ones above. Anthropic's reference page says a hook on a call only intercepts calls from the mods that run after it. At 2.1.287 a mod running after another saw that mod's calls, and the run is on the lifecycle page.
The nouns
There are 21 nouns and 80 verbs in the 2.1.286 declarations. Quoted glosses are the cheat sheet's wording.
| Noun | Verbs | What it's for |
|---|---|---|
$.tool |
call list check register |
"run a tool through the hooks and permissions", "tools the model has now", "give the model a new tool" |
$.command |
run list register |
"run /command as if typed" |
$.prompt |
submit read fill suggest compose |
"queue a prompt as this plugin", read the prompt box, "write the prompt box · propose dim text into it", build the system prompt without sending it (2.1.286) |
$.agent |
spawn list register |
"start a subagent, resolves when it settles", and define a new agent type |
$.turn |
abort |
Stop the turn in flight |
$.session |
id cwd root repo model surface surfaces turns messages usage version compact send append authorize |
Everything about the session you're in |
$.model |
complete fork classify |
fork is "tool-less completion over this transcript, cache-shared" |
$.ui |
log notice toast status ask open close panes scroll focus copy invalidate resolve blit |
The screen, with its own page |
$.fs |
read write list stat exists ancestors |
exists "never rejects" |
$.settings |
read |
The resolved settings, by source |
$.config |
set list |
The whole /config menu, the engine's rows and every enabled plugin's <plugin>.<field> rows, and a plugin reads its own fields from options (more on the config page) |
$.env |
get set |
"one variable by LITERAL name" |
$.store |
get set delete keys |
Persistent key/value storage that only you can see |
$.state |
get set |
Shared values with a version, that panes redraw from |
$.http |
fetch |
The network, and the only way to it |
$.process |
run spawn |
"argv on the host, no shell", and spawn streams the output |
$.clock |
now sleep after every |
Time, which tests can swap out |
$.audio |
play speak |
Sound |
$.telemetry |
log mark |
Analytics, and only the plugins built into Claude Code can call it (2.1.284, more below) |
$.mcp |
call connect |
An MCP server, directly, and connecting one your own manifest lists (2.1.285) |
$.plugin |
name root |
Plain data, see below |
$.env.get only takes a literal name, and the same scan that enforces $.noun.verb(...) checks it. You can't read the environment by a computed key, which closes off the obvious way to exfiltrate it. $.state works the same way: plugin and key have to be literals in the source.
What's new since 2.1.283
Five verbs and one noun arrived between 2.1.283 and 2.1.286, going by the declarations: $.telemetry.log, $.telemetry.mark, $.mcp.connect, $.session.append and $.prompt.compose. A handful of older calls grew new fields as well. I ran everything here at 2.1.286 from a --plugin-dir plugin, except where it says I didn't.
$.telemetryhaslogandmark, and while the built-in telemetry plugin is loaded an installed plugin can't call either of them. It gets its own section below.$.mcp.connect(server)connects one of the MCP servers your own plugin's manifest lists, and it never rejects for a refusal. On a name my manifest didn't list it resolved to{ "isConnected": false, "reason": "unlisted", "message": "This plugin's manifest lists no MCP server named \"nope\"." }. The declarations list five more reasons:unapproved,disabled,policy,authandfailed. A success is{ isConnected: true, server }, andserveris the name$.mcp.calltakes, usuallyplugin:<plugin>:<server>. I haven't connected a real server, so that shape is only the declarations' word.$.session.append({ message, agentId })puts a row into the conversation. It takes two kinds of row.type: "user"is one the model reads and the person doesn't see as typed, and mine came back stored withrole: "user"andisMeta: true.type: "system"is a notice, which the declarations say the model never reads, and mine was stored withname: "informational". Content is text blocks only, and a notice takes exactly one. The declarations say to leaveagentIdout for the main conversation, or give a running subagent's id.$.model.complete(request, { signal })takes anAbortSignal. With one that was already aborted it resolved{ isAnswered: false, reason: "aborted" }in 2 ms with zero usage, and the declarations say no request is made at all. Aborting 300 ms in resolvedabortedat 327 ms, also with zero usage reported.asUser: trueon$.prompt.submitsends your text to the model bare. Hooks still see the origin as{ kind: "plugin", name, asUser: true }, and the declarations say the transcript still names your plugin. It works in-p, where the submitted prompt ran as its own turn before the-pprompt's, and only the-pprompt's answer was printed. The exact wording on both sides is further down.$.process.runresults carryisStdoutTruncatedandisStderrTruncated. Each stream keeps 4 MiB. Reading 5,000,000 bytes of/dev/zerogave me a stdout 4,194,304 long andisStdoutTruncated: true, and a small run saidfalsefor both.- A
command.runanswer can carryexitCode. It's what the process exits with when that command was the whole prompt of a headless run. A hook answering{ text: "exiting with 3", exitCode: 3 }madeclaude -p "/modsaexit"exit 3. The declarations say it's a whole number from 0 to 255 and an interactive session ignores it. $.fs.listentries carrymtimeMs($.fs.statalready did). A file's came back as1790803880576.4146, fraction and all, so round it before you use it as a key. A directory's list entry was0, and the declarations say a link or anything else that isn't a file is0too. For a directory's real time, the declarations point you at$.fs.stat.decorationscolor ranges of the prompt box. Aprompt.editanswer and a$.prompt.fillcan both carry them, each one{ start, end }plus any ofcolor,backgroundColor,dimColor,bold,italic,underlineandstrikethrough. The declarations say ranges are clamped, later entries win, and a fill's decorations are gone at the draft's next change unless aprompt.edithook answers new ones. I haven't drawn one yet, so that's all from the declarations.
New in 2.1.286: $.prompt.compose() returns the system prompt's sections, run through every other plugin's prompt.compose hook on top of the engine's own. Leave the argument out and it uses the session's model and tools, or pass any of model, promptModel, surfaces, tools, outputStyle and traits to compose for something else. The declarations say it's "composed for nobody to send, so nothing the session holds is written." I haven't called it from a mod yet, so I'm only quoting them. Read gotcha 10 before you hook the event itself.
What asUser changes, word for word. I submitted Reply with exactly the word BANANA. from session.start in a -p run and read the request back with $.session.messages({ as: "api" }). With asUser: true the user message was that sentence and nothing else. Without it, the model got this:
The modsa-submit plugin sent a message:
Reply with exactly the word BANANA.
This is how Claude Code surfaces a prompt a plugin submits between turns — it starts this turn in the user's place. Address the message above.
Neither form expands @file mentions or pasted images, according to the declarations.
The model reads an appended user row, and it may not trust it. I appended "the secret word is PELICAN" as a user row at session.start, and the model (Haiku 4.5) answered PELICAN when the -p prompt asked for the word. On the turn before, which my plugin had also started, it called the same setup a likely prompt injection and said so. That's one run on one model, but I wouldn't count on an appended row being read like something the person typed.
What $.session.append refuses. Every refusal rejects, so the call throws. These are the four I hit at 2.1.286:
two text blocks on a notice HooksError: $.session.append: refused: a notice that is not one text block
an image block HooksError: $.session.append: refused: a image block the conversation does not take from a plugin (a row a plugin appends carries text blocks)
an agentId nobody is running HooksError: $.session.append: refused: no running loop is no-such-agent
type "assistant" HooksError: modsa-probe: $.session.append takes { message: { type: "user" | "system", content } } (content an array of text blocks) and an optional agentId (a string)
The last one never reached a hook. The first three went through every session.append hook on the way down and were refused at the bottom, so a hook above you can see a row that's never stored.
What arrived between 2.1.272 and 2.1.283
Ten verbs and one noun arrived between 2.1.272 and 2.1.283. The ones you're most likely to want:
$.state.get/$.state.sethold a value with a version number.setonly writes your own plugin's values and takes{ ifVersion }for a compare-and-set. Anything drawing from a value redraws when it changes, so it's what you want for a pane's data.setis refused while aui.renderhook is drawing, so write fromonPressor another event.$.process.spawnstreams a child's output as it arrives. The child lives as long as yourfor awaitloop does, and leaving the loop kills it. So doesnext.signalaborting, or the module unloading.$.agent.registerdefines an agent type named<plugin>:<name>that the Agent tool can use from the next turn on.$.session.sendsends a plain-text message to another agent or session, the same delivery the SendMessage tool uses.$.prompt.readreturns what's in the prompt box and where the cursor is, so afillcan keep what the person typed. It never rejects and returns an empty box in a-prun.$.session.versiongives you the Claude Code version and build time, and$.session.rootgives you the project root, which follows/cdand worktree moves but not a shellcd.$.ui.panesand$.ui.copyare covered on the UI page.
$.plugin isn't a capability
$.plugin.name and $.plugin.root are plain strings. Calling them throws $.plugin.name is not a function, and the error message gives away the value, which is how I worked this out. A module whose only use of $ is $.plugin.name validates with calls: nothing on $, since there's nothing there to govern. Anthropic's reference page lists name and root in its table of methods (mods API methods), but the 2.1.287 declarations still give plugin: { name: string; root: string }, two strings you read.
const name = $.plugin.name // right
const name = $.plugin.name() // throws
$.telemetry is for the built-ins
$.telemetry has two verbs, and it's been one of core's nouns since 2.1.284, going by the declarations. log writes a row for Anthropic's own analytics, or a record for the OpenTelemetry collector the machine's settings point at. mark counts one use of a feature as ok, sad or bad, and a mark always goes to Anthropic.
An installed plugin can't call either one while the built-in cc-plugin-telemetry is loaded, and it loaded in every session I ran. At 2.1.286 every call from my --plugin-dir plugin rejected, and a record meant for the collector was no exception:
HooksError: modsa-probe: $.telemetry.log: $.telemetry serves the plugins built into Claude Code alone
HooksError: modsa-probe: $.telemetry.mark: $.telemetry serves the plugins built into Claude Code alone
claude plugin validate doesn't warn you. It lists $.telemetry.log and $.telemetry.mark under calls: and passes the module. The refusal comes from a hook in the built-in telemetry plugin that denies any caller outside the built-ins and core, which I read in the 2.1.286 bundle.
Anthropic's reference page says only that "a record is sent only when Claude Code or a built-in mod raised it" (mods API methods), which reads like the record gets dropped quietly. At 2.1.287 the call still rejects. A --plugin-dir mod that wrapped each call in try wrote down what came back:
log anthropic: rejected: HooksError: telcall: $.telemetry.log: $.telemetry serves the plugins built into Claude Code alone
log collector: rejected: HooksError: telcall: $.telemetry.log: $.telemetry serves the plugins built into Claude Code alone
mark: rejected: HooksError: telcall: $.telemetry.mark: $.telemetry serves the plugins built into Claude Code alone
The debug log named the hook that did it: cc-plugin-telemetry (builtin) answered telemetry.log without next() in 2.4ms; nothing beneath it ran for this dispatch. So wrap the call in try, or better, don't make it.
Hooking the events is narrower still. An installed plugin can only hook telemetry.log, and only by naming the collector in the matcher. A hook that names no to stands on every stream, Anthropic's included, and a telemetry.mark hook is on Anthropic's by definition, so both get the module refused at validate and again at load. The load error at 2.1.286, word for word:
hooks module modsa-markonly@inline failed to load: modsa-markonly: hooks module not loaded; its hooks stand on the telemetry stream "anthropic" (a matcher names it, or a telemetry hook names no "to" and so stands on every stream), which is for the plugins built into the CLI; name the collector on each telemetry hook: on("telemetry.log", { to: "collector" }, hook) (policy/scan)
Anthropic's reference page says to hook them "by name or as telemetry.*", and their events page says the same. At 2.1.287 neither works without a to. claude plugin validate refused a module hooking telemetry.*, one hooking telemetry.mark and one hooking telemetry.log with no to, all three with the message above, and a live load of the last two failed with it word for word. The only one that passed was on("telemetry.log", { to: "collector" }, hook), which validate listed as hooks: telemetry.log{to=collector}.
The collector hook it asks for does work. On this box, where managed settings turn OpenTelemetry export on, mine saw the engine's own user_prompt, api_request and assistant_response records, once each per turn over two turns. The events page has what they carry.
The ops with surprises in them
$.session.usage() is mostly empty until a turn completes. At session.start you get this:
{"startedAt":1790534750172,"context":{"window":200000},"rateLimits":[],"cost":{"usd":0}}
and after the first turn.complete, this:
{"startedAt":1790534750172,"context":{"tokens":20701,"window":200000,"percent":10},
"rateLimits":[{"kind":"five_hour","percentUsed":2,"resetsAt":"2026-09-27T23:20:00.000Z"},
{"kind":"seven_day","percentUsed":33,"resetsAt":"2026-10-01T07:00:00.000Z"}],
"cost":{"usd":0.0185368}}
tokens and percent are missing rather than zero. So usage.context.percent > 80 becomes undefined > 80, which is false, and a context warning written the obvious way never fires. I reproduced that at 2.1.272 and again at 2.1.283. The gotchas page has code that gets it right. rateLimits stays empty for anyone not on a Claude.ai subscription, which includes every Bedrock and Vertex session.
$.model.complete and $.model.fork no longer return null. Since 2.1.280 both return { isAnswered: true, text, usage } or { isAnswered: false, reason, usage }. reason is empty-reply, aborted or api-error, and fork adds nothing-to-fork for a conversation with no response yet. Code that checked for null now reads .text off a failure and gets undefined, so check isAnswered.
$.model.fork tells you the context size for the price of a completion. Its cache_read_input_tokens is the size of the transcript: 24,781 against a measured 24,786 when I tried it at 2.1.272. It's billed as a full cache read though, so it's a measuring tool and not something to poll.
$.session.repo only understands git. A jj workspace returns null, and so does a secondary working copy. Anything that branches on "is this a repo" needs a fallback that isn't an error message.
$.session.compact refuses in headless. Word for word: $.session.compact: not available in a headless (-p / SDK) session yet: compaction here runs inside a turn (a /compact prompt); catch it and carry on. At least the error tells you what to do about it.
$.prompt.submit refuses slash commands. $.command.run is there for those.
$.ui.ask is tool.call on AskUserQuestion underneath. So in a session without that tool, a -p run for one, it fails with $.tool.call: no tool named "AskUserQuestion" in this session, which says nothing about ask.
$.session.authorize() gives you an opaque handle and never a token. You spend it by passing it to $.http.fetch, and the engine sets the credential header itself, and only for a first-party host:
await $.http.fetch(url, { auth: (await $.session.authorize())?.handle })
$ outlives its dispatch
next is described as "made once per dispatch per hook, frozen", and it rejects once its dispatch is over. Nothing says that about $, and Anthropic's own diff mod captures a $ at session.start and keeps calling through it for the rest of the session, from timer callbacks, long after that hook returned.
You can't store $ itself because the loader refuses it. You store arrow functions that call through it instead, which is the Host port pattern on the anatomy page.
engine.create builds your $
Before any of your hooks run, the engine raises engine.create and folds it through every plugin to build your $. You can't reach it as $.noun.event, it has no time budget, and .catch on it throws, because if it fails the load fails.
It's there so a plugin can add a noun. Here's Anthropic's telemetry mod doing that at 2.1.283, which is where $.telemetry came from then:
on('engine.create', async ($, e, next) => {
const beneath = await next(e)
const telemetry = {
mark: (entry: TelemetryMark) => record(beneath, entry),
log: (entry: TelemetryLog) => write(beneath, entry),
}
return { ...beneath, telemetry }
})
Look at what the new noun closes over. It's beneath, the engine interface coming back up the fold, and never the hook's own $. That matters. Your $ belongs to you, beneath is the thing you're extending on the way back up, and whoever uses the noun gets a $ built from it.
From 2.1.284 $.telemetry is core's own noun. The mod in the 2.1.286 bundle still hooks engine.create, but it spreads beneath last, so core's telemetry wins the merge. Anthropic's overview page describes the telemetry mod as adding methods other mods can call. The 2.1.287 declarations put telemetry on CoreEngineInterface, next to fs and store, so I'm keeping core as its home.
An installed plugin can do this too, as long as it writes the closure one particular way. The loader scans installed modules, and at 2.1.286 it refuses a module that passes beneath to a helper function, with the value of next(e) at engine.create is passed as an argument, at validate and again at load. It was already refusing that at 2.1.283. What loads at 2.1.286 is a noun whose methods call beneath directly, like beneath.fs.write(...) inside the closure, with the hook returning { ...beneath, audit }. In a --plugin-dir run another installed plugin called that noun, and the debug log said $.audit.record (r6c-consumer -> r6b-noun) answered in 10.8ms. I don't know whether that shape already loaded at 2.1.283, because I never tried it there. The full working version is recipe 6 on the recipes page.
Two more refusals at 2.1.286:
- Replacing an existing noun is refused with
engine.create replaced $.store, added by core; to change what a method does, hook the method. You add nouns and you hook verbs. - Spreading a core noun is refused with
$.store is used as a value. Call its methods one at a time instead.
At 2.1.283 a provider couldn't use its own noun, and I put that down to the re-entry guard working per plugin. At 2.1.286 it can. A plugin calling the noun it adds got $.audit.record (r6d-self -> r6d-self) answered in 46.4ms, which fits the guard working per hook, and my own session.append hook seeing rows that another hook in the same plugin appended.
Telling TypeScript about a new noun
The noun exists at runtime as soon as the fold returns it. Getting TypeScript to know about it is a separate job, and telemetry does it with a declaration file that imports nothing:
declare module 'claude-code' {
interface EngineInterface {
telemetry: Telemetry
}
}
"types" in plugin.json points at it. The mod's own comment on the file is "Nothing here is imported, so it stands on its own." That's the whole requirement, since a declaration file with a top-level import becomes a module and stops augmenting anything.
The generated types now include the nouns your dependencies add. Up to 2.1.283 they described only what the engine knows about itself, so a mod using another mod's noun wrote against unknown, and that was the main thing stopping mods from building on each other. At 2.1.287 a mod that lists a plugin under dependencies gets that plugin's "types" file copied in beside the engine's, which is what Anthropic's create page describes. My consumer listed a provider whose "types" file adds $.audit, and the load log said:
type root of consnoun at /tmp/implB-lee/consnoun/.claude-plugin/types: entries claude-code, claude-code-tools, claude-code-mcp, provnoun; wrote .claude-plugin/types/claude-code/index.d.ts, .claude-plugin/types/claude-code-tools/index.d.ts, .claude-plugin/types/claude-code-mcp/index.d.ts, .claude-plugin/types/provnoun/index.d.ts, .claude-plugin/types/tsconfig.json, .claude-plugin/types/.gitignore, tsconfig.json
provnoun/index.d.ts came out identical to the provider's file. The provider has to ship a "types" file for this to work, and going by that page a plugin you don't list under dependencies gets nothing generated. telemetry is in the engine's own declarations because it moved into core.
A missing noun throws
It doesn't read as undefined, it throws, and a throw skips your hook and quietly runs whatever's beneath. So a mod that depends on a noun from somewhere else has to cope with it not being there.
$.telemetry used to be the example here. At 2.1.283 the noun came from the built-in telemetry plugin, which always loads, so it existed for everyone and still threw when an installed plugin called it. Since 2.1.284 it's core's, and the throw at 2.1.286 is the refusal covered in the telemetry section above.
The names moved once already
Between 2.1.263 and 2.1.272 the filesystem operations were renamed:
| Was | Is |
|---|---|
$.fs.readFile |
$.fs.read |
$.fs.writeFile |
$.fs.write |
$.fs.listDir |
$.fs.list |
There was no deprecation period, no alias and no migration note, and there still isn't one in the 2.1.286 declarations. Every mod using the old names broke, and it broke the way described above, with a throw, a skip and a line in a log. The model.complete change in 2.1.280 was the same kind of break. The header of the declarations says this surface may change between releases without notice, and it means it. Pin the build you tested against, and re-read the declarations whenever you upgrade.