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

The engine interface

Every noun and verb on $, what a call costs, and the load-time scan that decides whether it is allowed.

$ is the first argument to every hook and the only way your code reaches anything outside itself. There is no fetch, no process, no require. Reading a file, running a command, drawing on the screen, asking the model a question: all of it goes through $, and that is deliberate, because a table of functions is a thing an organisation can take entries out of.

The cheat sheet's line for it is the one to hold onto: $ is the plugins' capability table, not the agent's. Withholding $.http from your mod does not stop Claude using WebFetch. It stops your mod.

Every verb is also an event

This is the thing that makes the design cohere. $.fs.read(path) does not call a function, it raises a dispatch called fs.read, which folds through every plugin that hooked it before core does the reading. So another mod can log your file reads, rewrite the path, cache the result, or refuse. Forty-nine operations work this way and they are listed on the events page.

Your own calls do not come back to you: a hook never sees the dispatches it raised. Everyone else's hooks see them.

The nouns

Glosses in quotes are the cheat sheet's own wording.

Noun Verbs What it is for
$.tool call list 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 fill suggest "queue a prompt as this plugin", "write the prompt box · propose dim text into it"
$.agent spawn list "start a subagent, resolves when it settles"
$.turn abort Stop the turn in flight
$.session id cwd repo model surfaces turns messages usage compact authorize Everything about the session you are inside
$.model complete fork classify fork is "tool-less completion over this transcript, cache-shared"
$.ui log notice toast status ask open close invalidate resolve blit The screen. Its own page.
$.fs read write list stat exists ancestors exists "never rejects"
$.settings read The resolved settings, by source
$.config set list Your plugin's own configuration rows
$.env get set "one variable by LITERAL name"
$.store get set delete keys Persistent key/value, yours alone
$.http fetch The network, and the only door to it
$.process run "argv on the host, no shell"
$.clock now sleep after every Time, injectable in tests
$.audio play speak Sound
$.mcp call An MCP server directly
$.plugin name root Data rather than capability. See below.

$.env.get takes a literal name and that is enforced by the same scan that enforces $.noun.verb(...). You cannot read the environment by computed key, which forecloses the obvious way to exfiltrate it.

$.plugin is not a capability

$.plugin.name and $.plugin.root are plain strings. Calling them throws, and the thrown message leaks the value, which is how I worked this out. A module whose only use of $ is $.plugin.name validates with calls: nothing on $, because there is nothing there to govern.

const name = $.plugin.name          // right
const name = $.plugin.name()        // throws

The ops with surprises in them

$.session.usage() is empty until a turn completes. At session.start you get this:

{"context":{"window":200000},"rateLimits":[],"cost":{"usd":0}}

and after the first turn.complete, this:

{"context":{"tokens":27532,"window":200000,"percent":14},
 "rateLimits":[{"kind":"five_hour","percentUsed":40,"resetsAt":"2026-09-15T06:20:00.000Z"},
               {"kind":"seven_day","percentUsed":70,"resetsAt":"2026-09-17T07:00:00.000Z"}],
 "cost":{"usd":0.006494}}

tokens and percent are absent keys rather than zeroes, so usage.context.percent > 80 is undefined > 80, which is false, and a context-pressure warning written the obvious way never fires. I reproduced that twice. It has an entry on the gotchas page with the code that gets it right.

$.model.fork tells you the context size for the price of a completion. Its cache_read_input_tokens is the transcript size: 24,781 against a measured 24,786, five tokens apart. It is billed as a full cache read and it returns null on a cold snapshot, so it is a measurement instrument rather than a polling loop.

$.session.repo only knows git. A jj workspace returns null, and so does a secondary working copy. Anything branching on "is this a repo" needs a fallback that is not an error message.

$.session.compact refuses in headless. Verbatim: $.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. The error tells you what to do with it.

$.prompt.submit refuses slash commands. Use $.command.run, which exists for that.

$.ui.ask is tool.call on AskUserQuestion underneath, so in a session without that tool it fails with $.tool.call: no tool named "AskUserQuestion" in this session rather than with anything about ask.

$.session.authorize() hands you an opaque handle, never a token. You spend it by passing it to $.http.fetch, and the engine sets the credential header itself, only for a first-party host:

await $.http.fetch(url, { auth: (await $.session.authorize())?.handle })

$ outlives its dispatch

next is explicitly "made once per dispatch per hook, frozen", and it rejects once its dispatch is done. $ carries no such wording, and Anthropic's own diff mod captures a $ at session.start and calls through it for the rest of the session, from timer callbacks, long after that hook returned.

You cannot store $ itself, because the loader refuses that. You store arrow functions that call through it, which is the Host port pattern on the anatomy page.

$ is built per plugin, and engine.create is where

Before your hooks run, the engine raises engine.create and folds it through every plugin, building your $. It is the one event that is not reachable as $.noun.event, it has no budget, and .catch on it throws, because its failure is the load's failure rather than a dispatch's.

It exists so a plugin can add a noun. Here is Anthropic's telemetry mod doing exactly that, which is how $.telemetry comes to exist on internal builds:

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 }
})

Read what the new noun closes over. It is beneath, the engine interface that came up from the fold, and never the hook's own $. That is not style. Your $ is yours; beneath is what you are extending on the way back up, and the consumer's $ is built from it.

Two refusals:

  • Replacing an existing noun is refused. engine.create replaced $.store, added by core; to change what a method does, hook the method. Adding is how you extend; hooking is how you change.
  • A provider cannot consume its own noun, because the re-entrancy guard is per plugin rather than per hook. You end up inlining a second copy of the implementation for your own use. It is ugly and there is no way around it today.

Telling the type system about a new noun

The noun exists at runtime the moment the fold returns it. Making it exist for TypeScript is a separate job, and telemetry does it with a standalone declaration file that imports nothing at all:

declare module 'claude-code' {
  interface EngineInterface {
    telemetry: Telemetry
  }
}

Pointed at by "types" in plugin.json. The mod's own comment on the file: "Nothing here is imported, so it stands on its own." That is the requirement, since a declaration file with a top-level import is a module and stops being a global augmentation.

/plugin-types does not write plugin-added nouns. It writes what the engine knows about itself, so a mod consuming another mod's noun writes against unknown unless that mod ships its own .d.ts and you have it. That is the state of the ecosystem at 2.1.272 and it is the main thing stopping mods composing with each other.

A missing noun throws

It does not read as undefined. Asking for $.telemetry on a public build gives you undefined is not an object (evaluating '$.telemetry.log'), which skips your hook and runs whatever is beneath it, quietly. So a mod depending on a noun somebody else provides needs to survive its absence. Anthropic's diff mod depends on $.telemetry and therefore does not run on a public build at all.

When an organisation withholds a noun, the refusal names who withheld it, which is better than the above and is the case you can actually diagnose.

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

No deprecation, no alias, no migration note. Every mod written against the old names broke, and the failure mode is the one above: a throw, a skip, and a dim line. 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 when you upgrade.