{"page":"anatomy","title":"Anatomy of a mod","blurb":"plugin.json, hooks.json and register(), and what each of them is allowed to say.","build":"2.1.286","sections":["The two manifests","register, and the argument nobody uses","The import line","A mod is a closure","Registering an event twice","Three ways to name an event","Matchers","The loader reads your source, and it's strict","Getting $ out of a hook without breaking the rules","Check it before you run it","Running it"],"limit":8000,"section":null,"offset":0,"count":7960,"total":19280,"next_offset":7960,"next_url":"https:\/\/changelogs.core-directive.com\/reference\/mods\/anatomy.json?offset=7960","markdown":"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.\n\n```text\nmy-mod\/\n  .claude-plugin\/plugin.json     the plugin manifest\n  hooks\/hooks.json               the hooks module manifest\n  hooks\/hooks.ts                 export function register(on, options) { ... }\n  README.md\n```\n\n## The two manifests\n\n`.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:\n\n```json\n{\n  \"name\": \"my-mod\",\n  \"version\": \"0.0.1\",\n  \"description\": \"What it does, in a sentence somebody will read in a list.\"\n}\n```\n\n`hooks\/hooks.json` is what makes it a mod, and the key that matters is `modules`:\n\n```json\n{ \"modules\": [\".\/hooks.ts\"] }\n```\n\nThe 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.\n\n## register, and the argument nobody uses\n\nYour module exports one function, and the declarations give it one line:\n\n```ts\nexport type Register = (on: On, options: PluginOptions) => unknown;\n```\n\n`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](\/reference\/mods\/config) 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.\n\nIf `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.\n\n## The import line\n\nThere's exactly one package you can import, and it isn't on npm:\n\n```ts\nimport type { On } from \"claude-code\"\n```\n\n`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:\n\n```text\n[moda] $.ui.log (to debug): PROBE-A ... atom=function read=function update=function derive=function memberOf=function ...\n```\n\nThe 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.\n\nThe 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:\n\n```text\n\/\/ Written by Claude Code 2.1.287.\n\/\/ Claude Code function hooks: the plugin API's TypeScript declarations.\n\/\/\n\/\/ EARLY ACCESS: this surface may change between releases without notice.\n```\n\nAt 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:\n\n```text\ntype 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\n```\n\nAnthropic's [create page](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/create#get-the-types-for-your-build) 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.\n\nEverything else you import is your own code, by relative path. Anything else and the loader refuses the whole module with one line:\n\n> a hooks module imports its own files by relative path and \"claude-code\", nothing else\n\nThat 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](\/v\/2.1.286), 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.\n\nEvery 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.\n\n## A mod is a closure\n\nThis 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 `Map`s and a `Set` before it registers a single hook:\n\n```ts\nexport function register(on: On) {\n  let host: Host | null = null\n  let isPaneOpen = false\n  let columns = 0\n  let model: Model = emptyModel()\n  const seen = new Map<string, Hunk[]>()\n\n  on('session.start', async ($, e, next) => { \/* ... *\/ })\n  on('ui.render', { component: 'Pane' }, async ($, e, next) => { \/* ... *\/ })\n}\n```\n\nThat'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.\n\nAnything 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](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/interface#keep-state) has the table of how long each one lasts, and both are on the [engine page](\/reference\/mods\/engine) here too.\n\n## Registering an event twice\n\nRegister the same event twice in one plugin with no matcher and it fails. `claude plugin validate` catches it before anything runs:\n\n```text\non(\"session.start\") is registered twice without a matcher; the first is at ...\/hooks\/hooks.ts:2\n```\n","previous_page":"overview","next_page":"lifecycle","url":"https:\/\/changelogs.core-directive.com\/reference\/mods\/anatomy","index_url":"https:\/\/changelogs.core-directive.com\/reference\/mods.json"}