{"slug": "claude-code-mods-a-field-guide-build-plugins-that-hook-the-engine-add-commands", "title": "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.", "summary": "Developer ruvnet published a field guide to building Claude Code mods, plugins that hook the engine's events to block or rewrite tool calls, add slash commands and tools, and render custom UI such as status lines, panes and animated graphics. The guide documents the hooks module API (register(on, options), on('tool.call'), on('ui.render')), the sandboxed runtime with no DOM or Node access, and enabling the early-access feature via CLAUDE_CODE_ENABLE_FUNCTION_HOOKS. It also covers hot reloading through the plugin-authoring skill and the auto-generated type definitions that ship with each loaded mod.", "body_md": "**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.**\n\n*By [ruvnet](https://github.com/ruvnet). Written from building and shipping a real mod, with every pitfall we hit included.*\n\n**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.\n\nThroughout this guide:\n\n**🧪 Field-tested** means we did it in a shipped mod and saw it work (or break) for real.\n**📘 Engine docs** means it comes from the engine's reference and type definitions, and we haven't exercised it ourselves.\n\nA 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.\n\n| You want… | Mod mechanism | \n|---|---|\n| Block or rewrite a tool call (protect `.env` , normalise commands) | `on('tool.call', { tool }, …)` returning`{ deny }` or`next({ ...e, … })` | \n| React to the prompt, or rewrite it | `on('prompt.submit', …)` | \n| Add or replace a system-prompt section | `on('prompt.compose', …)` | \n| A slash command | `$.command.register` +`on('command.run', …)` | \n| A status-line entry | `$.ui.status(text)` | \n| A toast | `$.ui.toast(text)` | \n| A band of UI above the prompt | `on('ui.render', { component: 'AbovePrompt' }, …)` | \n| A pane beside the conversation | `$.ui.open({ id })` +`on('ui.render', { component: 'Pane' }, …)` | \n| Draw a slash command's output as UI | `on('ui.render', { component: 'CommandOutput', props: { command } }, …)` | \n| A tool the model can call | `$.tool.register` + serve it in`tool.call` | \n| A subagent type | `$.agent.register` | \n| Background work | `$.clock.every` /`$.clock.after` , started in`session.start` | \n| Run programs, read files | `$.process.run` (argv, no shell),`$.fs` | \n| Ask a model something | `$.model.complete` ,`$.model.fork` | \n| Colour graphics and animation | `Raster` cells +`$.ui.blit` | \n| Mouse- and keyboard-driven widgets | `Client` surface modules | \n\nThe 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.\n\n**Turn mods on** (early access):\n\n```\nexport CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1\n# or persistently, in ~/.claude/settings.json:\n#   \"env\": { \"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS\": \"1\" }\n```\n\n**Load a mod** (pick one):\n\n```\nclaude --plugin-dir ./my-mod                         # this session only\n# ~/.claude/settings.json → \"env\": { \"CLAUDE_CODE_PLUGIN_DIRS\": \"/abs/path/my-mod\" }   # every session\n```\n\nBoth are watched in an interactive session: saving a file reloads the module.\n\n**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>/`.\n\n- The first time a mod is written there, Claude Code asks once: *Enable hot reloading for this session?*\n- After you agree, the mod reloads when each turn ends.\n- 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) ).\n\n**Read the types (🧪).** Once a mod loads, the engine writes `<mod>/.claude-plugin/types/`:\n\n- `claude-code/index.d.ts` : the whole API, every event,`$` method and element prop. About 14k lines, so grep it.\n- `claude-code-tools/index.d.ts` : this build's built-in tools, so`e.tool === 'Bash'` narrows`e` .\n- `claude-code-mcp/index.d.ts` : the MCP tools that were connected.\n- A `tsconfig.json` , so`tsc -p <mod>` type-checks.\n\n```\ngrep -n \"hotkey\" my-mod/.claude-plugin/types/claude-code/index.d.ts\n```\n\n**Two commands you'll run constantly:**\n\n```\nclaude plugin validate ./my-mod      # what the module hooks and calls, and anything the engine would refuse\nclaude --debug                       # every refused tree and skipped hook, with the reason\n```\n\nDuring 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.**\n\nThree files:\n\n```\nmy-mod/\n├── .claude-plugin/plugin.json\n└── hooks/\n    ├── hooks.json\n    └── register.mjs         # or .ts / .tsx / .js\n// .claude-plugin/plugin.json\n{\n  \"name\": \"my-mod\",\n  \"version\": \"0.1.0\",\n  \"description\": \"What it does, in one line\",\n  \"userConfig\": {                       // optional: settings users change with `claude plugin configure my-mod`\n    \"refreshSeconds\": { \"type\": \"number\", \"title\": \"Refresh (s)\", \"default\": 15 },\n    \"mode\": { \"type\": \"string\", \"title\": \"Mode\", \"default\": \"fast\", \"options\": [\"fast\", \"full\"] }\n  }\n}\n// hooks/hooks.json: one module, path relative to this file\n{ \"modules\": [\"./register.mjs\"] }\n// hooks/register.mjs\n/** @type {import('claude-code').Register} */\nexport function register(on, options = {}) {\n  on('session.start', async ($, e, next) => {\n    $.ui.toast(`${$.plugin.name} loaded`);\n    return next(e);\n  });\n}\n```\n\n**The hook contract.** Every hook is `($, e, next)`:\n\n- `$` : the engine. Every call is spelled noun, then method:`$.ui.open` ,`$.clock.every` ,`$.process.run` .\n- `e` : the event's input, a frozen plain value.\n- `next(e)` : run the plugins beneath, then the engine's own behaviour. It resolves to the event's result.\n\nSo a hook can:\n\n- **pass** :`return next(e)`\n- **rewrite** :`return next({ ...e, command: e.command.trim() })`\n- **observe the result** :`const r = await next(e); /* look at r */ return r;`\n- **answer for itself** : return a value without calling`next` .\n\n**Options are user input (🧪).** `options` holds the `userConfig` values, so bound and validate them before use:\n\n``` js\nconst num = (v, d, lo, hi) => (Number.isFinite(Number(v)) ? Math.min(Math.max(Math.round(Number(v)), lo), hi) : d);\nconst refreshMs = num(options.refreshSeconds, 15, 5, 3600) * 1000;\n```\n\n**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.\n\n📘 Engine example: refuse edits to `.env` files, and flag failed Bash commands in the status line.\n\n``` js\nconst PROTECTED = /(^|\\/)\\.env(\\.|$)/;\n\nexport function register(on) {\n  on('tool.call', { tool: 'Edit' }, ($, e, next) =>\n    PROTECTED.test(e.file_path) ? { deny: `${$.plugin.name}: ${e.file_path} is protected.` } : next(e));\n\n  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {\n    const ran = await next({ ...e, command: e.command.trim() });\n    $.ui.status(ran.deny === undefined && ran.isError ? `failed: ${e.command.slice(0, 40)}` : undefined);\n    return ran;\n  });\n}\n```\n\n🧪\n\n``` js\nexport function register(on) {\n  on('session.start', async ($, e, next) => {\n    await $.command.register({ name: 'hello', description: 'Say hello', argumentHint: '[name]' }).catch(() => {});\n    return next(e);\n  });\n\n  on('command.run', { command: 'hello' }, async ($, e) => {\n    const who = String(e.args || 'world').trim();\n    $.ui.toast(`hello, ${who}`);\n    $.ui.status(`said hello to ${who}`);   // undefined clears it\n    return { text: `Hello, ${who}!` };      // the command's output row (the model reads it too)\n  });\n}\n```\n\n📘 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.\n\n``` js\nimport { atom, read, update } from 'claude-code';\n\nconst last = atom({ plugin: 'turn-band', key: 'last' } as const, null);\nconst hidden = atom({ plugin: 'turn-band', key: 'isHidden' } as const, false);\n\nexport const register = (on) => {\n  let startedAt = 0, tools = 0;\n  on('prompt.submit', async ($, e, next) => { startedAt = await $.clock.now(); tools = 0; return next(e); });\n  on('tool.call', ($, e, next) => { tools += 1; return next(e); });\n  on('turn.complete', async ($, e, next) => {\n    const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000);\n    await update($, last, () => ({ seconds, tools }));\n    return next(e);\n  });\n  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {\n    const turn = await read($, last);\n    if (e.props.hasSurvey || !turn || (await read($, hidden))) return next(e);   // yield to surveys\n    const { Box, Text, Button } = $.ui.resolve(e);\n    return <Box><Text dimColor>Last turn: {turn.seconds}s, {turn.tools} tools </Text>\n      <Button key=\"hide\" label=\"Hide\" onPress={() => update($, hidden, () => true)} /></Box>;\n  });\n};\n```\n\nState values are declared in a type contract (`types/index.d.ts`, named in `plugin.json` as `\"types\"`) and checked by `claude plugin validate`:\n\n```\ndeclare module 'claude-code' {\n  interface PluginState { 'turn-band': { last: { seconds: number; tools: number } | null; isHidden: boolean } }\n}\n```\n\n🧪 The core pattern from our shipped mod: a command opens a pane, a timer refreshes data from a CLI, and the pane redraws.\n\n``` js\nconst PANE = 'live';\n\nexport function register(on, options = {}) {\n  let host = null, data = null, stopTimer = null, busy = false;\n\n  async function refresh() {\n    if (!host || busy) return;\n    busy = true;\n    try {\n      const run = await host.run(['node', host.cli, 'status', '--json'], { timeoutMs: 30_000 });\n      try { data = JSON.parse(run.stdout); } catch { data = { ok: false, reason: 'cli_error' }; }\n      data.at = await host.now();                 // ⚠ $.clock.now() is a Promise\n    } finally { busy = false; host.invalidate(); }\n  }\n  function startPolling() {\n    if (stopTimer) return;\n    const t = host.every(15_000, () => { void refresh(); });\n    stopTimer = typeof t === 'function' ? t : () => t?.cancel?.();\n    void refresh();\n  }\n  const stop = () => { stopTimer?.(); stopTimer = null; };\n\n  on('session.start', async ($, e, next) => {\n    host = {\n      cli: `${$.plugin.root}/../bin/cli.js`,      // find files beside the mod\n      run: (argv, init) => $.process.run(argv, init),\n      every: (ms, fn) => $.clock.every(ms, fn),\n      now: () => $.clock.now(),\n      invalidate: () => $.ui.invalidate('ui.render'),\n    };\n    await $.command.register({ name: 'live', description: 'Open the live pane' }).catch(() => {});\n    return next(e);\n  });\n\n  on('command.run', { command: 'live' }, async ($) => {\n    await $.ui.open({ id: PANE, title: 'Live', focus: true, closeOnEscape: true, holdToasts: true, rows: 16 });\n    startPolling();\n    return { text: 'Live pane open.' };\n  });\n\n  on('ui.render', { component: 'Pane' }, async ($, e, next) => {\n    if (e.requestId !== PANE) return next(e);\n    if (host && !stopTimer) startPolling();       // a reload kept the pane open: resume\n    const { Box, Text, Button } = await $.ui.resolve(e);\n    return Box({ flexDirection: 'column', paddingX: 1, children: [\n      Text({ bold: true, color: 'cyan', children: 'Live' }),\n      Text({ dimColor: true, children: data ? `updated ${new Date(data.at).toLocaleTimeString()}` : 'waiting…' }),\n      Button({ key: 'refresh', label: 'Refresh (r)', hotkey: 'r', onPress: () => { void refresh(); } }),\n    ] });\n  });\n\n  on('ui.close', { id: PANE }, async ($, e, next) => { stop(); return next(e); });\n  on('session.end', async ($, e, next) => { stop(); return next(e); });\n}\n```\n\nWhy each line is there:\n\n- **`$.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.\n- **`$.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` `` .\n- **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.\n- **Stop timers** on`ui.close` and`session.end` .\n\n📘\n\n``` js\non('session.start', async ($, e, next) => {\n  await $.tool.register({\n    name: 'room_status',\n    description: 'Current occupancy of the room sensors',\n    inputSchema: { type: 'object', properties: {}, additionalProperties: false },\n  });\n  return next(e);\n});\n// Serve it: a hook answers with { result }. Core validates it against any output schema\n// and maps it for the model.\non('tool.call', { tool: 'mcp__my-mod__room_status' }, async ($, e) => ({ result: await readSensors() }));\n```\n\n- The tool is listed to the model as `mcp__<plugin>__<name>` .\n- Register it in `session.start` , which is awaited before the first prompt, so it exists from turn one.\n- A hook can also add `context: ['…']` : text the model reads after the result and the user never sees.\n\nA 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.**\n\n| Element | Notes | \n|---|---|\n| `Box` | Flexbox subset: `flexDirection` ,`gap` ,`padding*` ,`flexGrow` ,`justifyContent` ,`borderStyle` ,`borderColor` ,`position: 'absolute'` ,`hover` .**Not all of CSS:** 🧪`flexBasis` got the whole tree refused. | \n| `Text` | `color` ,`bold` ,`dimColor` ,`italic` ,`wrap` (`'truncate-end'` keeps rows one line) | \n| `Button` | `key` ,`label` ,`hotkey` ,`onPress` ,`variant: 'primary'` ,`plain` ,`role: 'dismiss'` | \n| `Input` ,`Select` | Not on `mobile` | \n| `Markdown` | Model-style text: headings, lists, tables, code, links | \n| `Raster` | **Terminal only.** A grid of coloured cells (§5.4) | \n| `Image` | **Terminal only.** Pixels in kitty/Ghostty, the`alt` text elsewhere | \n| `Client` | Your own drawing module with a frame clock and input (§5.6). Not on `vscode` or`mobile` | \n| `Svg` | Remote surfaces only | \n\n**A tree with an element the surface lacks is refused whole** (🧪). Guard per surface:\n\n``` js\nconst ui = $.ui.resolve(e);\nconst picture = ui.Raster ? ui.Raster(grid.toRaster('chart')) : ui.Text({ dimColor: true, children: 'open in the terminal for the chart' });\n```\n\n**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.\n\nA pane is seated in one of two places, given by `e.props.placement`:\n\n| Placement | When | Size | \n|---|---|---|\n| `dock` | Fullscreen terminal, ≥ 110 columns | Beside the transcript, floor to ceiling | \n| `inline` | Everything else | Above the prompt, **a third of the screen by default** | \n\n🧪 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:\n\n```\nawait $.ui.open({ id: PANE, title: 'Live', rows: 26 });     // inline height wanted; the dock ignores it\n// switching views? open again with the new rows: each open sets them anew\n```\n\n- **Size to the box you get, not the screen:**`e.props.bodyColumns` (narrower when docked) and`e.props.scroll.bodyRows` .\n- A tree taller than the box scrolls with the arrow keys, but only while the pane is focused.\n- `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.\n- 🧪 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.\n\n``` js\nButton({ key: 'tab-2', label: 'Chart (2)', hotkey: '2', onPress: () => setView('chart') })\n```\n\nA `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\":\n\n1. With no focus request, `2` arrived in the conversation as a chat message.\n2. With `focus: true` alone, the pane still reported`isFocused: false` in our session.\n3. 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.\n\n```\nawait $.ui.open({ id: PANE, title: 'Live', focus: true, closeOnEscape: true, holdToasts: true, rows: 26 });\n```\n\nFocus 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:\n\n- \n**Say when the pane lacks the keys.** It's in the render input as`e.props.isFocused` :\n\n```\ne.props.isFocused === false && Text({ dimColor: true, children: 'click the pane or press ctrl+x tab to use the keys' })\n```\n\n- \n**Mirror every key as a slash subcommand** (`/live chart` ), so nothing depends on focus.\n\n📘 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).\n\n🧪 `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.\n\n```\nui.Raster({ key: 'chart', columns: 64, rows: 12, cells: '<base64>' })\n```\n\n`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:\n\n``` js\nconst B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';\nexport function toBase64(b) {\n  let s = '', i = 0;\n  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]; }\n  if (b.length - i === 1) { const n = b[i] << 16; s += B64[n >> 18 & 63] + B64[n >> 12 & 63] + '=='; }\n  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] + '='; }\n  return s;\n}\nexport class Grid {\n  constructor(columns, rows) {\n    Object.assign(this, { columns, rows });\n    this.cells = new Uint32Array(columns * rows * 3);\n    for (let i = 0; i < columns * rows; i++) this.cells.set([0x20, 0x01000000, 0x01000000], i * 3);\n  }\n  set(x, y, ch, fg = 0x01000000, bg = 0x01000000) {\n    if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return;\n    this.cells.set([typeof ch === 'number' ? ch : ch.codePointAt(0), fg >>> 0, bg >>> 0], (y * this.columns + x) * 3);\n  }\n  toRaster(key) {\n    return { key, columns: this.columns, rows: this.rows, cells: toBase64(new Uint8Array(this.cells.buffer)) };\n  }\n}\n```\n\nTest `toBase64` against `Buffer.from(bytes).toString('base64')` in plain Node.\n\nTechniques that make cell graphics look good (🧪, all used in the case study):\n\n| Technique | How | Good for | \n|---|---|---|\n| **Half blocks** | Glyph `▀` (U+2580): fg = upper pixel, bg = lower pixel | Double vertical resolution: heat maps, waterfalls, images | \n| **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 | \n| **Percentile colour limits** | Map the 5th–95th percentile to the colour ramp, not min/max | One outlier no longer flattens the picture | \n| **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 | \n| **Perceptual ramps** | near-black → indigo → cyan → emerald → amber → white | Readable on dark themes | \n\n🧪 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.\n\n``` js\nconst stopAnim = $.clock.every(80, async () => {                 // ~12 fps\n  const t = await $.clock.now();                                  // real time, not an assumed tick\n  const grid = drawFrame(model, { columns, rows, t });            // same function the render uses\n  const { cells } = grid.toRaster('chart');\n  Promise.resolve($.ui.blit({ requestId: PANE, key: 'chart', cells, columns, rows })).catch(() => {});\n});\n```\n\nRules we learned the hard way:\n\n- **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` .\n- **Don't `await` a blit.** It resolves only once a frame is painted, and blits between frames fold anyway. Fire and forget.\n- **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.\n- **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\".\n\n📘 `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.\n\n📘 *Not used in our shipped mod; summarised from the type definitions.*\n\n`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:\n\n- `surface.elements` (Box, Text, Button… but**no**`Raster` ,`Image` or nested`Client` );\n- local `surface.state` /`setState` ;\n- `surface.columns` /`rows` ;\n- `surface.every(ms, fn)` : its own frame clock;\n- `surface.onPointer(fn)` : down, move, up, enter, leave, with sub-cell positions on terminals that report pixels;\n- `surface.onKey(fn)` , once a click gives it focus;\n- `surface.post(data)` to message your hooks module, as`ui.message` .\n\nIt'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.\n\n📘 From the engine docs:\n\n- **`Markdown`** draws model-style text. With` key` and`onLinkPress` , a click on a link raises`ui.press` with its`href` instead of opening it.\n- **`Image`** shows PNG or RGBA bytes, or a file or shared-memory name. Pixels appear in kitty/Ghostty, the`alt` text elsewhere.\n- **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.\n- **`CommandOutput`** : hook`{ component: 'CommandOutput', props: { command: 'mine' } }` to draw your command's output row as a tree, inline in the transcript.\n\n| Where | Lifetime | Use for | \n|---|---|---|\n| Module variables | Until the next reload (🧪 a hot reload resets them) | Timers, caches you can rebuild | \n| `$.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 | \n| `$.store` | Across sessions | Preferences, history | \n\n📘 Rules for `$.state`:\n\n- Never write while drawing; `set` is denied inside a render hook.\n- Write from a handler or another event with `update($, ref, fn)` , which retries on version conflicts, so two quick presses both land.\n- Only the owning plugin writes a value.\n\n🧪 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.\n\n**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:\n\n- the base64 encoder matches `Buffer` ;\n- each animated picture keeps its size across frames;\n- labels are honest (MEASURED vs SYNTHETIC);\n- surfaces without `Raster` get a note instead of a refused tree.\n\n**The engine for behaviour (🧪).** `claude plugin test ./my-mod` runs `*.test.ts` files against the real engine, with the world beneath your plugin mocked:\n\n``` js\nimport { expect, mock, test } from 'claude-code/testing'\n\ntest('the chart tab draws and animates', async ($, on) => {\n  const clock = mock.clock(on)\n  const blits: string[] = []\n  on('session.start', ($, e) => ({ cwd: e.cwd }))\n  on('command.register', ($, e) => ({ value: { command: e.name } }))\n  on('process.run', () => ({ value: { exitCode: 0, stdout: JSON.stringify(FAKE), stderr: '' } }))\n  on('ui.status', () => ({ value: undefined }))\n  on('ui.blit', ($, e) => { blits.push(e.key); return { value: {} } })\n\n  await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })\n  const pane = await $.ui.mount({ plugin: 'my-mod', surface: 'terminal', component: 'Pane', requestId: 'live',\n    props: { title: 'Live', isFocused: true, bodyColumns: 120, placement: 'dock', scroll: { offset: 0, bodyRows: 34 }, view: {} } })\n  await clock.settle()\n  await pane.press({ key: 'tab-2' })\n  for (let i = 0; i < 4; i++) await clock.advance(100)\n  expect(await pane.find({ type: 'Raster', key: 'chart' })).toBeDefined()\n  expect(blits).toContain('chart')\n  await pane.unmount()\n})\nCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test ./my-mod\n```\n\nThree traps that each cost us an afternoon (🧪):\n\n1. **`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.\n2. **Don't stub `ui.invalidate` without calling `next`.** The mounted drawing follows your invalidations. Swallow them and`find` keeps reading the first frame forever.\n3. **Mount a component once per test.** A second`$.ui.mount` on the same`requestId` throws, so read redraws from the same handle.\n\nWhen a test fails mysteriously, dump the tree: `console.log(JSON.stringify(await pane.drawn()))`.\n\n📘 Write the UI test body once and loop it over `['terminal', 'desktop'] as const` to show the mod doesn't depend on one surface.\n\n**Inside an npm package (🧪).** Put the mod in `<pkg>/mod/` and list it in `files`. Add a CLI subcommand that prints its path:\n\n```\nnpx your-package mod                  # prints the mod folder and how to load it\nclaude --plugin-dir \"<that path>\"\n```\n\nBecause the mod finds its CLI via `$.plugin.root`, it keeps working wherever npm installs the package.\n\n**From a marketplace:**\n\n```\n/plugin marketplace add <owner>/<repo>\n/plugin install <mod>@<marketplace>\n```\n\n**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.\n\n**Least authority (ruvnet practice):**\n\n- Run fixed argv only, never a shell string.\n- Validate every option and every host or path.\n- Keep a mod read-only unless writing is the point. Ours never flashes or configures hardware.\n- Remember that a mod runs with Claude Code's access, so install mods only from sources you trust, and say so in your README.\n\n**Budget it.** Keep the mod dependency-free and watch package size. 🧪 Ours added about 37 KB for three graphical views.\n\n-  `await $.clock.now()` : it's a Promise. 🧪 Without it the pane showed \"Invalid Date\".\n-  Only props the surface supports. 🧪 `flexBasis` refused the whole tree. Read the dim \"refused:\" line.\n-  No `node:*` , no`Buffer` , no dynamic`import()` . Own files only; use`$.plugin.root` for paths.\n-  After a reload, module variables are empty and timers are gone. Resume from `ui.render` , or keep the data in`$.state` .\n-  Stop timers on `ui.close` and`session.end` .\n-  Hotkeys need focus. Open as the documented dialog, show `isFocused` , and mirror keys as slash subcommands.\n-  Inline panes get a third of the screen. Request `rows` per view and lay out from`bodyRows` /`bodyColumns` .\n-  `Raster` ,`Image` and`Client` are not on every surface. Guard on`ui.Raster` .\n- A blit must match the mounted size. Build the first paint and the frames from one function, and don't await blits.\n- Animate on the real clock, and label what motion means.\n-  Engine tests: await `clock.advance` , don't swallow`ui.invalidate` , mount once.\n- Never show stale data as live. When a source goes quiet, drop its picture instead of keeping it under a LIVE badge.\n\n[`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:\n\n- **Overview** : Wi-Fi sensing nodes and a 60 GHz radar kit, in bordered cards with sparklines.\n- **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` .\n- **Radar** : a braille range fan with an animated sonar ping, plus braille vitals charts.\n\nIts layout is a good template for any graphical mod:\n\n| File | Holds | Touches `$` ? | \n|---|---|---|\n| `model.mjs` | settings, argv, result parsing, the view model, history | no | \n| `raster.mjs` | Grid, base64, colour map, half-block waterfall, braille canvas, fan, charts | no | \n| `anim.mjs` | frame functions of time | no | \n| `views.mjs` | `viewOf` (tree) and`picturesOf` (every animated picture) | no | \n| `register.mjs` | hooks, timers, blits; re-exports the rest for tests | **yes** | \n\nIt 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.\n\n*© 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.*", "url": "https://wpnews.pro/news/claude-code-mods-a-field-guide-build-plugins-that-hook-the-engine-add-commands", "canonical_source": "https://gist.github.com/ruvnet/a485e930b148185197fc53fd38b429ea", "published_at": "2026-10-02 00:55:50+00:00", "updated_at": "2026-10-02 21:36:51.201143+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents", "ai-products"], "entities": ["Claude Code", "ruvnet", "Anthropic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/claude-code-mods-a-field-guide-build-plugins-that-hook-the-engine-add-commands", "markdown": "https://wpnews.pro/news/claude-code-mods-a-field-guide-build-plugins-that-hook-the-engine-add-commands.md", "text": "https://wpnews.pro/news/claude-code-mods-a-field-guide-build-plugins-that-hook-the-engine-add-commands.txt", "jsonld": "https://wpnews.pro/news/claude-code-mods-a-field-guide-build-plugins-that-hook-the-engine-add-commands.jsonld"}}