Follow Discord
Sweep 06 Nov 2026 · 01:32Z Build v2.1.319 537 read Stable v2.1.311 Latest v2.1.318 Next v2.1.319 Feeds RSS JSON llms.txt llms-full.txt Unofficial
Mods · 02 of 10

Anatomy of a mod

plugin.json, hooks.json and register(), and what each of them is allowed to say.

Read against 2.1.286 Anthropic's page: Mods reference: files 19,280 characters 11 sections anatomy.json, paged

A mod is four files and one exported function. Two of the files are manifests, one is your code, and the README is optional. There's no build step, no bundler config and no registry to sign up to. You point Claude Code at a folder and it loads.

my-mod/
  .claude-plugin/plugin.json     the plugin manifest
  hooks/hooks.json               the hooks module manifest
  hooks/hooks.ts                 export function register(on, options) { ... }
  README.md

The two manifests

.claude-plugin/plugin.json is the normal plugin manifest and it has no idea hooks exist. Name, version, description and whatever else a plugin carries:

{
  "name": "my-mod",
  "version": "0.0.1",
  "description": "What it does, in a sentence somebody will read in a list."
}

hooks/hooks.json is what makes it a mod, and the key that matters is modules:

{ "modules": ["./hooks.ts"] }

The path is relative to hooks/. It's written as a list but it only takes one entry, a second one fails validation with "hooks.json modules names one hooks module per plugin; a second entry is refused". The same file can still carry ordinary hooks beside it. An old surface key is refused too, with a message telling you to name the module in the Client element instead.

register, and the argument nobody uses

Your module exports one function, and the declarations give it one line:

export type Register = (on: On, options: PluginOptions) => unknown;

on registers hooks. options is whatever the person running your mod configured, flattened out of the userConfig block in plugin.json, and it gets its own page because it fails in worse ways than it looks. Most of Anthropic's own mods take one parameter, register(on: On), and stop there, so don't expect their source to show you how to do configuration.

If register returns a promise the engine waits for it, so an async register works. Attach every .catch before it finishes though. A .catch added after register has returned throws.

The import line

There's exactly one package you can import, and it isn't on npm:

import type { On } from "claude-code"

claude-code is a bare name the loader resolves itself. A type import like that one is erased, but the module isn't empty at run time any more. It exports the five $.state helpers, and a 2.1.287 probe mod that imported them by value got real functions back:

[moda] $.ui.log (to debug): PROBE-A ... atom=function read=function update=function derive=function memberOf=function ...

The declarations still say "(at run time the import is empty)" in their header at 2.1.287, about 13,600 lines above the place they declare those same five exports.

The types come from the declarations Claude Code writes. When it loads a mod from a folder you own it writes them beside the mod as .claude-plugin/types/claude-code/index.d.ts, plus a tsconfig.json pointing at them, so your editor picks them up with no setup. At 2.1.287 that file is 14,916 lines and about 574 KB (14,711 lines and 566 KB at 2.1.286), it needs TypeScript 5.4 or newer, and it opens by telling you what it is:

// Written by Claude Code 2.1.287.
// Claude Code function hooks: the plugin API's TypeScript declarations.
//
// EARLY ACCESS: this surface may change between releases without notice.

At 2.1.286 a hidden /plugin-types <dir> command wrote a bigger version with the built-in tool shapes in it. 2.1.287 drops the command and writes the tool shapes into the same folder on every load, so the folder is the whole story now. For a probe mod of mine at 2.1.287 the debug log listed what it wrote:

type root of moda at /tmp/agent-aec17-probe/moda: entries claude-code, claude-code-tools, claude-code-mcp; 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/tsconfig.json, .claude-plugin/types/.gitignore, tsconfig.json

Anthropic's create page has a table of what each file declares. For scale, claude-code-tools was 5,088 lines covering 71 built-in tools, and claude-code-mcp was an empty interface because I had no MCP server connected. The root tsconfig.json is only added when the mod has none of its own. What their page doesn't mention is the .gitignore in the types folder, which holds a single *, so none of the generated files end up in your repository unless you force them in.

Everything else you import is your own code, by relative path. Anything else and the loader refuses the whole module with one line:

a hooks module imports its own files by relative path and "claude-code", nothing else

That rules out node:fs, node:child_process and every package in your node_modules. At 2.1.286 I put a package in a mod's node_modules and imported it by name, and validate refused it with cannot import "tinypad" (from hooks/hooks.ts) followed by that same line. 2.1.286 does install a plugin's dependencies from a package-lock.json or a text bun.lock, according to this site's 2.1.286 changelog, which read it from the bundle. I haven't tried an install, and nothing I ran lets a hooks module import what it puts there. So a mod that needs a parser still copies the parser in.

Every file has to be named .ts, .tsx, .jsx, .js, .mjs, .cjs, .mts or .cts, and all of them load as ES modules. One habit from Anthropic's mods looks wrong but is worth copying, they write .js when importing a .ts file. import { paneView } from './views/index.js' resolves views/index.ts. That's standard NodeNext behaviour and the loader follows it.

A mod is a closure

This reads oddly the first time. register runs once, and every hook you register closes over whatever you declared inside it, so that's where your state lives. The diff mod opens its register with fifteen let bindings, two Maps and a Set before it registers a single hook:

export function register(on: On) {
  let host: Host | null = null
  let isPaneOpen = false
  let columns = 0
  let model: Model = emptyModel()
  const seen = new Map<string, Hunk[]>()

  on('session.start', async ($, e, next) => { /* ... */ })
  on('ui.render', { component: 'Pane' }, async ($, e, next) => { /* ... */ })
}

That's the usual place for state. A variable at the top of the module works as well, it just goes back to its starting value every time the module reloads, which during development is every save. Two sessions in one process get two register calls and two closures, which is why closure state is safe. The diff mod swaps its model out whole (model = { ...model, placement: e.props.placement }) even though nothing makes it, because the render hook reads that binding and a half-changed object in the middle of a redraw is a miserable bug to chase.

Anything that has to outlive a reload goes in $.state or $.store. $.state lasts the session, survives a reload of the module, and is reset by /clear, /resume and /branch. Other plugins can read your values there, but only the owner writes them. $.store is your plugin's own JSON file of key-value pairs, kept from one session to the next. The atom, read and update helpers you import from claude-code are the short way to use $.state. Anthropic's Keep state has the table of how long each one lasts, and both are on the engine page here too.

Registering an event twice

Register the same event twice in one plugin with no matcher and it fails. claude plugin validate catches it before anything runs:

on("session.start") is registered twice without a matcher; the first is at .../hooks/hooks.ts:2

At load time it's on("session.start") registered twice, and the whole module is dropped, the second registration doesn't just get ignored. Globs and negations follow the same rule.

What you can do is register one exact event name several times with a different matcher on each. on("tool.call", { tool: "Read" }, ...) and on("tool.call", { tool: "Bash" }, ...) in the same plugin is fine, and they nest in the order you registered them, first outermost. Otherwise register once and branch inside the hook.

Three ways to name an event

The pattern takes three forms, and the third only shows up in the type:

export type Pattern = EventName | Glob | Negation;
export type Glob = '*' | `${Namespace}.*`;
type Negation = `!${Exclude<EventName | Glob, '*'>}`;

An exact name like tool.call. A glob, tool.*, classic.* or a bare *. Or a negation, which is either of those with a ! in front. !* isn't allowed because it would select nothing.

Negation isn't on the cheat sheet or in the issue thread, and it works at runtime as well as in the type checker. I found that out while narrowing a bug, on("!tool.call", ...) registers on every event except tool.call, and it's the workaround for the worktree isolation bug.

Namespace is recursive, so globs nest. When a pattern matches several events e becomes the union of their types, and a negation that selects none of the events you're checking narrows e to never, which is the type system telling you the registration does nothing.

Matchers

The three-argument form puts a matcher between the pattern and the hook. It's a partial object compared against the event:

on('tool.call', { tool: 'Bash' }, hook)                        // one value
on('command.run', { command: ['clear', 'resume'] }, hook)      // any of these
on('tool.call', { tool: [...Tools.EDITING_TOOLS] }, hook)      // spread a readonly tuple
on('tool.call', { command: /rm\s+-rf/ }, hook)                 // a RegExp tests the value as a string

An array means "any of these" and the built-ins use it everywhere. Note the spread on the third line, an as const tuple is readonly and the matcher type isn't, so it has to be copied. An operator-style object like { startsWith: 'p4' } never matches a string, it's treated as a partial object like everything else.

A matcher can nest eight levels deep and hold 256 values. Past either limit the runtime refuses it, and past eight levels the types stop narrowing too.

A few mistakes refuse the whole module instead of quietly doing nothing. An empty array ({ tool: [] }) is refused because it can never match. A value of the wrong type for a key any matching event declares ({ command: 5 }) is refused on every variant, so one typo in a glob hook's matcher takes the whole module down. Both fail at load, which is the good way to fail.

The loader reads your source, and it's strict

Before a module runs, the loader scans it for everything it touches. That's how an organisation can take $.http away from a plugin and know the plugin can't sneak around it, and it's why the spelling rules on the cheat sheet are rules and not style:

$ is always written $.noun.verb(…) literally; on("event") literally. the loader inventories both and refuses anything it cannot see.

It follows $ through your code, into helper functions too. Pass $ to a helper that null-checks it and this is what claude plugin validate says, the same at 2.1.283 and 2.1.286:

✘ modules../hooks.ts: bad: .../hooks/hooks.ts, compiled line 2 `if (obj === null || obj === undefined)`:
  $ itself is used in a BinaryExpression (bound, passed, spread, returned or read);
  $ is always spelled $.noun.event(...) at the call site, on is always on("<event>", hook),
  and next.to always next.to(e, "<tier>")

It found the helper, compiled it, named the kind of syntax node and quoted the line. const fs = $.fs is refused for the same reason, and so is returning $, spreading it or logging it.

Handing $ to a helper isn't the problem in itself. At 2.1.286 a makeHost($) whose body only writes $.command.register(spec) and $.ui.log(text) passes validate, and the inventory credits the helper: calls: $.command.register (via makeHost), $.fs.write, $.ui.log (via makeHost). It's what the helper does with $ that gets checked.

It checks the shape though, and not whether a verb exists. $.fs.bogusVerb("x") passes validation and shows up in the inventory as calls: $.fs.bogusVerb. At runtime it's $.fs.bogusVerb is not a function, the hook is skipped and whatever sits beneath it runs. So don't treat the validator as a list of what exists, the declarations are that.

Getting $ out of a hook without breaking the rules

So $ can't be stored, and anything your mod does outside a hook body still needs it. The diff mod has a neat answer, which is to capture the calls and leave the object alone:

type Host = {
  now: () => number
  readFile: (path: string) => Promise<string>
  status: (text: string | undefined) => Promise<void>
  invalidate: () => Promise<void>
}

let host: Host | null = null

on('session.start', async ($, e, next) => {
  host = {
    now:        () => $.clock.now(),
    readFile: path => $.fs.read(path),
    status:   text => $.ui.status(text),
    invalidate: () => $.ui.invalidate('ui.render'),
  }

  return next(e)
})

Every call is still literally $.noun.verb(...) inside a hook body, so the scanner sees all of it and the inventory stays complete. What escapes is a table of arrow functions, which are ordinary values, and the rest of the mod goes through host without ever seeing $.

It also tells you something the declarations never say outright, $ outlives the dispatch it came from. next is "made once per dispatch per hook, frozen" and rejects once that dispatch is over. $ has no wording like that, and the diff pane keeps calling through a $ it captured at session.start for the rest of the session, from timers, hours later. That's Anthropic's own code doing it.

If you want the proper name, it's a port. Host is the interface the mod's code depends on, session.start is where it gets wired up, and the engine is one implementation behind it. The diff mod's comment calls host "the host every pinned backend reads through", and it handles calls that arrive before session.start with a one-liner, const currentOf = (engine: Host): Host => host ?? engine.

Check it before you run it

claude plugin validate ./my-mod reads both manifests, compiles the module, runs the scan and prints what it found. A clean run lists your hooks, matchers included, and every $ call, which is the only place you can see the inventory an organisation would be governing:

Validating hooks: /tmp/my-mod/hooks/hooks.json

  ❯ ./hooks.ts hooks: session.start, tool.call{tool=Read}, tool.call{tool=Bash}
  ❯ ./hooks.ts calls: $.fs.read, $.ui.status

✔ Validation passed with warnings

The warning there is only a missing author in plugin.json.

Validation doesn't type-check your configuration defaults, and it'll pass a userConfig the loader then rejects. That trap is on the config page.

Running it

claude --plugin-dir ./my-mod --debug-file /tmp/hooks.log

--plugin-dir loads the folder where it is and reloads it when you save. At 2.1.272 plain --debug captured none of the hooks output, and I used to add --debug hooks as well. At 2.1.287 --debug-file on its own was enough, and the log from my probe run had 209 hooks module lines in it, the load lines and every settled in included. Anthropic's Read the debug log uses the same flag. What you get is a full census. From a 2.1.286 run on a machine with managed settings, trimmed:

hooks module cc-plugin-sec-default@builtin loaded (native, environment 1, tier prepend); events: classic.*,prompt.section,...
hooks worker spawned (one for every plugin)
hooks module my-mod@inline loaded (worker, environment 2, tier user); events: session.start
hooks module cc-plugin-agents-md@builtin loaded (native, environment 3, tier builtin); events: session.start,prompt.context,agent.spawn,tool.call
engine.create: no plugin-provided interfaces; $ built for cc-plugin-sec-default,my-mod,cc-plugin-agents-md,cc-plugin-telemetry,cc-plugin-diff
plugin.register: my-mod (user, my-mod@inline), judged by cc-plugin-sec-default: admitted
hooks module cc-plugin-sec-default@builtin classic.SessionStart settled in 1.7ms (native link, next() included)

Every hook that runs gets a settled in line, so a hook that never shows up never ran. Anthropic's own mods load even when yours are gated off, so check that your mod's name is in the $ built for list. If it isn't, it didn't load, and the same log has a line starting hooks module, your mod's name and not loaded: with the reason after it. To rule out the machine itself, run claude plugin test in an empty folder. no hooks module to load means mods can load there, and Anthropic's troubleshooting page has the other answers it gives.

Up to 2.1.283, reading options in register without declaring userConfig logged options requested but its manifest declares no userConfig. At 2.1.286 that line is gone from the binary, and a mod that does it just reads undefined with nothing in the log.

Don't name your mod after a built-in one. At 2.1.286 the old built-in names still count as the built-in's name. I loaded a --plugin-dir mod called sec-default on a managed machine and it was dropped with another plugin of that name loads first (a managed one, or an earlier source). One called agents-md went the other way. Mine loaded, and Anthropic's cc-plugin-agents-md was the one dropped, so that session lost its AGENTS.md support without a word outside the debug log.

--bare loads no hooks module from a plugin you installed or pointed at. At 2.1.286 a --plugin-dir mod under --bare logs installed plugins that are not managed load no hooks module in this mode (--bare). The 2.1.286 bundle also carries a $.tool.register refusal for it, refused: this session runs with --bare, which takes tools from the plugins its command line names and from no built-in one. That part is unverified. My mod never loaded under --bare, so it never got as far as registering anything.

Hot reload only works for --plugin-dir and the per-session folder the "Make a mod" skill writes to, which Anthropic's create page puts at ~/.claude/dev-mods/<session-id>/. A copy under ~/.claude/skills/ served me stale code through three runs in a row at 2.1.270 and only picked up the edit after a restart. claude -p always loads fresh. Develop against --plugin-dir and install afterwards, or you'll end up debugging a change that already applied.

The @inline in my-mod@inline is how the loader names a plugin loaded this way, and it matters in one place, configuring options through --settings needs that exact key.

Feedback