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.

Claude Code Mods: a field guide

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. 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 ), 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.

Contents


1. What a mod is, and what you can build

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.


2. Setup

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///.

  • 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 ).

Read the types (🧪). Once a mod loads, the engine writes /.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 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: ; the engine drew its own. Read those lines first.


3. Anatomy of a mod

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:

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.


4. Recipes

4.1 Guard a tool

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

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;
  });
}

4.2 A slash command, a toast, a status line

🧪

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)
  });
}

4.3 A band above the prompt, with state

📘 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.

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 Last turn: {turn.seconds}s, {turn.tools} tools 
      

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 } }
}

4.4 A live pane fed by a process

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

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 /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.

4.5 A tool the model can call

📘

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____.
  • 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.

5. Building UIs

5.1 Surfaces and element tables

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:

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.

5.2 Layout and sizing: inline vs docked

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.

5.3 Keyboard: focus, hotkeys, dialogs

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).

5.4 Graphics with Raster

🧪 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: '' })

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:

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

5.5 Animation with $.ui.blit

🧪 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.

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.

5.6 Interactive regions with Client

📘 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.

5.7 Other elements: Markdown, Image, hover cards

📘 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.

6. State: module variables, $.state, $.store

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.


7. Testing

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:

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.


8. Shipping

Inside an npm package (🧪). Put the mod in /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 ""

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

From a marketplace:

/plugin marketplace add /
/plugin install @

Options for folder-loaded mods live in ~/.claude/settings.json under pluginConfigs..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.


9. Pitfalls checklist

  • 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.

10. Case study: a live sensor pane

ruview-live is the mod this guide was written from. It ships in the @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.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论