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

Anatomy of a mod

plugin.json, hooks.json and register(): every file a mod needs and what each one may say.

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 is no build step, no bundler config, no entry in a registry. 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 ordinary plugin manifest and it does not know that 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 the one that makes it a mod, and at 2.1.272 it has exactly one key that matters:

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

All three mods Anthropic ships declare {"modules": ["./register.ts"]} and nothing else. The path is relative to hooks/. modules is a list, so a plugin can carry more than one, though nothing in the shipped code does and the per-plugin limits below apply to the plugin rather than to each module.

register, and the argument almost nobody uses

Your module exports one function. The declarations give it three lines:

export type HooksModule = { register: Register };
export type Register = (on: On, options: PluginOptions) => unknown;
export type PluginOptions = Readonly<Record<string, string | number | boolean | readonly string[]>>;

on registers hooks. options is whatever the person running your mod configured, flattened from the userConfig block in plugin.json, and it has a page of its own because its failure modes are worse than its shape suggests. None of the three built-in mods take it. They all declare export function register(on: On) with one parameter and stop there, which is worth knowing when you are reading their source for patterns: they are not modelling configuration for you.

register returns unknown and the runtime ignores what it returns, so an async register buys you nothing and costs you the one guarantee that matters, which is that every .catch is attached before register returns.

The import line

There is exactly one package you can import and it is not on npm:

import type { On } from "claude-code"

claude-code is a bare specifier the loader resolves itself. Run /plugin-types inside Claude Code to write the declarations to disk, point your tsconfig at them, and your editor knows all 83 events. At 2.1.272 that file is 10,843 lines and 410,087 bytes, and it opens by telling you what it is:

// Written by Claude Code 2.1.272.
// EARLY ACCESS: this surface may change between releases without notice.

Everything else you import is your own code, by relative path. Try anything else and the loader refuses the module, saying so in one line:

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

That covers node:fs, node:child_process, and every package in your node_modules. There is no dependency mechanism. A mod that needs a parser vendors the parser.

One convention from the built-ins is worth copying even though it looks wrong: they write .js on relative imports of .ts files. import { paneView } from './views/index.js' resolves views/index.ts. That is standard NodeNext behaviour and the loader follows it.

A mod is a closure

This is the part that reads oddly the first time. register runs once, and every hook you register is a closure over whatever you declared inside it. State lives in that closure. The diff mod opens its register with fifteen let bindings, two Maps and a Set before it registers anything:

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) => { /* ... */ })
}

There is no module-level mutable state to inject, no framework to hold it, and nowhere else to put it. Two sessions in one process get two register calls and two closures, which is why this is safe. The diff mod replaces its model immutably (model = { ...model, placement: e.props.placement }) even though nothing forces it to, because the render hook reads that binding and a half-mutated object during a redraw is a bug nobody enjoys.

One hook per event per plugin

Register the same event twice in one plugin and on throws. The declarations say it in four words, "a repeat throws", and it happens at load, so the whole module is dropped rather than the second registration being ignored. Two plugins may both hook tool.call. One plugin may not.

The workaround is the obvious one. Register once, branch inside. It is less pleasant than two registrations and it is also honest about what is happening, since two hooks on one event in one plugin would have had a composition order between them that nothing in the manifest expresses.

Three ways to name an event

The pattern argument takes three forms, and the third is documented nowhere except the type:

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

An exact name (tool.call). A glob (tool.*, classic.*, or bare *). Or a negation, which is any of those with a ! in front, except *, because a hook on nothing is not a hook.

Negation appears on neither the cheat sheet nor in the issue thread, and it works at runtime rather than only in the type checker. I confirmed that the hard way while narrowing a bug: on("!tool.call", ...) registers on every event except tool.call and is the workaround for the worktree isolation bug.

Namespace is recursive in the type, so globs nest. When a pattern matches several events, e narrows to the union of their event types, and a negation selecting none of them narrows e to never, which is the type system telling you the registration is pointless.

Matchers

The three-argument form takes a matcher between the pattern and the hook, and it is a partial object matched 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 is a legal value

The array form is a set membership test and the built-ins use it constantly. Note the spread on the third line: an as const tuple is readonly and the matcher type is not, so it needs copying.

MatcherData includes RegExp, which the cheat sheet does not mention. Narrowing bottoms out at NarrowDepth = 8, so a matcher nine levels deep stops narrowing the type even though it still matches at runtime.

Two things about matchers refuse the module rather than doing nothing. An empty array ({ tool: [] }) is refused, because it can never match. And a wrong-typed value for a key that 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 case.

The loader reads your source, and it is stricter than it looks

Before a module runs, the loader scans it for what it touches. This is how an organisation can withhold $.http from a plugin and know the plugin cannot route around it, and it is why the spelling rules on the cheat sheet are rules rather than style:

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

It is a dataflow analysis rather than a pattern match, and it follows $ into helper functions. Passing $ to a helper that does a null check on it produces this, verbatim, from claude plugin validate:

✘ modules../h.ts: armL: .../hooks/h.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 AST node kind, and quoted the line. const fs = $.fs is refused for the same reason, and so is returning $, spreading it, or logging it.

It checks shape and not spelling, though. $.fs.bogusVerb("x") passes validation and is duly listed in the capability inventory as calls: $.fs.bogusVerb. At runtime it is $.fs.bogusVerb is not a function, which skips the hook and runs whatever is beneath it. The validator is not a reference for which operations exist; the declarations are.

Getting $ out of a hook, legally

So $ cannot be bound, passed or stored, and everything your mod does outside a hook body needs it. The diff mod solves this and the solution is the single most useful pattern in the shipped source. Capture the calls, never the object:

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 site is still literally $.noun.verb(...) inside a hook body, so the scanner sees all of it and the inventory is complete. What escapes is a table of arrow functions, and those are ordinary values. The rest of the mod reads through host and never sees $ at all.

This also tells you something the declarations do not say outright: $ outlives its dispatch. next is explicitly "made once per dispatch per hook, frozen" and rejects once its dispatch is over. $ has no such wording and the diff pane calls through a $ captured at session.start for the rest of the session, from timer callbacks, hours later. That is Anthropic's own code doing it.

The real name for this is a port. Host is the interface the mod's own code depends on, session.start is the composition root, and the engine is one adapter behind it. The diff mod's own comment calls host "the host every pinned backend reads through", and it handles re-binding with a three-word function, const currentOf = (engine: Host): Host => host ?? engine, so a call arriving before session.start has bound anything uses the engine it was handed.

Check it before you run it

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

✔ Validation passed
  modules../hooks.ts: my-mod; events: session.start,tool.call; calls: $.fs.read, $.ui.status

calls: nothing on $ is a real output and it means what it says. A module whose only use of $ is $.plugin.name reports it, because $.plugin is data rather than a capability.

Validation is not a type check of your configuration defaults and it will pass a userConfig the loader then rejects. That trap is on the config page.

Running it

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

--plugin-dir loads the folder in place and reloads it when you save. Plain --debug captures none of the hooks output, so the two extra flags are not optional if you want to see anything. What you get is a complete census:

hooks worker spawned (one for every plugin)
hooks module my-mod loaded (worker, environment 1, tier user); events: session.start,tool.call
engine.create: no plugin-provided interfaces; $ built for my-mod
plugin.register: my-mod (user, my-mod@inline), judged by core alone: admitted
hooks module my-mod tool.call settled in 12.4ms (worker hop, next() included)

Every hook that starts gets a settle line, so a hook that never appears never ran. $ built for nobody means no hooks module loaded at all, and the usual cause is the gate.

Hot reload holds only for --plugin-dir. A mod installed under ~/.claude/skills/ served me stale code through three consecutive runs 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 will debug a change that did apply.

The suffix 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.