Skip to content
Back to skills

Mod Dev

ASecurity

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.

  • 5 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 6, 2026
ai-agentstypescriptgoshellbashreactnodetestingdebugginggitapi

Works with

  • claude code
  • terminal
  • api

Security analysis

A100/100

Scanned October 7, 2026

npx -y skills add leopiney/wolfbud-claude-mod --skill mod-dev --agent claude-code

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.

Security grade badge for Mod Dev
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/leopiney-mod-dev/badge)](https://www.skillsdirectory.com/skills/leopiney-mod-dev)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.