Skip to content

Create a mod

Have Claude write a Claude Code mod for you, or build one by hand, then use generated types, validate and plugin test to keep it working.

A mod is a plugin with a hooks module: a JavaScript or TypeScript file whose functions Claude Code calls on events. There are two ways to make one: describe it to Claude, or write it yourself. Claude Code loads .js and .ts directly, so you need no Node.js, bundler or build step either way.

You need Claude Code v2.1.287 or later (claude --version). Not sure a mod is the right tool? The comparison table in /docs/plugins/mods/overview helps.

Ask Claude for a mod

In an interactive session, just describe it:

make a mod that shows how many minutes this session has been running above the prompt

Claude uses a built-in skill called plugin-authoring that knows where mods go, which events and methods your version supports, and how they load. It loads the skill itself, or you can run /plugin-authoring first.

What happens next

  1. Claude writes the files into a folder for this mod inside the session's mods folder, ~/.claude/dev-mods/<session-id>/, for example ~/.claude/dev-mods/3f2a9c1e-.../session-clock/. In default and acceptEdits permission modes you approve each file, because ~/.claude is a protected path (see /docs/permission-modes).
  2. You approve hot reloading. When the first file is saved, Claude Code asks whether to enable hot reloading for the session:
    • Enable for this session: mods in the session's folder load when the turn ends and reload after each turn that changes them. The answer persists if you resume the session.
    • Not now: nothing loads yet. The files stay put and load next time that session starts. Delete the folder to stop it ever loading.
  3. Check it loaded. Run /plugin, Tab to Installed, and look for it. You can switch it off there.
  4. Try it, and iterate. Tell Claude what to change. The mod reloads at the end of each turn that edits it.

Keeping it

A mod Claude writes only loads in the session that created it, and the session's mods folder is deleted once older than cleanupPeriodDays. To keep it, copy the folder somewhere permanent such as ~/mods/session-clock, then load it with claude --plugin-dir ~/mods/session-clock, or share it.

When a Claude-written mod will not load

It needs your approval in a trusted workspace where mods are allowed. It will not load when:

  • nobody can approve: claude -p, or dontAsk mode;
  • you have not accepted the workspace trust prompt;
  • mods are disabled: --safe-mode, --bare, disableAllHooks, or a managed policy.

Write a mod yourself

We will build edit-meter. It counts the files Claude edits, shows the count beside the spinner, and adds a /edits command that lists every file touched so far.

1. Folders and manifest

mkdir -p edit-meter/.claude-plugin edit-meter/hooks

PowerShell: New-Item -ItemType Directory -Force edit-meter\.claude-plugin, edit-meter\hooks.

edit-meter/.claude-plugin/plugin.json is an ordinary manifest; mods add no required fields:

{
  "name": "edit-meter",
  "version": "0.1.0",
  "description": "Counts files Claude edits and lists them with /edits",
  "author": { "name": "Cameron Shields" }
}

2. Point at your code

edit-meter/hooks/hooks.json. The modules key, with one path relative to this file, is what makes the plugin a mod:

{
  "description": "edit-meter hooks module",
  "modules": ["./register.js"]
}

3. Write the module

edit-meter/hooks/register.js:

// Files Claude has edited this session, in order, without duplicates
let touched = []

export function register(on) {
  // Once at start (and after each reload): add the command
  on('session.start', async ($, e, next) => {
    await $.command.register({
      name: 'edits',
      description: 'List the files Claude has edited',
    })
    return next(e)
  })

  // Edit and Write calls: let them run, then record the path if they succeeded
  on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
    const result = await next(e)
    if (!result.deny && !result.isError && !touched.includes(e.file_path)) {
      touched = [...touched, e.file_path]
      $.ui.invalidate('ui.render')
    }
    return result
  })

  // Only /edits reaches this hook, because of the matcher
  on('command.run', { command: 'edits' }, async () => {
    if (touched.length === 0) return { text: 'No files edited yet' }
    return { text: touched.length + ' file(s):\n' + touched.join('\n') }
  })

  // Keep the spinner, append our count once there is something to show
  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
    if (touched.length === 0) return next(e)
    return next({ ...e, props: { ...e.props, suffix: ' · ' + touched.length + ' files touched…' } })
  })
}

4. Load and try it

claude --plugin-dir ./edit-meter

Ask Claude for something that edits a couple of files. The spinner reads, for example, Thinking · 2 files touched…. Afterwards run /edits; the transcript shows the list prefixed with the plugin name, edit-meter: 2 file(s): ....

You can exercise a command without an interactive session:

claude -p "/edits" --plugin-dir ./edit-meter
# edit-meter: No files edited yet

If /edits is not in the command list, the module did not load; see /docs/plugins/mods/troubleshoot.

5. Edit while it runs

Leave the session open, change ' files touched…' to ' files changed…' and save. A transcript line says edit-meter reloaded and lists its hooks; the next spinner uses the new wording. Each reload calls register again, so touched resets to empty. To survive reloads, keep state in $.state or $.store (/docs/plugins/mods/interface).

How the module works

Every hook receives the same three arguments:

ArgumentWhat it is
$The mods API: every method a mod can call, grouped in namespaces like $.ui and $.command
eThe event as frozen plain data. For a tool call, e.tool plus the tool's arguments as fields (e.file_path)
nextPasses the event to later mods and then Claude Code's default behaviour, resolving to the result

In edit-meter, session.start observes (registers a command, then next(e)). tool.call observes after the fact by awaiting next(e) first. command.run answers: it returns a result and never calls next. ui.render rewrites: it passes a copy of the event with a new suffix. The second argument to on, such as { command: 'edits' }, is a matcher that filters which events reach the hook. More in /docs/plugins/mods/events.

Keep working on a mod

Have Claude change it

Start with the mod loaded so Claude's edits take effect in the same session:

claude --plugin-dir ./edit-meter

Then ask, for example "add a /edits-clear command that empties the list". Claude edits the module, runs claude plugin validate and fixes what it reports. A --plugin-dir folder is a protected path, so in default and acceptEdits modes you approve each edit. Changes reload when the turn ends.

Generated type definitions

Every time Claude Code loads or reloads a mod from --plugin-dir (or one Claude wrote), it writes .d.ts files into the mod's .claude-plugin/types/, describing exactly what your version supports:

FileDeclares
claude-code/index.d.tsEvery event with input and result, every mods API namespace and method, and the elements each surface can draw
claude-code-tools/index.d.tsBuilt-in tool inputs and results, so checking e.tool === 'Edit' narrows e
claude-code-mcp/index.d.tsInputs of MCP tools connected the last time you saved a file in the mod
<plugin>/index.d.tsWhat each plugin in your manifest's dependencies adds to the API
tsconfig.jsonCompiler options suited to a hooks module

If the mod has no tsconfig.json, Claude Code adds one at its root extending the generated one, so tsc -p ./edit-meter and your editor work immediately. A copy is also published as mods/types/claude-code.d.ts in the anthropics/claude-code GitHub repository, with the generating version on its first line.

Events and methods change between releases. When the generated types and any web page disagree (this one included), trust the types. claude-code/index.d.ts has a comment and example for every method; search it for the name, such as 'tool.call'.

See what Claude Code reads from your code

claude plugin validate ./edit-meter

This checks the manifest and runs the same static analysis Claude Code runs at load, without executing anything. The output includes lines along these lines:

  ❯ ./register.js hooks: session.start, tool.call{tool=Edit,Write}, command.run{command=edits}, ui.render{component=Spinner}
  ❯ ./register.js calls: $.command.register, $.ui.invalidate

✔ Validation passed

hooks: lists the events and their filters; calls: lists every API method. You may also see env reads:/env writes:, state reads:/state writes:, and lines like gating hook without .catch: tool.call for hooks that can refuse actions without a .catch handler. A missing event usually means a typo, reported as for example "tool.calls" is not an event.

Rules that keep static analysis happy

  • Write API calls in full: $, namespace, method, as in $.store.get('touched'). Passing $ to a top-level function in the same file is fine (calls: then shows $.store.get (via loadTouched)). Passing $ to a method, an inner function or an imported function fails, except the read and update helpers that $.state uses. Never alias, destructure or computed-index $ or a namespace: const ui = $.ui fails with $.ui is used as a value.
  • Event names are string literals in each on call. A variable or a loop fails with the event name passed to on() is not a string literal.
  • Do not shadow on inside register: "on" is declared again (shadowed).
  • Imports: only relative paths inside the plugin, plus the bare claude-code module for types and helpers.
  • Static imports only, at the top. import() fails with a dynamic import(); a hooks module imports its own files with an import declaration.
  • ES modules only, import not require.

Test it

claude plugin test runs *.test.ts files with no session, sign-in or network. Save as edit-meter/tests/edit-meter.test.ts:

import { expect, test } from 'claude-code/testing'

test('/edits lists each file once', async ($, on) => {
  // Stand in for Claude Code: every tool call "succeeds" without running
  on('tool.call', () => ({ result: 'ok' }))

  await $.tool.call({ tool: 'Edit', file_path: 'src/a.ts', old_string: 'x', new_string: 'y' })
  await $.tool.call({ tool: 'Write', file_path: 'src/b.ts', content: '' })
  await $.tool.call({ tool: 'Edit', file_path: 'src/a.ts', old_string: 'y', new_string: 'z' })

  const reply = await $.command.run({ command: 'edits', args: '' })
  expect(reply.text).toBe('2 file(s):\nsrc/a.ts\nsrc/b.ts')
})
cd edit-meter && claude plugin test

Stubbing models and stores, timers and drawings are covered in /docs/plugins/mods/test.

Sharing a mod

A mod is a plugin, so versioning and distribution are the same:

  • A few people: send the folder or a .zip.
  • Your team: list it in your own (possibly private) marketplace, and register that marketplace in the repo's settings so everyone gets it. See /docs/plugins/publish and /docs/plugins/host-marketplace.
  • Your organisation: an admin installs it through managed settings; see /docs/plugins/mods/admin.
  • Everyone: a public marketplace, or Anthropic's directory.

Before publishing, check the name: claude plugin validate rejects names that look like Anthropic's own, such as anything starting claude-. State in your README which Claude Code version you tested against, since the API moves.

Keep developing against the folder with --plugin-dir, not an installed copy. Installed plugins are cached by version, so edits will not reach an installed copy until you bump the version and reinstall.