How mods are built in THIS repo - where a mod's files live (mods/<name>/, not ~/.claude/dev-mods), how to load and hot-reload it, the validate / type-check / test loop, the static-analysis rules `claude plugin validate` enforces, and test-kit gotchas. Load together with the built-in `plugin-authoring` skill whenever creating, editing, testing, debugging or shipping a Claude Code mod here.
Installs into .claude/skills of the current project.
Are you the author of Mod Dev?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/leopiney-mod-dev)
---
name: mod-dev
description: How mods are built in THIS repo - where a mod's files live (mods/<name>/, not ~/.claude/dev-mods), how to load and hot-reload it, the validate / type-check / test loop, the static-analysis rules `claude plugin validate` enforces, and test-kit gotchas. Load together with the built-in `plugin-authoring` skill whenever creating, editing, testing, debugging or shipping a Claude Code mod here.
---
# Building mods in this repo
`plugin-authoring` (built into Claude Code) is the authority on the mods API: load it first, every time. It names the type declarations for the running build (a ~20k-line `claude-code.d.ts`), and those beat any doc, this file included. This skill covers only what that skill leaves open: where mods live in this repo, and the gotchas we've already hit.
## Where a mod lives: override plugin-authoring's WHERE TO WRITE IT
`plugin-authoring` writes a mod into the session-scoped `~/.claude/dev-mods/<session-id>/`, and Claude Code deletes that folder after `cleanupPeriodDays`. In this repo the mod's home is the git tree:
```
mods/<mod-name>/
├── .claude-plugin/
│ ├── plugin.json # name, version, description, author, userConfig, "types" if it has a contract
│ └── types/ # GENERATED by the engine on every load: gitignored, never edit
├── hooks/
│ ├── hooks.json # { "modules": ["./register.ts"] }
│ └── register.ts(x) # export const register: Register = (on, options) => { ... }
├── types/index.d.ts # only when the mod keeps $.state: the PluginState contract
├── tests/*.test.ts # at least one, run by `claude plugin test`
└── README.md # what it does + the Claude Code version it was tested on
```
- **Name rule:** `plugin.json` `name` must not look like Anthropic's (`claude-*` fails validation). The repo is called `wolfbud-claude-mod`; the mod inside is just `wolfbud`.
- Write into `~/.claude/dev-mods/` only if the user explicitly wants a quick hot-reloaded prototype in the current session without restarting. Copy the result into `mods/` before ending.
## Loading it
- `claude --plugin-dir ./mods/<name>` loads it for one session. Repeat the flag to load several mods. All mods in the repo: `claude $(for d in mods/*/; do printf -- '--plugin-dir %s ' "$d"; done)`.
- An interactive session watches a `--plugin-dir` folder. Edits Claude saves during a turn reload when the turn ends; other saves reload once the folder has gone quiet. A reload runs `register` again and fires `session.start` again. Module variables reset; `$.state` (session) and `$.store` (across sessions) survive.
- The session we're in now loaded no `--plugin-dir`, so a new mod under `mods/` won't run here. Validate and test it from the shell, and tell the user the exact restart command.
- Desktop/SDK hosts: `CLAUDE_CODE_PLUGIN_DIRS` (absolute paths) in the `env` of `~/.claude/settings.json`, never the project's settings.
- Confirm it loaded: `/plugin`. The dim line reads `N mods active · <name>`. Failures show as one dim transcript line `<plugin>: <event> ... <reason>`; the full detail is in `claude --debug`.
- An installed copy is cached by version, so keep developing against `--plugin-dir`. The pre-commit hook (`scripts/bump-version.mjs`) bumps the patch once per branch when a commit changes what a mod ships; for a feature or breaking change, commit with `VERSION_BUMP=minor` or `major`. Don't bump by hand.
## The loop (run all three before calling a change done)
```bash
claude plugin validate mods/<name> # hooks: / calls: / env reads: lines + every refusal
npx -y -p typescript@5 tsc -p mods/<name> # once the mod has loaded once (engine lays tsconfig + types)
(cd mods/<name> && claude plugin test) # runs tests/*.test.ts against the real engine, no network
```
Before the mod has ever loaded there is no generated `tsconfig.json`. Type-check with a tsconfig kept outside the mod (in the scratchpad): copy the one in the header of `plugin-authoring`'s `types/claude-code.d.ts`, and make its `include` list that `.d.ts` file plus `mods/<name>/hooks` and `mods/<name>/tests`. `tsc` isn't installed globally; use `npx -y -p typescript@5`.
## Static-analysis rules (validate fails otherwise)
- Write every API call in full: `$.store.get(...)`. Never `const ui = $.ui`, never destructure `$` or a noun, never `$[name]`.
- `$` may be passed only to a function declared at the top level of the same file (validate then reports `$.http.fetch (via speak)`). Passing it to an imported function, a method, or a closure inside a hook fails. `read`/`update` from `claude-code` are the exceptions.
- Event names in `on(...)` and env names in `$.env.get(...)` must be string literals.
- Imports: only relative files of the plugin, plus `'claude-code'` (types and the `atom`/`read`/`update`/`derive` helpers). No npm packages, no `import()`, no `require`. ES modules only.
- Don't redeclare `on` inside `register`.
- The runtime has no DOM and no Node. Web APIs (URL, TextEncoder, AbortController, crypto.subtle) exist. Anything else outside the module goes through `$`: `$.http.fetch`, `$.fs`, `$.process`, `$.clock`, `$.store`, `$.env`.
## Test-kit gotchas (verified on 2.1.287)
- Hooks a test registers sit beneath the plugin and stand in for the engine. For the "op" events behind `$` calls (`http.fetch`, `audio.play`, `fs.read`, `process.run`, ...), the stub returns `{ value: <result> }` (or `{ deny }`), never the bare result:
```ts
on('http.fetch', (_$, e) => ({ value: { status: 200, ok: true, headers: {}, text: '{}' } }))
on('audio.play', () => ({ value: undefined }))
```
- For env, store and clock, use `mock.env(on, { NAME: 'v' })`, `mock.store(on, {...})` and `mock.clock`, all from `claude-code/testing`.
- `$.command.run({ command, args })` runs fine, but the test kit's types require the full input for `tsc`: add `origin: { kind: 'composer' }, presentation: { isFullscreen: false, columns: 120 }`.
- UI tests: `$.ui.mount({ plugin, surface, component, props })`. Loop the body over `['terminal', 'desktop'] as const`, and never assume a surface.
## Picking the shape (summary; the details are in plugin-authoring)
| Want | Use |
| --- | --- |
| pane / sidebar | `$.ui.open({ id, title })` + `ui.render` on `{ component: 'Pane', requestId: id }`. If opened unprompted it only appears at 144+ terminal columns |
| band above the prompt | `ui.render` on `{ component: 'AbovePrompt' }`; return `next(e)` when there's nothing to show |
| status line / toast | `$.ui.status(text)` / `$.ui.toast(text)` |
| slash command | `$.command.register` in `session.start` + `command.run` hook returning `{ text }` |
| react to Claude's reply | `turn.complete`: `e.answer`, `e.reason`, `e.agentId` (set for subagents) |
| guard a tool | `tool.call` with `{ tool }` matcher: `{ deny }`, `next({...e})`, or `await next(e)` |
| background work | start in `session.start`, keep alive with `$.clock.every/after`; a hook has a time budget (in-flight `$` calls don't count) and `next.signal` aborts it |
Drawn values belong in `$.state`, with each one declared in `types/index.d.ts` under the mod's name. Never draw from module variables, because a reload wipes them.
## References
- Docs: https://code.claude.com/docs/en/plugins/mods/overview, plus `/create`, `/interface`, `/events`, `/api`, `/test`, `/troubleshoot`, `/reference` under the same path
- Sample mods (token-weather, blast-radius, replay-theater): https://github.com/anthropics/claude-code-playground/tree/main/claude-code/mods
- Built-in mods with tests (diff pane, agents-md userConfig, telemetry contract): https://github.com/anthropics/claude-code/tree/main/mods
- Types online (may be newer than the installed build): https://github.com/anthropics/claude-code/blob/main/mods/types/claude-code.d.ts
- ElevenLabs specifics: the `elevenlabs-mod` skill in this repo