# Claude Code Mods: a field guide — build plugins that hook the engine, add commands and tools, and draw their own UI (panes, colour graphics, animation). By ruvnet.

> Source: <https://gist.github.com/ruvnet/a485e930b148185197fc53fd38b429ea>
> Published: 2026-10-02 00:55:50+00:00

**How to build Claude Code mods: plugins that hook the engine, add commands and tools, and draw their own UI inside Claude Code, from status lines to animated panes.**

*By [ruvnet](https://github.com/ruvnet). Written from building and shipping a real mod, with every pitfall we hit included.*

**Early access.** Mods (function hooks) are an early-access Claude Code feature, and the API moves between releases. Your build writes its own type definitions next to every mod you load (see [Setup](#2-setup)), and those types are the authority. Grep them before you trust any guide, this one included.

Throughout this guide:

**🧪 Field-tested** means we did it in a shipped mod and saw it work (or break) for real.
**📘 Engine docs** means it comes from the engine's reference and type definitions, and we haven't exercised it ourselves.

A mod is a Claude Code plugin whose behaviour is a **hooks module**: JavaScript or TypeScript that exports `register(on, options)`. Inside it, `on(event, hook)` attaches middleware to the engine's own events. Each hook can watch an event, rewrite it, or answer it in place of the engine.

| You want… | Mod mechanism | 
|---|---|
| Block or rewrite a tool call (protect `.env` , normalise commands) | `on('tool.call', { tool }, …)` returning`{ deny }` or`next({ ...e, … })` | 
| React to the prompt, or rewrite it | `on('prompt.submit', …)` | 
| Add or replace a system-prompt section | `on('prompt.compose', …)` | 
| A slash command | `$.command.register` +`on('command.run', …)` | 
| A status-line entry | `$.ui.status(text)` | 
| A toast | `$.ui.toast(text)` | 
| A band of UI above the prompt | `on('ui.render', { component: 'AbovePrompt' }, …)` | 
| A pane beside the conversation | `$.ui.open({ id })` +`on('ui.render', { component: 'Pane' }, …)` | 
| Draw a slash command's output as UI | `on('ui.render', { component: 'CommandOutput', props: { command } }, …)` | 
| A tool the model can call | `$.tool.register` + serve it in`tool.call` | 
| A subagent type | `$.agent.register` | 
| Background work | `$.clock.every` /`$.clock.after` , started in`session.start` | 
| Run programs, read files | `$.process.run` (argv, no shell),`$.fs` | 
| Ask a model something | `$.model.complete` ,`$.model.fork` | 
| Colour graphics and animation | `Raster` cells +`$.ui.blit` | 
| Mouse- and keyboard-driven widgets | `Client` surface modules | 

The module runs in a sandbox of its own: **no DOM, no Node** (no `fs`, `Buffer`, `process` or `node:` imports). Everything outside it goes through `$`. It may import only its own files (and types from `claude-code`). Dynamic `import()` is not allowed.

**Turn mods on** (early access):

```
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
# or persistently, in ~/.claude/settings.json:
#   "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" }
```

**Load a mod** (pick one):

```
claude --plugin-dir ./my-mod                         # this session only
# ~/.claude/settings.json → "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/abs/path/my-mod" }   # every session
```

Both are watched in an interactive session: saving a file reloads the module.

**Hot reload while Claude writes the mod (🧪).** The built-in `plugin-authoring` skill watches a per-session folder, `~/.claude/dev-mods/<session-id>/<mod-name>/`.

- The first time a mod is written there, Claude Code asks once: *Enable hot reloading for this session?*
- After you agree, the mod reloads when each turn ends.
- A reload runs `register` again in a fresh environment:**module variables reset and old timers are dropped** . Values in`$.state` and`$.store` survive (see[§6](#6-state-module-variables-state-store) ).

**Read the types (🧪).** Once a mod loads, the engine writes `<mod>/.claude-plugin/types/`:

- `claude-code/index.d.ts` : the whole API, every event,`$` method and element prop. About 14k lines, so grep it.
- `claude-code-tools/index.d.ts` : this build's built-in tools, so`e.tool === 'Bash'` narrows`e` .
- `claude-code-mcp/index.d.ts` : the MCP tools that were connected.
- A `tsconfig.json` , so`tsc -p <mod>` type-checks.

```
grep -n "hotkey" my-mod/.claude-plugin/types/claude-code/index.d.ts
```

**Two commands you'll run constantly:**

```
claude plugin validate ./my-mod      # what the module hooks and calls, and anything the engine would refuse
claude --debug                       # every refused tree and skipped hook, with the reason
```

During a hot-reload session, a hook that fails or a tree that doesn't validate also shows up as one dim transcript line, for example `my-mod: ui.render (Pane) refused: <reason>; the engine drew its own`. **Read those lines first.**

Three files:

```
my-mod/
├── .claude-plugin/plugin.json
└── hooks/
    ├── hooks.json
    └── register.mjs         # or .ts / .tsx / .js
// .claude-plugin/plugin.json
{
  "name": "my-mod",
  "version": "0.1.0",
  "description": "What it does, in one line",
  "userConfig": {                       // optional: settings users change with `claude plugin configure my-mod`
    "refreshSeconds": { "type": "number", "title": "Refresh (s)", "default": 15 },
    "mode": { "type": "string", "title": "Mode", "default": "fast", "options": ["fast", "full"] }
  }
}
// hooks/hooks.json: one module, path relative to this file
{ "modules": ["./register.mjs"] }
// hooks/register.mjs
/** @type {import('claude-code').Register} */
export function register(on, options = {}) {
  on('session.start', async ($, e, next) => {
    $.ui.toast(`${$.plugin.name} loaded`);
    return next(e);
  });
}
```

**The hook contract.** Every hook is `($, e, next)`:

- `$` : the engine. Every call is spelled noun, then method:`$.ui.open` ,`$.clock.every` ,`$.process.run` .
- `e` : the event's input, a frozen plain value.
- `next(e)` : run the plugins beneath, then the engine's own behaviour. It resolves to the event's result.

So a hook can:

- **pass** :`return next(e)`
- **rewrite** :`return next({ ...e, command: e.command.trim() })`
- **observe the result** :`const r = await next(e); /* look at r */ return r;`
- **answer for itself** : return a value without calling`next` .

**Options are user input (🧪).** `options` holds the `userConfig` values, so bound and validate them before use:

``` js
const num = (v, d, lo, hi) => (Number.isFinite(Number(v)) ? Math.min(Math.max(Math.round(Number(v)), lo), hi) : d);
const refreshMs = num(options.refreshSeconds, 15, 5, 3600) * 1000;
```

**JSX is optional.** `.tsx` modules can use JSX (the factory is the global `h`). Plain `.mjs` with function calls, `Box({ children: [...] })`, works too, and is what our mod uses.

📘 Engine example: refuse edits to `.env` files, and flag failed Bash commands in the status line.

``` js
const PROTECTED = /(^|\/)\.env(\.|$)/;

export function register(on) {
  on('tool.call', { tool: 'Edit' }, ($, e, next) =>
    PROTECTED.test(e.file_path) ? { deny: `${$.plugin.name}: ${e.file_path} is protected.` } : next(e));

  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    const ran = await next({ ...e, command: e.command.trim() });
    $.ui.status(ran.deny === undefined && ran.isError ? `failed: ${e.command.slice(0, 40)}` : undefined);
    return ran;
  });
}
```

🧪

``` js
export function register(on) {
  on('session.start', async ($, e, next) => {
    await $.command.register({ name: 'hello', description: 'Say hello', argumentHint: '[name]' }).catch(() => {});
    return next(e);
  });

  on('command.run', { command: 'hello' }, async ($, e) => {
    const who = String(e.args || 'world').trim();
    $.ui.toast(`hello, ${who}`);
    $.ui.status(`said hello to ${who}`);   // undefined clears it
    return { text: `Hello, ${who}!` };      // the command's output row (the model reads it too)
  });
}
```

📘 Engine example: show the last turn's duration and tool count, with a Hide button. The values live in `$.state`, so they survive hot reloads and redraw their readers automatically.

``` js
import { atom, read, update } from 'claude-code';

const last = atom({ plugin: 'turn-band', key: 'last' } as const, null);
const hidden = atom({ plugin: 'turn-band', key: 'isHidden' } as const, false);

export const register = (on) => {
  let startedAt = 0, tools = 0;
  on('prompt.submit', async ($, e, next) => { startedAt = await $.clock.now(); tools = 0; return next(e); });
  on('tool.call', ($, e, next) => { tools += 1; return next(e); });
  on('turn.complete', async ($, e, next) => {
    const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000);
    await update($, last, () => ({ seconds, tools }));
    return next(e);
  });
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
    const turn = await read($, last);
    if (e.props.hasSurvey || !turn || (await read($, hidden))) return next(e);   // yield to surveys
    const { Box, Text, Button } = $.ui.resolve(e);
    return <Box><Text dimColor>Last turn: {turn.seconds}s, {turn.tools} tools </Text>
      <Button key="hide" label="Hide" onPress={() => update($, hidden, () => true)} /></Box>;
  });
};
```

State values are declared in a type contract (`types/index.d.ts`, named in `plugin.json` as `"types"`) and checked by `claude plugin validate`:

```
declare module 'claude-code' {
  interface PluginState { 'turn-band': { last: { seconds: number; tools: number } | null; isHidden: boolean } }
}
```

🧪 The core pattern from our shipped mod: a command opens a pane, a timer refreshes data from a CLI, and the pane redraws.

``` js
const PANE = 'live';

export function register(on, options = {}) {
  let host = null, data = null, stopTimer = null, busy = false;

  async function refresh() {
    if (!host || busy) return;
    busy = true;
    try {
      const run = await host.run(['node', host.cli, 'status', '--json'], { timeoutMs: 30_000 });
      try { data = JSON.parse(run.stdout); } catch { data = { ok: false, reason: 'cli_error' }; }
      data.at = await host.now();                 // ⚠ $.clock.now() is a Promise
    } finally { busy = false; host.invalidate(); }
  }
  function startPolling() {
    if (stopTimer) return;
    const t = host.every(15_000, () => { void refresh(); });
    stopTimer = typeof t === 'function' ? t : () => t?.cancel?.();
    void refresh();
  }
  const stop = () => { stopTimer?.(); stopTimer = null; };

  on('session.start', async ($, e, next) => {
    host = {
      cli: `${$.plugin.root}/../bin/cli.js`,      // find files beside the mod
      run: (argv, init) => $.process.run(argv, init),
      every: (ms, fn) => $.clock.every(ms, fn),
      now: () => $.clock.now(),
      invalidate: () => $.ui.invalidate('ui.render'),
    };
    await $.command.register({ name: 'live', description: 'Open the live pane' }).catch(() => {});
    return next(e);
  });

  on('command.run', { command: 'live' }, async ($) => {
    await $.ui.open({ id: PANE, title: 'Live', focus: true, closeOnEscape: true, holdToasts: true, rows: 16 });
    startPolling();
    return { text: 'Live pane open.' };
  });

  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
    if (e.requestId !== PANE) return next(e);
    if (host && !stopTimer) startPolling();       // a reload kept the pane open: resume
    const { Box, Text, Button } = await $.ui.resolve(e);
    return Box({ flexDirection: 'column', paddingX: 1, children: [
      Text({ bold: true, color: 'cyan', children: 'Live' }),
      Text({ dimColor: true, children: data ? `updated ${new Date(data.at).toLocaleTimeString()}` : 'waiting…' }),
      Button({ key: 'refresh', label: 'Refresh (r)', hotkey: 'r', onPress: () => { void refresh(); } }),
    ] });
  });

  on('ui.close', { id: PANE }, async ($, e, next) => { stop(); return next(e); });
  on('session.end', async ($, e, next) => { stop(); return next(e); });
}
```

Why each line is there:

- **`$.process.run(argv)` has no shell (🧪).** Pass fixed argv arrays and validate anything from settings. A failed run becomes an honest error object, never a crash.
- **`$.plugin.root` finds sibling files (🧪).** A mod can't use`import.meta.url` with`node:url` . Our mod ships at`<pkg>/mod` , so its CLI is`` `${$.plugin.root}/../bin/cli.js` `` .
- **Resume polling from the render hook (🧪).** A hot reload or resumed session reruns`register` with empty variables while the engine keeps the pane open, so the pane sat on "waiting…" forever. If the pane is being drawn and nothing is polling, start polling.
- **Stop timers** on`ui.close` and`session.end` .

📘

``` js
on('session.start', async ($, e, next) => {
  await $.tool.register({
    name: 'room_status',
    description: 'Current occupancy of the room sensors',
    inputSchema: { type: 'object', properties: {}, additionalProperties: false },
  });
  return next(e);
});
// Serve it: a hook answers with { result }. Core validates it against any output schema
// and maps it for the model.
on('tool.call', { tool: 'mcp__my-mod__room_status' }, async ($, e) => ({ result: await readSensors() }));
```

- The tool is listed to the model as `mcp__<plugin>__<name>` .
- Register it in `session.start` , which is awaited before the first prompt, so it exists from turn one.
- A hook can also add `context: ['…']` : text the model reads after the result and the user never sees.

A render hook gets `e.surface`: `terminal`, `desktop`, `vscode` or `mobile`. The elements come from that surface's own table, via `const { Box, Text, Button } = $.ui.resolve(e)`. **They are not globals.**

| Element | Notes | 
|---|---|
| `Box` | Flexbox subset: `flexDirection` ,`gap` ,`padding*` ,`flexGrow` ,`justifyContent` ,`borderStyle` ,`borderColor` ,`position: 'absolute'` ,`hover` .**Not all of CSS:** 🧪`flexBasis` got the whole tree refused. | 
| `Text` | `color` ,`bold` ,`dimColor` ,`italic` ,`wrap` (`'truncate-end'` keeps rows one line) | 
| `Button` | `key` ,`label` ,`hotkey` ,`onPress` ,`variant: 'primary'` ,`plain` ,`role: 'dismiss'` | 
| `Input` ,`Select` | Not on `mobile` | 
| `Markdown` | Model-style text: headings, lists, tables, code, links | 
| `Raster` | **Terminal only.** A grid of coloured cells (§5.4) | 
| `Image` | **Terminal only.** Pixels in kitty/Ghostty, the`alt` text elsewhere | 
| `Client` | Your own drawing module with a frame clock and input (§5.6). Not on `vscode` or`mobile` | 
| `Svg` | Remote surfaces only | 

**A tree with an element the surface lacks is refused whole** (🧪). Guard per surface:

``` js
const ui = $.ui.resolve(e);
const picture = ui.Raster ? ui.Raster(grid.toRaster('chart')) : ui.Text({ dimColor: true, children: 'open in the terminal for the chart' });
```

**Keep drawing pure (🧪).** Write `viewOf(ui, model, opts)` → tree. Test it in plain Node with stub elements (`const el = t => p => ({ type: t, props: p })`). Reuse the same functions for animation frames.

A pane is seated in one of two places, given by `e.props.placement`:

| Placement | When | Size | 
|---|---|---|
| `dock` | Fullscreen terminal, ≥ 110 columns | Beside the transcript, floor to ceiling | 
| `inline` | Everything else | Above the prompt, **a third of the screen by default** | 

🧪 Our first pane was designed docked. Inline, most users saw it **cut off**: the bottom row of buttons never appeared. A "compact" redesign with no borders read as broken, so we reverted it. What fixed it was asking for the height each view needs:

```
await $.ui.open({ id: PANE, title: 'Live', rows: 26 });     // inline height wanted; the dock ignores it
// switching views? open again with the new rows: each open sets them anew
```

- **Size to the box you get, not the screen:**`e.props.bodyColumns` (narrower when docked) and`e.props.scroll.bodyRows` .
- A tree taller than the box scrolls with the arrow keys, but only while the pane is focused.
- `e.viewport.isFullscreen` tells you whether a dock is possible at all. Only open a pane*unasked* (from`session.start` or a timer) where it would be a sidebar.
- 🧪 A 20-line Node script that sums a tree's rows (text = 1, raster = its `rows` , borders + 2, plus gaps) is a cheap check that every view fits the rows it requests.

``` js
Button({ key: 'tab-2', label: 'Chart (2)', hotkey: '2', onPress: () => setView('chart') })
```

A `hotkey` is one digit or one lowercase letter, and **it fires only while the pane holds the keyboard.** Anything typed into the prompt goes to the prompt. 🧪 This was our most-reported "bug":

1. With no focus request, `2` arrived in the conversation as a chat message.
2. With `focus: true` alone, the pane still reported`isFocused: false` in our session.
3. The engine docs give the **dialog form** . Open with`focus` ,`closeOnEscape` and`holdToasts` together, and the pane "takes the keys, Tab and the arrows walk its buttons, Esc closes it". Treat it as the documented route, and check`isFocused` on your own setup.

```
await $.ui.open({ id: PANE, title: 'Live', focus: true, closeOnEscape: true, holdToasts: true, rows: 26 });
```

Focus is **a request, not a grant**: it's refused while the user has text in the composer, while a dialog is up, and so on. Two defences:

- 
**Say when the pane lacks the keys.** It's in the render input as`e.props.isFocused` :

```
e.props.isFocused === false && Text({ dimColor: true, children: 'click the pane or press ctrl+x tab to use the keys' })
```

- 
**Mirror every key as a slash subcommand** (`/live chart` ), so nothing depends on focus.

📘 More: `autoFocus` on an element, `$.ui.focus({ requestId, key })` to move the ring, `ui.focus` events, and `action: 'app:…'` on a Button to bind it to one of the engine's keybinding actions (pressable from the prompt).

🧪 `Raster` is a fixed grid of terminal cells, each a glyph with 24-bit foreground and background colours. It is **one element however many cells it has**, so never build a `Box` per cell. It works in any truecolor terminal (Windows Terminal included), because it isn't a pixel protocol.

```
ui.Raster({ key: 'chart', columns: 64, rows: 12, cells: '<base64>' })
```

`cells` is the base64 of little-endian `u32` triplets, row-major, one triplet per cell: `[codePoint, fg, bg]`. A colour is `0xRRGGBB`, and `0x01000000` means "the terminal's own colour". With no `Buffer` available, encode base64 yourself:

``` js
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
export function toBase64(b) {
  let s = '', i = 0;
  for (; i + 2 < b.length; i += 3) { const n = (b[i] << 16) | (b[i + 1] << 8) | b[i + 2]; s += B64[n >> 18 & 63] + B64[n >> 12 & 63] + B64[n >> 6 & 63] + B64[n & 63]; }
  if (b.length - i === 1) { const n = b[i] << 16; s += B64[n >> 18 & 63] + B64[n >> 12 & 63] + '=='; }
  else if (b.length - i === 2) { const n = (b[i] << 16) | (b[i + 1] << 8); s += B64[n >> 18 & 63] + B64[n >> 12 & 63] + B64[n >> 6 & 63] + '='; }
  return s;
}
export class Grid {
  constructor(columns, rows) {
    Object.assign(this, { columns, rows });
    this.cells = new Uint32Array(columns * rows * 3);
    for (let i = 0; i < columns * rows; i++) this.cells.set([0x20, 0x01000000, 0x01000000], i * 3);
  }
  set(x, y, ch, fg = 0x01000000, bg = 0x01000000) {
    if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return;
    this.cells.set([typeof ch === 'number' ? ch : ch.codePointAt(0), fg >>> 0, bg >>> 0], (y * this.columns + x) * 3);
  }
  toRaster(key) {
    return { key, columns: this.columns, rows: this.rows, cells: toBase64(new Uint8Array(this.cells.buffer)) };
  }
}
```

Test `toBase64` against `Buffer.from(bytes).toString('base64')` in plain Node.

Techniques that make cell graphics look good (🧪, all used in the case study):

| Technique | How | Good for | 
|---|---|---|
| **Half blocks** | Glyph `▀` (U+2580): fg = upper pixel, bg = lower pixel | Double vertical resolution: heat maps, waterfalls, images | 
| **Braille** | U+2800 + bit mask; each cell is a 2×4 dot grid | Line charts, arcs, radar fans, plots. One colour per cell, so give elements priorities and the highest wins | 
| **Percentile colour limits** | Map the 5th–95th percentile to the colour ramp, not min/max | One outlier no longer flattens the picture | 
| **Fit chart scales to the data** | Pad the data range to a minimum span; shade reference bands only where they overlap | A reference band 40–180 turned a real 68–74 rhythm into a flat line | 
| **Perceptual ramps** | near-black → indigo → cyan → emerald → amber → white | Readable on dark themes | 

🧪 A full redraw is rate-limited: `$.ui.invalidate` allows about 10 a second (30 for the shown pane). Use it for *state* changes. For motion, `$.ui.blit` repaints **one mounted, keyed Raster in place** with no render pass: up to 120 blits a second are accepted and about 60 shown.

``` js
const stopAnim = $.clock.every(80, async () => {                 // ~12 fps
  const t = await $.clock.now();                                  // real time, not an assumed tick
  const grid = drawFrame(model, { columns, rows, t });            // same function the render uses
  const { cells } = grid.toRaster('chart');
  Promise.resolve($.ui.blit({ requestId: PANE, key: 'chart', cells, columns, rows })).catch(() => {});
});
```

Rules we learned the hard way:

- **The size must equal the mounted Raster's** , or the blit is refused (a resize is a redraw). Build the first paint and every frame from one function, and unit-test that each picture keeps its size across`t` .
- **Don't `await` a blit.** It resolves only once a frame is painted, and blits between frames fold anyway. Fire and forget.
- **Use the real clock.** We once advanced time by a fixed 80 ms per tick. When timers ran late, "pulses at the measured rate" slowed down, and phases jumped on every full render (which read the real clock). A code review caught it.
- **Say what an animation means.** Decoration should read as decoration. If motion encodes data, label it, for example "pulses at the device-reported rate: a metronome, not a waveform".

📘 `Image` elements swap pixel sources at frame rate with `$.ui.blit({ requestId, key, source })`, where the source can be another process's shared-memory buffer. That's how a mod could show a live video feed in kitty or Ghostty.

📘 *Not used in our shipped mod; summarised from the type definitions.*

`Client({ key, module: './widget.mjs', props, width, height })` hands a region of your tree to a **surface module**: a function `(props, surface) => tree` that runs on the drawing thread. It gets:

- `surface.elements` (Box, Text, Button… but**no**`Raster` ,`Image` or nested`Client` );
- local `surface.state` /`setState` ;
- `surface.columns` /`rows` ;
- `surface.every(ms, fn)` : its own frame clock;
- `surface.onPointer(fn)` : down, move, up, enter, leave, with sub-cell positions on terminals that report pixels;
- `surface.onKey(fn)` , once a click gives it focus;
- `surface.post(data)` to message your hooks module, as`ui.message` .

It's the tool for games, drag-to-pan charts and drawing canvases: anything that needs per-frame input without round-tripping through your hooks. The module path must be a string literal. Three `setState` calls in a row with no input between them counts as a render loop and unmounts the instance.

📘 From the engine docs:

- **`Markdown`** draws model-style text. With` key` and`onLinkPress` , a click on a link raises`ui.press` with its`href` instead of opening it.
- **`Image`** shows PNG or RGBA bytes, or a file or shared-memory name. Pixels appear in kitty/Ghostty, the`alt` text elsewhere.
- **Hover cards** : give a`Box` a`key` to scope`hover` styles. A`position: 'absolute'` Box with`display: 'none'` plus`hover: { display: 'flex' }` pops up over its neighbours.
- **`CommandOutput`** : hook`{ component: 'CommandOutput', props: { command: 'mine' } }` to draw your command's output row as a tree, inline in the transcript.

| Where | Lifetime | Use for | 
|---|---|---|
| Module variables | Until the next reload (🧪 a hot reload resets them) | Timers, caches you can rebuild | 
| `$.state` (`atom` ,`read` ,`update` ) | The session; survives reloads; versioned | Anything a drawing reads. A `read` while drawing subscribes, and an`update` redraws exactly those readers, with no`invalidate` needed | 
| `$.store` | Across sessions | Preferences, history | 

📘 Rules for `$.state`:

- Never write while drawing; `set` is denied inside a render hook.
- Write from a handler or another event with `update($, ref, fn)` , which retries on version conflicts, so two quick presses both land.
- Only the owning plugin writes a value.

🧪 Our pane kept its data in module variables and coped with reloads by resuming polling from `ui.render` (§4.4). That's fine for data you can re-fetch. Use `$.state` for anything you can't.

**Plain Node for pure code (🧪).** Split the mod so only one file touches `$` (§10). Then `node --test` covers the parts with no engine calls: models, layout, cell graphics, frame functions. Useful assertions:

- the base64 encoder matches `Buffer` ;
- each animated picture keeps its size across frames;
- labels are honest (MEASURED vs SYNTHETIC);
- surfaces without `Raster` get a note instead of a refused tree.

**The engine for behaviour (🧪).** `claude plugin test ./my-mod` runs `*.test.ts` files against the real engine, with the world beneath your plugin mocked:

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

test('the chart tab draws and animates', async ($, on) => {
  const clock = mock.clock(on)
  const blits: string[] = []
  on('session.start', ($, e) => ({ cwd: e.cwd }))
  on('command.register', ($, e) => ({ value: { command: e.name } }))
  on('process.run', () => ({ value: { exitCode: 0, stdout: JSON.stringify(FAKE), stderr: '' } }))
  on('ui.status', () => ({ value: undefined }))
  on('ui.blit', ($, e) => { blits.push(e.key); return { value: {} } })

  await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
  const pane = await $.ui.mount({ plugin: 'my-mod', surface: 'terminal', component: 'Pane', requestId: 'live',
    props: { title: 'Live', isFocused: true, bodyColumns: 120, placement: 'dock', scroll: { offset: 0, bodyRows: 34 }, view: {} } })
  await clock.settle()
  await pane.press({ key: 'tab-2' })
  for (let i = 0; i < 4; i++) await clock.advance(100)
  expect(await pane.find({ type: 'Raster', key: 'chart' })).toBeDefined()
  expect(blits).toContain('chart')
  await pane.unmount()
})
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test ./my-mod
```

Three traps that each cost us an afternoon (🧪):

1. **`await clock.advance(ms)`.** It returns a Promise. Unawaited advances race each other once a fast animation timer exists, and a refresh "never runs". It looks exactly like a bug in your mod.
2. **Don't stub `ui.invalidate` without calling `next`.** The mounted drawing follows your invalidations. Swallow them and`find` keeps reading the first frame forever.
3. **Mount a component once per test.** A second`$.ui.mount` on the same`requestId` throws, so read redraws from the same handle.

When a test fails mysteriously, dump the tree: `console.log(JSON.stringify(await pane.drawn()))`.

📘 Write the UI test body once and loop it over `['terminal', 'desktop'] as const` to show the mod doesn't depend on one surface.

**Inside an npm package (🧪).** Put the mod in `<pkg>/mod/` and list it in `files`. Add a CLI subcommand that prints its path:

```
npx your-package mod                  # prints the mod folder and how to load it
claude --plugin-dir "<that path>"
```

Because the mod finds its CLI via `$.plugin.root`, it keeps working wherever npm installs the package.

**From a marketplace:**

```
/plugin marketplace add <owner>/<repo>
/plugin install <mod>@<marketplace>
```

**Options for folder-loaded mods** live in `~/.claude/settings.json` under `pluginConfigs.<name>.options`. Each `userConfig` field also appears as a row in the config menu.

**Least authority (ruvnet practice):**

- Run fixed argv only, never a shell string.
- Validate every option and every host or path.
- Keep a mod read-only unless writing is the point. Ours never flashes or configures hardware.
- Remember that a mod runs with Claude Code's access, so install mods only from sources you trust, and say so in your README.

**Budget it.** Keep the mod dependency-free and watch package size. 🧪 Ours added about 37 KB for three graphical views.

-  `await $.clock.now()` : it's a Promise. 🧪 Without it the pane showed "Invalid Date".
-  Only props the surface supports. 🧪 `flexBasis` refused the whole tree. Read the dim "refused:" line.
-  No `node:*` , no`Buffer` , no dynamic`import()` . Own files only; use`$.plugin.root` for paths.
-  After a reload, module variables are empty and timers are gone. Resume from `ui.render` , or keep the data in`$.state` .
-  Stop timers on `ui.close` and`session.end` .
-  Hotkeys need focus. Open as the documented dialog, show `isFocused` , and mirror keys as slash subcommands.
-  Inline panes get a third of the screen. Request `rows` per view and lay out from`bodyRows` /`bodyColumns` .
-  `Raster` ,`Image` and`Client` are not on every surface. Guard on`ui.Raster` .
- A blit must match the mounted size. Build the first paint and the frames from one function, and don't await blits.
- Animate on the real clock, and label what motion means.
-  Engine tests: await `clock.advance` , don't swallow`ui.invalidate` , mount once.
- Never show stale data as live. When a source goes quiet, drop its picture instead of keeping it under a LIVE badge.

[`ruview-live`](https://github.com/ruvnet/RuView/tree/main/harness/ruview/mod) is the mod this guide was written from. It ships in the [`@ruvnet/ruview`](https://www.npmjs.com/package/@ruvnet/ruview) npm package. `/ruview` opens a pane with three views:

- **Overview** : Wi-Fi sensing nodes and a 60 GHz radar kit, in bordered cards with sparklines.
- **Waterfall** : per-subcarrier signal strength over time, drawn with half blocks and a perceptual colour ramp, replayed at the frames' real arrival rate with`$.ui.blit` .
- **Radar** : a braille range fan with an animated sonar ping, plus braille vitals charts.

Its layout is a good template for any graphical mod:

| File | Holds | Touches `$` ? | 
|---|---|---|
| `model.mjs` | settings, argv, result parsing, the view model, history | no | 
| `raster.mjs` | Grid, base64, colour map, half-block waterfall, braille canvas, fan, charts | no | 
| `anim.mjs` | frame functions of time | no | 
| `views.mjs` | `viewOf` (tree) and`picturesOf` (every animated picture) | no | 
| `register.mjs` | hooks, timers, blits; re-exports the rest for tests | **yes** | 

It is tested with 24 plain-Node tests and 4 engine tests, and validated with `claude plugin validate`. Every number on screen carries an honest label, MEASURED or SYNTHETIC, or "device-reported, not validated". At ruvnet that rule matters as much as the graphics.

*© ruvnet. MIT. Corrections welcome: the mod API is early access and moves between releases, and your build's `.claude-plugin/types/claude-code/index.d.ts` always wins over this guide.*
