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
Blog ·

Claude Code's function hooks: the plugin runtime Anthropic hasn't documented

A JavaScript plugin API has shipped inside Claude Code since v2.1.242, off by default and missing from the docs. How to switch it on, what it can do, and the version each piece landed in.

7,154 words 33 min read AnExiledDev

Claude Code plugins have carried hooks for a long time: shell commands listed in hooks/hooks.json that run on PreToolUse and the other lifecycle events. Anthropic documents that form well enough that I'll link the plugins reference and not repeat it. Since v2.1.242 (24 Aug 2026) the same file can name a JavaScript module instead. The module registers functions against events like tool.call, prompt.submit, session.start and ui.render, and every function gets an object called $ that reaches the model, the session, the terminal, the filesystem, a key value store, the network and the host's processes. A plugin written this way can draw its own panes and buttons, register tools the model calls, register slash commands, and keep working between turns on timers.

Anthropic's own names for this are function hooks and hooks modules. As of v2.1.269 (11 Sep 2026) none of the 192 pages in their docs corpus uses either name, and the official CHANGELOG.md doesn't either. The bundle does, and it ships a type declarations file that opens with "EARLY ACCESS: this surface may change between releases without notice". It has, in at least ten of the nineteen releases since, 2.1.269 included. Everything I ran, I ran on 2.1.269. The tables carry 2.1.271 to 2.1.274 too, dated from the bundles and the declarations and nothing else, and the version column at the end tells you what to expect from an older build.

Every claim here was checked against the shipped bundles, the generated declarations, or a real run on my box. Where I couldn't check something, it's in the last section.

Turning it on

The loader checks four things before it runs any module. All four have to pass, and each failure writes its own line to the debug log.

  1. The rollout flag. tengu_plugin_hooks_modules is a GrowthBook flag with a built-in default of false, there since 2.1.242. Since 2.1.259 the environment variable CLAUDE_CODE_ENABLE_FUNCTION_HOOKS overrides it. 1, true, yes or on turns the runtime on. 0, false, no or off turns it off. Anything else, including unset, falls through to the flag.
  2. disableAllHooks in managed settings.
  3. --bare, which turns hooks off as a feature.
  4. The managed setting allowManagedHooksOnly, or disableAllHooks in your own settings when policy doesn't set it. Safe mode (--safe-mode or CLAUDE_CODE_SAFE_MODE) is checked before any of the four and writes its own line, Safe mode: skipping plugin hook registration.

The easy route is the env block in ~/.claude/settings.json, which is how this box runs it:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

Exporting the variable in your shell before you launch does the same thing.

To see what the loader decided, run claude --debug --debug-file /path/to/log. A module that loaded writes a line like this:

hooks module hello loaded (worker, environment 1, tier user); events: session.start,tool.call,command.run

A module that didn't writes one of these, verbatim from the 2.1.267 bundle:

hooks modules not loaded: rollout flag (tengu_plugin_hooks_modules) is off, <source>
hooks module of plugin "X" not loaded: disableAllHooks in managed settings
hooks module of plugin "X" not loaded: hooks are disabled in this mode (--bare)
hooks module of plugin "X" not loaded: an admin stood the guard down (safe mode / managed allowManagedHooksOnly)
hooks module of plugin "X" not loaded: only managed plugins run (allowManagedHooksOnly / disableAllHooks)
hooks module of plugin "X" from <source> not loaded: another plugin of that name loads first (a managed one, or an earlier source)

<source> on the flag line says where the false came from: overridden by the CLAUDE_CODE_ENABLE_FUNCTION_HOOKS environment variable, from a local override, from GrowthBook (this session's payload), from GrowthBook (the disk cache of an earlier session), from the default (GrowthBook is off for this session: a third-party provider, or telemetry opted out), or from the default (a cold GrowthBook cache, no payload yet).

One correction to my own site. The 2.1.242 page says in one entry that nothing switches the runtime on or off, and in another that it's off unless enabled remotely. The second one is right. The 2.1.246 page says the flag reader answered false for everyone at that version, and I haven't re-checked whether GrowthBook serves it true to anyone at 2.1.269, so set the variable and don't wait on the flag.

The smallest plugin that does something

Three files.

hello/
  .claude-plugin/plugin.json
  hooks/hooks.json
  hooks/module.js

plugin.json is the ordinary manifest:

{
  "name": "hello",
  "version": "0.1.0",
  "description": "A one-file plugin of function hooks"
}

hooks.json names the module:

{
  "modules": ["module.js"]
}

module.js registers a tool, a slash command, and the hooks that serve them:

/** @type {import('claude-code').Register} */
export const register = (on, options) => {
  on("session.start", async ($, e, next) => {
    await $.tool.register({
      name: "greet",
      description: "Greets whoever you name.",
      inputSchema: {
        type: "object",
        properties: { who: { type: "string" } },
        required: ["who"],
      },
    });
    await $.command.register({
      name: "hello",
      description: "Say hello from the plugin",
    });

    return next(e);
  });

  on("tool.call", { tool: "mcp__hello__greet" }, async ($, e) => {
    return { result: `Hello, ${e.who}.` };
  });

  on("command.run", { command: "hello" }, async ($, e) => {
    const cwd = await $.session.cwd();

    $.ui.toast("The hello plugin is loaded", { timeoutMs: 4000 });

    return { text: `Hello from a function hook, running in ${cwd}.` };
  });
};

claude plugin validate hello reads the manifest and the module's source and reports what it found. It took 31 seconds on my box the first time and 7 to 15 seconds on later runs, so most of that first figure was a cold cache:

module.js hooks: session.start, tool.call{tool=mcp__hello__greet}, command.run{command=hello}
module.js calls: $.command.register, $.session.cwd, $.tool.register, $.ui.toast
✔ Validation passed with warnings

The warning is the missing author field. Then a headless run, with the variable set:

claude -p --plugin-dir ./hello --model haiku \
  "Call the greet tool with who set to Reader, then reply with exactly what it returned."

The reply was Hello, Reader., three turns, $0.0498 on Haiku. The debug log shows how the tool got there. A registered tool is served to the model as an MCP server named after the plugin, over loopback HTTP:

$.tool.register (hello): 1 tool(s) served at 127.0.0.1:44175/<secret>/hello v1
$.tool.register (hello): mcp__hello__greet visible after 857ms

That's why the tool is listed as mcp__hello__greet and why the tool.call matcher names it that way. claude --plugin-dir <folder> loads a plugin from disk for one session and the flag repeats, so you don't need a marketplace to develop one.

What the engine does with your module

The module runs in a worker environment of its own. There's no Node and no DOM in it. Everything outside it goes through $, and JSX is available with h as the factory and Fragment for fragments. Setting CLAUDE_CODE_HOOKS_SAME_THREAD runs the module on the main thread instead, and the loaded line says same-thread in place of worker when it does.

hooks.json accepts four keys: description, hooks, modules and surface. hooks is the shell-command form and can sit beside modules. modules takes exactly one path, relative to hooks.json, and a second entry is refused. surface arrived in 2.1.267 and names one more module, one whose exports draw the hooks module's Client elements on a surface without $. I haven't written one.

register(on, options) is the entry point. options holds the values of the fields the manifest's userConfig declares, read from pluginConfigs.<name>.options in settings. Read options from a plugin whose manifest declares no userConfig and the log says so once: plugin hello: options requested but its manifest declares no userConfig; every option reads as absent.

on(event, hook) and on(event, matcher, hook) add hooks. The matcher is an object whose keys depend on the event: { tool } for tool.call, { command } for command.run, { surface, component } for ui.render, { plugin, element } for ui.press. Since 2.1.265 the event name is a pattern: * for everything, classic.* for the shell-hook events, !tool.* to exclude a family.

Every hook is ($, e, next). e is the event's input as a frozen plain value. Return without calling next and you've answered the event yourself. next(e) passes it on to the other plugins and then to the engine's own behaviour, and resolves to the event's result. next({ ...e, text }) rewrites what the rest of the chain sees, within what that event lets you change. next.signal aborts when the dispatch is abandoned, because the user interrupted, another hook settled first, or the budget ran out. Hooks run in tiers, prepend, user, append, builtin, core, and next.to(e, tier) jumps to a later one. That jump is for managed plugins only. A --plugin-dir plugin sits in the user tier and the engine refuses it with next.to is available to managed plugins (prependPlugins / appendPlugins) only.

Inside the user tier the order is the order of the keys under enabledPlugins in ~/.claude/settings.json, first key outermost, and I only know that because I measured it. I had two plugins on session.compact. One answers the event with its own result and never calls next, so anything inside it never hears about the compaction at all. With the second plugin's key below the first, its hook was never dispatched, and its session.start and turn.complete hooks ran fine in the same session, so it was loaded, it was just inner. Moving its key above the other one in the settings file put it outermost and its hook ran and forked the conversation. Moving its entry in ~/.claude/plugins/installed_plugins.json instead did nothing. There's no flag for this, and claude plugin install puts a new plugin on the end of the list, so whatever you installed last runs innermost. If your plugin has to see an event another one swallows, put its key first by hand, and expect the next install or enable to shuffle it. That's on 2.1.273. Nothing in the declarations says it works this way, so check it again when the engine moves.

A hook that throws is skipped and the chain continues without it. Since 2.1.267 on(...).catch(handler) answers in its place, and the handler reads the reason on next.error. The transcript reports a failed hook once, in a dim line naming the plugin, the event and the reason. The debug log has every occurrence.

Since 2.1.246 the module can span files. The entry may import its own files by relative path and the empty claude-code module the types come from, and nothing else. Only the entry may use top-level await. The caps at 2.1.267 are 512 files and 8 MiB in all. Past either, the module isn't read and the log says so: is past the 512 files a hooks module may link and was not read or takes the module over 8388608 bytes in total and was not read.

session.start fires once per plugin when the session is ready and is awaited before the first prompt, so a $.tool.register awaited there is listed by turn one. It fires again for one plugin alone when that plugin reloads, after an edit, new options, /reload-plugins or an enable, and not on /clear. That's the place to register things and start timers, and it's why a reload starts the timers over.

What $ gives you

Every call here is dated by the first bundle whose host-operation or event array names it. $.ui.ask and $.plugin sit outside those arrays, and the clock and $.ui.resolve run inside the worker, so those four are dated by their implementation.

Noun Calls Since
$.plugin name, root (the manifest name and the plugin's directory) 2.1.242
$.ui notice, invalidate, resolve, log, ask, toast, status 2.1.242
$.ui open, close 2.1.265
$.model complete, classify 2.1.242
$.model fork 2.1.257
$.audio play, speak 2.1.242
$.mcp call 2.1.242
$.session cwd, model, turns (turnCount until 2.1.267), id, messages, repo 2.1.242
$.session surface 2.1.246
$.session usage 2.1.267
$.session compact 2.1.267
$.session surfaces 2.1.269
$.ui blit, scroll, focus 2.1.271
$.turn abort 2.1.246
$.prompt submit 2.1.242
$.prompt fill, suggest 2.1.268
$.tool list, register, call 2.1.242
$.tool check 2.1.269
$.command list, register, run 2.1.265
$.agent list, spawn 2.1.242
$.agent register 2.1.274
$.fs exists, stat 2.1.242
$.fs ancestors 2.1.247
$.fs read, write, list 2.1.267
$.store get, set, delete, keys 2.1.242
$.clock now, sleep, after, every 2.1.242
$.http fetch 2.1.242
$.process run 2.1.260
$.settings read 2.1.267
$.env get, set 2.1.267
$.config list, set 2.1.269

Two rows need a note. readFile, writeFile and listDir existed from 2.1.242 and were renamed to read, write and list in 2.1.267. The old names are gone from that bundle, so a module written against 2.1.266 breaks on the rename. Then 2.1.268 did the same to $.session.turnCount, which is $.session.turns now with no alias for the old name, so a module written against 2.1.267 breaks on that one. 2.1.269 was gentler about it: $.session.surface still answers, it's just marked deprecated in the declarations in favour of $.session.surfaces, because a session can draw on a terminal and two phones at once and the singular has no way to say that.

Two rules sit under the whole table. Nine of the calls are events as calls, $.prompt.submit, $.prompt.fill, $.prompt.suggest, $.tool.call, $.tool.check, $.agent.spawn, $.command.run, $.config.set and $.session.compact, each raising the event of the same name and resolving to its result. And every host operation is itself an event, because the engine builds the event list by spreading the operation list into it. on("process.run", hook) sees every $.process.run another plugin makes, with next.origin naming the caller, and can refuse it with { deny } or answer it with { value }. The calling plugin's own hook is skipped. Nothing in the docs says so, and for a plugin that guards a session it's the most useful thing in here.

The signatures you'll reach for most, from the 2.1.274 declarations:

  • $.ui.toast(text, { timeoutMs }) and $.ui.status(text) show state without starting a turn. Toasts are budgeted at 200 per plugin per session, the same number since 2.1.242, and the 201st logs $.ui.toast: 200 toasts this session; the rest go to the debug log. They're spaced as well: a toast inside 2000 ms of the plugin's last one is dropped and logged as $.ui.toast (hello): within 2000ms of the last; dropped: with the text after it. That number has been the same since 2.1.242 too.
  • $.ui.open({ id, title, focus }) opens a pane whose body you draw by hooking ui.render for { component: "Pane" }. The id is 1 to 64 characters of letters, digits, _ and -, one pane per id, and opening an open id retitles it. $.ui.close({ id }) closes it, and every close raises a ui.close event whose e.origin says who asked. Since 2.1.268 that origin is an object, { kind: "plugin" }, where 2.1.267 passed the bare string, and session.receive and session.start had the same treatment: e.origin.kind and isInteractive where a 2.1.267 hook read e.origin and interactive.
  • $.ui.ask(question, options) puts a question to the person and rejects in -p.
  • $.model.complete({ model, prompt, system, maxTokens }) resolves a string through the session's own client and credentials. maxTokens defaults to 256.
  • $.http.fetch(url, init) resolves { status, ok, headers, text }.
  • $.process.run(argv, { cwd, env, stdin, timeoutMs }) runs a host command by argument vector, no shell, and resolves { exitCode, stdout, stderr } for any exit code. The timeout defaults to 30 seconds and caps at ten minutes. Git runs with repo hooks off.
  • $.store.get(key) and $.store.set(key, value) keep JSON values across sessions.
  • $.clock.every(ms, fn) and $.clock.after(ms, fn) return a timer with a cancel() method. Timers run until cancelled or until the module reloads.
  • $.prompt.submit({ text }) hands the session a prompt once it's idle. Text is all it takes: the declared argument type strips turnId, and the host refuses attachments at run time with attachments cannot be submitted. Text starting with / is refused, the budget is 50 prompts a session, and the prompt arrives with origin { kind: "plugin", name }. A prompt inside 5000 ms of the plugin's last one is refused as well, with hello: $.prompt.submit refused: within 5000ms of the plugin's last prompt, and that spacing has been the same since 2.1.242. Calling it from a hook that's holding the turn is refused: prompt.submit, tool.call, agent.spawn, prompt.section, prompt.context, tool.describe, command.run, command.describe, and every classic.* event except SessionStart, Setup and SessionEnd. The engine's message says to submit from turn.complete instead.
  • $.prompt.fill({ text }) writes text into the prompt box as the person's draft, cursor at the end, replacing what was there, and resolves { isFilled }. $.prompt.suggest({ text }) shows it as the dim suggestion Tab accepts, the way the engine's own guess after a turn does, and resolves { isShown }, false while the box holds text or a turn runs. Both are refused with takes { text } (a string) on anything else, both are false headless, and both go through every other plugin's hook of the same name. New in 2.1.268.
  • $.tool.check({ tool, input }) asks the engine what it would decide about a call without making the call, and resolves { decision, reason?, rule? } where the decision is allow, ask or deny. Nothing runs, no dialog opens, and neither the PreToolUse hooks nor the auto-mode classifier is asked. I ran it on a Read of /etc/hostname and got { decision: "allow" }, then on Bash with rm -rf / and got ask carrying the engine's own sentence about removing a critical system directory. When core decides on a settings rule the rule comes back as written, Bash(git push:*), so a plugin can tell the person which line of their config is about to stop them. New in 2.1.269.
  • $.config.list() returns every row of the /config menu in the menu's order, each { key, label, kind, value, options, provider, isLocked }, after every config.describe hook has had it, so a row another plugin hid is already gone. Forty rows on my box. $.config.set({ key, value }) changes one as if the person did it in the menu, and resolves { value } once written or { deny } when a hook refused, the value doesn't fit the row's kind, or a trusted source owns it. A plugin's own userConfig fields are rows here too, keyed <plugin>.<field>. Both are new in 2.1.269, and set writes to the real settings file, so test it on something you don't mind changing.
  • $.session.surfaces() returns every surface the session is drawing on, terminal first and then desktop and mobile in the order they attached, new in 2.1.269. It's [] in -p. $.session.surface() still answers and is deprecated.
  • $.env.get(name) and $.env.set(name, value) take the name as a string literal, so claude plugin validate can list what a module reads and writes from its source.
  • $.session.cwd() is async. So are most session reads.
  • $.ui.scroll({ to, in, block }) moves one of your own sites the way scrollIntoView does, and $.ui.focus({ requestId, key }) puts the focus ring on an element you drew there. Both since 2.1.271, both answer {} or { deny }, and both are refused when the keyboard isn't yours: a transcript row only scrolls while you're answering the person's own input, and focus only moves in a site that already holds the keys.
  • $.ui.blit({ requestId, key, cells }) repaints a mounted Raster in place, no redraw. Blits between frames fold into one, so $.clock.every(33, ...) is an animation.
  • $.agent.register(spec) (2.1.274) defines an agent type from a module, the same fields an agents/<name>.md file takes plus name, and the Agent tool dispatches it from the next turn as <plugin>:<name>. Answer agent.offer with { isOffered: false } for it and you have an agent only your own tool can spawn.
  • $.session.usage({ breakdown: "full" }) counts each category with the token-count API the way /context does. "summary" estimates locally, and the plain call is free. The declarations carry the argument by 2.1.272 and I didn't date it closer.

Two host operations exist in the bundle and do nothing, session.authorize (2.1.257) and flag.value (2.1.246). Both sit behind one gate that still answers false at 2.1.269. The flag noun is filtered out of the worker's table, so $.flag is undefined in a module, and the host would refuse the call anyway with reads a feature flag, which this build has no table of. session.authorize is meant to mint an auth handle from the session's own credential for $.http.fetch to spend, at most four live handles per plugin, and it returns null while the gate is off. 2.1.268 is the first build to declare it: the declarations carry $.session.authorize(), a SessionAuthorization of { handle, kind } or null, and an auth field on the fetch init that the engine turns into the credential header for a first-party host only. The declaration says null means the build has no credential to hold. The bundle says the gate is off. Same answer either way, so I still wouldn't build on either. I called it at 2.1.269 to be sure and it came back null.

The events

Event Since What arrives
tool.call 2.1.242 The tool's arguments beside tool, tool_use_id, agentId and an optional consent. Answer { deny }, { result, context } with an optional ref and text, or an isError: true result.
tool.describe 2.1.242 A tool's description, to rewrite.
prompt.submit 2.1.242 The prompt as submitted, with its origin. Answer the prompt or { drop: reason }.
prompt.section 2.1.242 The system prompt's sections.
agent.spawn 2.1.242 A subagent about to start.
turn.start, turn.step, turn.complete 2.1.242 The turn's lifecycle.
ui.render 2.1.242 One component instance to draw. See the next section.
ui.resolve 2.1.242 Which element table a component gets, resolved when the plugins load.
engine.create 2.1.242 Runs before $ exists.
PreToolUse 2.1.242 The one shell-hook event the first build accepted, by its bare name.
ui.press 2.1.247 A press on a Button a render hook drew.
agent.offer, skill.prompt, attribution.text 2.1.251 An agent being offered, a skill's prompt, the attribution line.
prompt.context 2.1.259 The first message's context blocks.
session.start 2.1.259 { cwd, surface, isInteractive } (interactive until 2.1.267). surface is terminal, desktop, mobile since 2.1.268, or null in -p and the SDK. Answer { cwd }.
ui.input, ui.select 2.1.260 An Input or Select element's value.
command.run, command.describe 2.1.265 A registered slash command being run or described. Answer { text, ref }.
classic.* 2.1.265 The shell-hook events as function hooks: classic.PreToolUse, classic.SessionStart and the rest.
session.receive 2.1.267 One inbound delivery, { origin, text, event }, before it's queued. Answer { text } or { consumed: reason }.
session.compact 2.1.267 A compaction about to run over messages. Answer { messages } or { skip }.
ui.message 2.1.267 Data a Client element's surface module posted.
prompt.fill, prompt.suggest 2.1.268 Text about to be written into the prompt box, or shown as its suggestion, with e.origin.kind naming the engine or the plugin that asked. next({ ...e, text }) rewrites it; answer { isFilled: false } or { isShown: false } to stop it.
tool.check 2.1.269 A permission decision being made about a call, { tool, input, tool_use_id? }, with the id there only when a real call is being decided. Answer { decision, reason?, rule? }.
config.set, config.describe, config.list 2.1.269 A /config row changing, one row being described for the menu, or the whole menu being listed. Answer { value } or { deny } on the first, the label and help text on the second.
session.attach, session.detach 2.1.269 A client joined or left the session's roster, { surface, clientId, viewport? }. The terminal's own binding raises nothing. Observation only, core echoes the id back.
session.surfaces 2.1.269 The surfaces the session draws on, as a call and as an event.
ui.blit, ui.scroll, ui.focus 2.1.271 Your own call on its way to the surface: the cells, the scroll target, the element. A hook above the painter can repaint the cells with next or answer { deny }.
agent.register 2.1.274 The agent type a plugin is defining, as it defined it. A hook above rewrites any of it, and the type stays the caller's.
plugin.register 2.1.269 One hooks module about to load, with its name, tier, root, version, provenance and a scanned list of what it hooks and calls. Answer { allow: true } or { refuse: reason }.

Since 2.1.268 e is typed Frozen, read-only to every depth, so e.command = "x" is a type error and next({ ...e, command: "x" }) is the way to change what passes on. The engine never let a hook mutate e in place, the types just say so now.

One event no longer takes an ordinary hook. 2.1.269 rewrote the dispatcher around async generators and turned turn.step into the one streaming event there is, because the model's answer arrives in pieces and a hook that waited for the whole thing couldn't change what had already been drawn. Its hook is async function* ($, e, next), return yield* next(e) passes it straight through, for await (const c of next(e)) yield f(c) rewrites the pieces on the way past, and yielding without ever calling next answers alone and runs nothing beneath you. A plain function is a type error there even when it returns next(e), and a chunk you've already yielded stays yielded, so a hook that dies halfway leaves what it drew. Everything else is still ($, e, next).

The new turn.step interface is also where a plugin gets to say which model handles a request, and plugin.register is where one plugin gets to refuse another's module before it loads. I've read both and run neither.

session.start is the one to watch. I'd written it down as a 2.1.242 event and it isn't. The 2.1.242 array has no session event at all, and the first bundle to list session.start is 2.1.259, so a module that registers tools from it does nothing on 2.1.242 through 2.1.258.

Drawing

A ui.render hook receives one component instance: e.component says which, e.surface is terminal, desktop, since 2.1.268 mobile or, since 2.1.273, vscode, e.requestId says which instance, e.props are the component's plain-data props, and e.viewport, once the surface has measured, is { columns, rows }. A width change re-runs every hooked site once the resize settles. A height change alone redraws nothing. $.ui.invalidate("ui.render") asks for a redraw when your own state changed.

You build trees from the table $.ui.resolve(e) returns, because a module has no element globals. At 2.1.268 the terminal table holds Box, Text, Button, Input, Select, Link, Code and Client. The desktop table has those plus Svg. The mobile table, new in 2.1.268 for the Claude mobile app, is Box, Text, Button, Link, Code and Svg: no Input, Select or Client, and a name a surface lacks draws a fragment. 2.1.269 is the first build whose declarations carry that table, and it says why the two controls are missing, the control protocol has a press message and no input or select message yet, so it's the wire and not the phone. The same release declares a ToolUse instance's props as tool, isRunning, isErrored and isInterrupted, where 2.1.267 had toolName, running, errored and interrupted, and the other components' booleans took the same is prefix. The first build allowed five element types, Box, Text, div, span and b. The tree check accepts Button from 2.1.247, when ui.press arrived, but $.ui.resolve kept answering those five names until 2.1.260, when Button, Input, Select and Link joined its table. Svg came in 2.1.259, Code in 2.1.265 and Client in 2.1.267, which is also the release that dropped div, span and b. A tree still using one of the three fails the check from 2.1.267 with "div" is not an element (the elements are Box, Button, Input, Select, Svg, Code, Client, Text, Link, from $.ui.resolve(e)). A keyed Box scopes hover styles since 2.1.267.

Two elements and a surface arrived after that, and I've drawn on none of them. Raster (2.1.271) is a fixed grid, 1 to 512 columns by 1 to 256 rows, every cell a code point and two colours packed into cells as base64, terminal only, and $.ui.blit repaints it without a redraw. Markdown (2.1.274) is on every surface, takes up to 10,000 characters of the markdown an assistant reply would write, and a press on one of its links arrives through ui.press with e.link.href naming the target, if you gave it onLinkPress. A link whose scheme isn't https:, http: or file: draws as plain text. The vscode table, in the declarations from 2.1.273, is the desktop's without Client: Box, Text, Button, Input, Select, Svg, Link, Code and Markdown. And since 2.1.274 a Box takes position: "absolute", which lifts it out of the flow and places it by top, left, bottom and right against its parent, painted over whatever was there first.

Return a tree, or next({ ...e, props }) to change what the engine draws, or next(e) to leave it alone. A tree that doesn't validate is not drawn. The engine draws its own component and writes a line to the debug log starting ui.render (<Component>): a hook returned a tree that does not validate, followed by the reason. When a drawing silently falls back, read that line first.

The components you can hook are thirteen at 2.1.268. Ten have been there since 2.1.242: AskUserQuestion, AssistantMessage, InfoNotice, SessionMode, Spinner, ToolGroup, ToolResult, ToolUse, TurnDuration and UserMessage. Then PromptHint (2.1.246), AbovePrompt (2.1.257, a panel above the prompt) and Pane (2.1.265, a framed region the surface places). The keys came later than the panel: ctrl+x ctrl+a toggles it and ctrl+x tab focuses it, both since 2.1.260, and since 2.1.267 a pane takes the focus binding too.

This is the pane pattern, cut down from a plugin that runs on this box. It assumes a counter command registered in session.start the way the hello plugin registers hello:

const PANE = "counter";

on("command.run", { command: "counter" }, async ($, e) => {
  await $.ui.open({ id: PANE, title: "Counter", focus: true });

  return { text: "Open. Esc leaves it." };
});

on("ui.render", { surface: "terminal", component: "Pane" }, async ($, e, next) => {
  if (e.requestId !== PANE) {
    return next(e);
  }

  const { Box, Text, Button } = $.ui.resolve(e);
  const count = (await $.store.get("count")) ?? 0;

  return Box({
    flexDirection: "column",
    paddingX: 1,
    children: [
      Text({ key: "count", children: `Pressed ${count} times` }),
      Button({
        key: "more",
        label: "Press me",
        onPress: async () => {
          await $.store.set("count", count + 1);
          $.ui.invalidate("ui.render");
        },
      }),
    ],
  });
});

Two things in there matter. The requestId guard, because every open pane from every plugin arrives at your Pane hook and you only own yours. And children passed as a prop, which is how the constructors take them when you call them directly. Buttons keep their handler in the plugin and raise ui.press. Keys reach an element only while it has focus. This snippet and the watcher below passed claude plugin validate together as one plugin. I didn't drive either one interactively, the plugin they're cut from is what I've run.

Work that outlives a turn

A hook runs inside one dispatch with a budget, and anything it started should stop on next.signal. Work meant to keep going belongs in session.start, kept alive with $.clock.every and $.clock.after. A watcher looks like this:

on("session.start", async ($, e, next) => {
  const poll = async () => {
    const seen = (await $.store.get("last")) ?? null;
    const res = await $.http.fetch("https://example.com/feed.json");

    if (!res.ok) return;

    const latest = JSON.parse(res.text).version;

    if (latest !== seen) {
      await $.store.set("last", latest);
      $.ui.toast(`New release: ${latest}`, { timeoutMs: 10000 });
    }
  };

  await poll();
  $.clock.every(15 * 60 * 1000, poll);

  return next(e);
});

Reloading the module drops those timers and runs register again in a fresh environment, so nothing you started survives a save. $.store does.

The tooling

/plugin-types writes two files from the running build into .claude/types (or a directory you give it): claude-code.d.ts, which declares the claude-code module and every event, $ method and element with doc comments, and claude-code-mcp.d.ts, the inputs of the MCP tools connected right now. The first is 347,416 bytes at 2.1.269 and is the reference. Its header carries the tsconfig that fits a hooks module:

{
  "compilerOptions": {
    "target": "es2023",
    "lib": ["es2023"],
    "types": [],
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "jsx": "react",
    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment"
  },
  "include": [".claude/types", "hooks"]
}

Regenerate after every update instead of editing. The command itself dates from 2.1.242, when it wrote only the MCP file. claude-code.d.ts appeared in 2.1.259.

Since 2.1.271 it writes a third file, claude-code-plugins.d.ts, an index of what every enabled plugin adds to $ through the types field its manifest declares, so a plugin you depend on is typed with nothing copied. The same release added claude plugin test <dir>, which runs a plugin's *.test.ts files in an environment like the one its hooks run in, with a claude-code/testing kit that hands each test the engine's own $, an on, and a mock whose clock, store and env answer from memory. I haven't run a test through it.

If you want to read the declarations before you have a build that writes them, there's a copy checked in at types/claude-code.d.ts, written by 2.1.274. Nobody hand-wrote it and it isn't mine, it's what Claude Code says about itself, so the MIT licence on that repo deliberately doesn't cover it and types/README.md next to it carries the terms and the regeneration command. It declares an early access surface that moves between releases, so read the copy your own build writes wherever the two disagree.

claude plugin validate <path>, with --json and --strict, reports the hooks and $ calls it can see in the module's source. I couldn't date those two flags from the bundles, because validate lives in a chunk the main bundle loads on demand.

In an interactive session the --plugin-dir folder is watched since 2.1.259, and saving a file reloads the module. CLAUDE_CODE_PLUGIN_DIR_WATCH=false turns the watch off, and /reload-plugins reloads by hand. When a reload would change the MCP tools or the LSP tool it stops and tells you the next message would re-read the whole conversation instead of using the cache, and /reload-plugins --force applies it anyway.

The plugin I built while working all this out is public at github.com/AnExiledDev/cc-changelog-plugin, and it's the worked example for everything above: one module of about sixteen hundred lines, nine tools, a pane, a poll on $.clock.every, and the types it was written against sitting in the same tree. Clone it, then CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./cc-changelog-plugin and it loads. Every tool in it is a thin pass-through of a public JSON route on this site, no key and no account, and the README lists all seventeen routes with the paging contract and what a 422, a 404 and a 429 mean, so you can point anything you like at them. They're held to 120 requests a minute per caller and 600 a minute across everybody, which is more than a plugin will ever need and little enough that the site stays up.

Since 2.1.260 the binary carries a plugin-authoring skill whose description says to load it before writing or changing such a plugin. It calls itself orientation and points at the declarations for the contract, and it's where I got the debug-line wording above. I didn't check whether every build lists it in the session's skills.

Traps

  • $.session.cwd() returns a promise. My first draft of the hello plugin used it synchronously and the declarations caught it.
  • A turn.step hook has to be an async function* since 2.1.269. Hand it a plain function that returns next(e) and the types reject it.
  • You can't read $.flag at all, even to check whether it's there. The static scan refuses typeof $.flag with $.flag is used as a value (a noun of $ bound, passed or read) and the module doesn't load, so test for the noun some other way or don't test for it.
  • $.config.set writes the person's settings file for real. There's no dry run, and $.tool.check is the only one of the new calls that's guaranteed to change nothing.
  • A second entry in modules is refused. One module per plugin, and it imports the rest.
  • Within a tier, chain order is enabledPlugins key order, first key outermost, and a fresh install lands on the end. A plugin that answers an event without next hides it from every plugin after it. Measured on 2.1.273 with two plugins on session.compact, and it's one hand-edit away from changing.
  • session.start doesn't exist before 2.1.259.
  • The $.fs rename in 2.1.267 removed the old names, and the $.session.turns rename in 2.1.268 did the same.
  • Three values that were strings or bare booleans at 2.1.267 are objects or renamed at 2.1.268: e.origin on ui.close and session.receive is { kind }, session.start reports isInteractive, and ToolUse props are tool and isRunning. A hook comparing e.origin === "plugin" matches nothing now.
  • $.ui.ask rejects in -p, and session.start reports surface: null there. A plugin that assumes a terminal breaks under the SDK.
  • $.prompt.submit from a hook that holds the turn is refused. The list is in the $ section.
  • Reading options without a userConfig block gives you a warning and nothing else.
  • A hook that throws is silently skipped from the model's point of view. Watch the debug log.
  • A tool result for a registered tool is typed unknown. Return a string and you know what the model sees.
  • Registering a tool name again replaces the tool. Command names are 1 to 64 characters of letters, digits, _ and -.

Where each piece landed

Each version links the entry on this site that reads the bundle.

Version Date What landed
2.1.242 24 Aug 2026 The runtime: register, on, $, the first events and nouns, five elements, per-plugin budgets, prompt.submit, audio, CLAUDE_CODE_HOOKS_SAME_THREAD. Off by default.
2.1.246 25 Aug 2026 Multi-file modules, $.session.surface, $.turn.abort, PromptHint, flag.value. The flag reader answered false for everyone.
2.1.247 26 Aug 2026 Button, ui.press, $.fs.ancestors.
2.1.251 28 Aug 2026 agent.offer, skill.prompt, attribution.text.
2.1.257 1 Sep 2026 $.model.fork, AbovePrompt, session.authorize.
2.1.259 2 Sep 2026 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS, session.start, prompt.context, claude-code.d.ts from /plugin-types, hot reload, Svg.
2.1.260 3 Sep 2026 $.process.run, ui.input, ui.select, the plugin-authoring skill.
2.1.265 8 Sep 2026 $.command, Pane with $.ui.open and $.ui.close, pattern event names, classic.*, Code.
2.1.267 9 Sep 2026 session.receive, session.compact, ui.message, .catch, the surface key, $.fs rename, $.settings.read, $.env, $.session.usage, hover styles, Client, and div, span and b leave the element table.
2.1.268 10 Sep 2026 $.prompt.fill, $.prompt.suggest and their events, session.turnCount becomes session.turns, the mobile surface, session.authorize declared, Frozen event arguments, { kind } origins, the is prefix on render props, a desktop Client message runtime, a surface module that spans files, and managed prependPlugins deciding whether sec-default@builtin is seated.

| 2.1.269 | 11 Sep 2026 | $.tool.check and its event, the $.config noun with config.set, config.describe and config.list, session.attach and session.detach, session.surfaces, plugin.register, and turn.step becoming a streaming hook. | | 2.1.271 | 14 Sep 2026 | $.ui.scroll and $.ui.focus, $.ui.blit and the Raster element, claude plugin test, the manifest types field and claude-code-plugins.d.ts, and load-order dependencies between modules. | | 2.1.273 | 15 Sep 2026 | The vscode surface table in the declarations, the spinner tips rebuilt as a plugin, the diff panel prototyped as a pane, and a user:plugins OAuth scope behind a gate. Nothing new on $. | | 2.1.274 | 16 Sep 2026 | $.agent.register and its event, the Markdown element with pressable links, position: absolute on a Box, four built-in plugins (diff and claude-test, tips, and an AGENTS.md plugin that's off by default behind tengu_agents_md_mod), and a deny rule that withholds a tool at registration. |

The pages from 2.1.257 on are findings, shorter than full entries, so they name what changed and don't always explain it. The 2.1.268 page is findings only, 422 of them, because that release was run with composition switched off. Everything in the tables above was dated from the bundles directly, so a thin page costs the tables nothing.

A second correction to my own site while I'm here. The 2.1.269 page tags 18 of its 38 plugin-hook entries "Not switched on", $.tool.check, $.session.surfaces and the $.config events among them, and that label is wrong for all three. I ran every one of them on 2.1.269 with the variable set: $.tool.check, $.config.list, $.config.set and $.session.surfaces all answer, claude plugin validate lists tool.check, config.describe, session.attach and plugin.register as hooks it can see, and a tool.check hook of mine saw the model's own Bash call go past with allow. None of those entries carries a gate string; the rating stage reaches for that label whenever a change is real but there is nothing a reader can type, which is every plugin API call there has ever been. The gate the runtime sits behind is real, and it is the only one.

What I couldn't pin down

  • Whether GrowthBook serves tengu_plugin_hooks_modules as true to anyone at 2.1.269. The variable works and that's what I used.
  • When validate --json arrived. The flag exists at 2.1.269 and the validate code isn't in the main bundle.
  • Anything on the desktop, mobile or vscode surface. I tested the terminal only, and -p. The declarations carry the element tables for all three, and I have no way to draw on them.
  • Anything from 2.1.271 on. $.ui.blit, Raster, $.ui.scroll, $.ui.focus, $.agent.register, the Markdown element and claude plugin test are all dated from the bundles and the declarations. I've run none of them.
  • session.refreshAgents. The 2.1.274 page lists it as an engine capability. It's in the bundle and not in the declarations, so I'm treating it as internal until a declaration says otherwise.
  • turn.step as a streaming hook, and plugin.register refusing a module. Both are new in 2.1.269, both are in the declarations, and I've run neither.
  • What session.attach and session.detach look like when a phone really attaches. $.session.surfaces() is [] in -p and ["terminal"] is all a terminal session ever shows me.
  • The surface key and Client element. I read the schema description and the ui.message contract and wrote nothing that uses them. 2.1.268 gave the desktop a runtime for them and let a surface module import its own files, and I haven't run either.
  • $.prompt.fill and $.prompt.suggest. I dated them from the bundle and the declarations and never ran them.
  • Pane count and command count limits. I've seen numbers mentioned and couldn't confirm them in the bundle, so none are stated here.
  • The changelog pages this links to come from a single sweep per release, and a second sweep of the same bundle recovers about half of the first one's entries. The version table is dated from the bundles, so it doesn't inherit that, but a page missing a related entry is expected.

Reading this with an agent

If you've pointed an agent here to build a plugin, the order that works is: set the variable, run /plugin-types, read the declarations for every event and $ call you plan to use, write the module, run claude plugin validate, then run it with --debug --debug-file and read the log before you believe anything. The declarations win over this post wherever they differ, because the surface moves and this was written against 2.1.267 and revised for 2.1.268 and again for 2.1.269, with the tier order paragraph measured on 2.1.273. If the agent can't run /plugin-types itself, give it types/claude-code.d.ts and the module next to it from github.com/AnExiledDev/cc-changelog-plugin, which is a plugin that works and the API it works against, in one clone.