{"slug": "how-to-build-a-claude-code-mod-a-complete-example-with-a-pixel-pet", "title": "How to Build a Claude Code Mod: A Complete Example (With a Pixel Pet)", "summary": "A developer published a complete four-file example of a Claude Code mod, consisting of a manifest at .claude-plugin/plugin.json, a hooks/hooks.json file, a hooks/register.tsx hooks module, and a types/index.d.ts state contract, that draws an animated 12×8 pixel cat above the prompt and adds a /pet slash command in both the terminal and desktop app. The tutorial, built on the open-source pixel-pet project at github.com/eranltd/pixel-pet, explains that values a drawing reads must live in $.state rather than module variables because a reload re-runs the module and resets its variables while host-held state survives. The same pattern scales to Bit, a pixel puppy that runs a status board for every Claude Code session on the author's machine.", "body_md": "*A working Claude Code mod in four files: a live band above the prompt, a slash command, and state that survives reloads. Plus the open-source mod I built on top of it.*\n\n*A mock of a running Claude Code session with the pixel-pet mod. Every name and message is made up.*\n\nIf you searched for a Claude Code mod example and found mostly fragments, this is the full thing. By the end you will have a mod that draws an animated pixel cat above your prompt and adds a /pet command. It works in the terminal and in the desktop app.\n\nI’ll also show what the same pattern grows into: **Bit**, a pixel puppy that runs a status board for every Claude Code session on my machine. It is open source at [github.com/eranltd/pixel-pet](https://github.com/eranltd/pixel-pet).\n\nA Claude Code mod is a plugin with a **hooks module**: a TypeScript file that Claude Code loads into its own interface. It can draw UI above the prompt, open side panes, add slash commands, and react to what the session does: tool calls, prompts, turns starting and ending.\n\nThree things make it different from a script you run next to your editor:\n\n```\nmy-pet/├── .claude-plugin/plugin.json   the manifest├── hooks/hooks.json             names the hooks module├── hooks/register.tsx           the module: hooks, drawing, commands└── types/index.d.ts             the state contract\n```\n\n.claude-plugin/plugin.json:\n\n```\n{  \"name\": \"my-pet\",  \"version\": \"0.1.0\",  \"description\": \"A pixel cat above the prompt\",  \"types\": \"./types/index.d.ts\"}\n```\n\nhooks/hooks.json:\n\n```\n{ \"modules\": [\"./register.tsx\"] }\n```\n\nValues a drawing reads belong in $.state, not in module variables. A reload runs your module again and resets its variables, but $.state is held by the host and survives. Every value is declared under the mod's name.\n\ntypes/index.d.ts:\n\n```\ndeclare module 'claude-code' {  interface PluginState {    'my-pet': {      frame: number      isHidden: boolean    }  }}\n```\n\nKeep this file a plain declaration. An export {} at the end makes the validator miss the contract.\n\nhooks/register.tsx is the whole mod: a 12×8 pixel cat that flicks its tail, drawn as a Raster in the terminal and an animated Svg in the desktop app, plus a /pet command that hides it.\n\n``` python\nimport { atom, read, update } from 'claude-code'import type { Register } from 'claude-code'\njs\n// One letter per pixel; '.' is transparent. Two frames: the tail flicks.const FRAMES = [  ['..k.....k...', '.kkkkkkkkk..', '.kwkkkkkwk..', '.kkkkpkkkk..', '..kkkkkkkk.k', '..kkkkkkkkk.', '..k.k..k.k..', '............'],  ['..k.....k...', '.kkkkkkkkk..', '.kwkkkkkwk..', '.kkkkpkkkk..', '..kkkkkkkk..', '..kkkkkkkkkk', '..k.k..k.k..', '............'],]const WIDTH = 12const HEIGHT = 8const PALETTE: Record<string, number> = { k: 0x3b3b3b, w: 0x9be36d, p: 0xff9eb5 }const TERMINAL_DEFAULT = 0x01000000const TICK_MS = 500const PIXEL_PX = 4\njs\nconst frameAtom = atom({ plugin: 'my-pet', key: 'frame' } as const, 0)const hiddenAtom = atom({ plugin: 'my-pet', key: 'isHidden' } as const, false)\njs\nfunction toBase64(bytes: Uint8Array): string {  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'  let out = ''  for (let i = 0; i < bytes.length; i += 3) {    const a = bytes[i] ?? 0    const b = bytes[i + 1] ?? 0    const c = bytes[i + 2] ?? 0    const n = (a << 16) | (b << 8) | c    out += alphabet[(n >> 18) & 63]! + alphabet[(n >> 12) & 63]!    out += i + 1 < bytes.length ? alphabet[(n >> 6) & 63]! : '='    out += i + 2 < bytes.length ? alphabet[n & 63]! : '='  }  return out}\n// Terminal: one Raster, each cell a half-block glyph so one text row holds two pixel rows.function toRasterCells(rows: readonly string[]): string {  const words = new Uint32Array(WIDTH * (HEIGHT / 2) * 3)  let i = 0  for (let y = 0; y < HEIGHT; y += 2) {    for (let x = 0; x < WIDTH; x += 1) {      const top = PALETTE[rows[y]?.[x] ?? '.']      const bottom = PALETTE[rows[y + 1]?.[x] ?? '.']      const [glyph, fg, bg] =        top === undefined && bottom === undefined          ? [0x20, TERMINAL_DEFAULT, TERMINAL_DEFAULT]          : top === undefined            ? [0x2584, bottom ?? TERMINAL_DEFAULT, TERMINAL_DEFAULT]            : [0x2580, top, bottom ?? TERMINAL_DEFAULT]      words[i++] = glyph      words[i++] = fg      words[i++] = bg    }  }  return toBase64(new Uint8Array(words.buffer))}\n// Desktop: one SVG, a rect per pixel; SMIL flips the frames so no redraw is needed.function animatedSvg(): string {  const dur = `${(FRAMES.length * TICK_MS) / 1000}s`  const groups = FRAMES.map((rows, tick) => {    const rects = rows.flatMap((row, y) =>      row.split('').flatMap((ch, x) => {        const color = PALETTE[ch]        return color === undefined ? [] : [`<rect x=\"${x}\" y=\"${y}\" width=\"1\" height=\"1\" fill=\"#${color.toString(16).padStart(6, '0')}\"/>`]      }),    )    const start = tick / FRAMES.length    const end = (tick + 1) / FRAMES.length    const [values, keyTimes] = tick === 0 ? ['visible;hidden', `0;${end}`] : ['hidden;visible;hidden', `0;${start};${end}`]    return `<g visibility=\"${tick === 0 ? 'visible' : 'hidden'}\"><animate attributeName=\"visibility\" values=\"${values}\" keyTimes=\"${keyTimes}\" dur=\"${dur}\" repeatCount=\"indefinite\" calcMode=\"discrete\"/>${rects.join('')}</g>`  })  return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${WIDTH} ${HEIGHT}\" width=\"${WIDTH * PIXEL_PX}\" height=\"${HEIGHT * PIXEL_PX}\" shape-rendering=\"crispEdges\">${groups.join('')}</svg>`}\njs\nexport const register: Register = on => {  on('session.start', async ($, e, next) => {    const started = await next(e)    await $.command.register({ name: 'pet', description: 'Show or hide the pixel cat' })    // The terminal Raster redraws on each tick; the desktop SVG animates itself.    $.clock.every(TICK_MS, async () => {      await update($, frameAtom, frame => (frame + 1) % FRAMES.length)    })    return started  })\njs\n  on('command.run', { command: 'pet' }, async $ => {    const isHidden = await update($, hiddenAtom, hidden => !hidden)    return { text: isHidden ? 'The cat went for a nap.' : 'The cat is back.' }  })\njs\n  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {    if (e.props.hasSurvey || (await read($, hiddenAtom))) {      return next(e)    }    let pet    if (e.surface === 'terminal') {      const { Raster } = $.ui.resolve(e)      const frame = await read($, frameAtom)      pet = <Raster key=\"pet\" columns={WIDTH} rows={HEIGHT / 2} cells={toRasterCells(FRAMES[frame] ?? FRAMES[0]!)} />    } else if (e.surface === 'desktop') {      const { Svg } = $.ui.resolve(e)      pet = <Svg source={animatedSvg()} alt=\"a pixel cat\" width={WIDTH * PIXEL_PX} height={HEIGHT * PIXEL_PX} isInteractive />    } else {      return next(e)    }    const { Box, Text } = $.ui.resolve(e)    return (      <Box flexDirection=\"row\" alignItems=\"center\" columnGap={2}>        {pet}        <Text color={e.props.isWorking ? 'cyan' : 'gray'}>{e.props.isWorking ? 'working…' : 'ready'}</Text>      </Box>    )  })}\n```\n\n**Every hook has one shape.** on(event, matcher?, hook) adds a hook, and the hook receives ($, e, next). $ is the engine, e is the event's frozen input, and next(e) runs the plugins beneath and then Claude Code itself. Return without calling next and your hook answers for itself.\n\n**Drawing is a render hook.** ui.render on { component: 'AbovePrompt' } draws the band above the prompt. Return a tree to show it, or next(e) to show nothing. e.props.bodyColumns gives you the width, so a band can switch layouts on narrow windows.\n\n**Commands are two calls.** Register the name with $.command.register in session.start, then answer it with a command.run hook that returns { text }.\n\n**State redraws for you.** read($, atom) inside a render hook subscribes the drawing. update($, atom, fn) from a timer or another hook redraws it.\n\n**Animation without redraws.** On the desktop, the SVG uses SMIL animations to flip frames by itself. That matters more than it sounds: every redraw replaces the buttons under the pointer, so a tail that redraws the band steals your clicks.\n\nRun it from its folder for one session:\n\n```\nclaude --plugin-dir ./my-pet\n```\n\nFor sessions the desktop app starts, name the folder in the env block of ~/.claude/settings.json as CLAUDE_CODE_PLUGIN_DIRS. A terminal session reloads the mod on every save. In the desktop app, type /reload-plugins at the prompt after an edit.\n\nCheck it before you load it:\n\n```\nclaude plugin validate ./my-pet\n```\n\nTest it. A test mounts a component and asserts on the tree it draws:\n\n``` js\nimport { expect, mock, test } from 'claude-code/testing'\njs\ntest('the cat draws above the prompt', async ($, on) => {  mock.clock(on, { now: 1_800_000_000_000 })  mock.store(on)  const props = { hasSurvey: false, isWorking: false, maxRows: 20, bodyColumns: 120, scroll: { bodyRows: 20, offset: 0 }, view: {} } as never  const ui = await $.ui.mount({ plugin: 'my-pet', surface: 'terminal', component: 'AbovePrompt', props })  expect(await ui.find({ key: 'pet' })).toBeDefined()  await ui.unmount()})\nclaude plugin test ./my-pet\n```\n\nAfter the first load, the engine writes the API types into .claude-plugin/types/, with a tsconfig.json your mod can extend. From then on tsc -p ./my-pet type-checks it.\n\nThe cat above is the skeleton. Here is what I grew it into.\n\nBit sits in the top-left corner of the band. He works while Claude works, hops when a task finishes, droops when a check fails, and sits up with needs you when a tool call waits on my permission. Around him the band shows:\n\nNone of it is a separate app. It is one hooks module of a few thousand lines, built by describing what I wanted, in sentences, while using the tool. “Move the dog to the corner.” “Make him smaller.” “Why is there a gap next to Slack?” Each was a one-minute round trip.\n\nAt the prompt of a terminal Claude Code session:\n\n```\n/plugin install pixel-pet --marketplace eranltd/pixel-pet\n```\n\nAnswer y to add the marketplace and choose the user scope. A mod installed from a terminal at the user scope loads in the desktop app too.\n\nThe repository is its own marketplace: one file, .claude-plugin/marketplace.json, lists the mod. To share yours, add that file and push the folder to GitHub.\n\n**What is the difference between a Claude Code plugin and a mod?** A mod is a plugin whose behavior lives in a hooks module, so it can change Claude Code’s own interface: bands, panes, commands, and reactions to tool calls.\n\n**Can a Claude Code mod draw custom UI?** Yes. A ui.render hook returns a tree of the engine's elements. The band above the prompt and side panes are the common places.\n\n**Does a mod work in the Claude Code desktop app?** Yes. Draw for e.surface === 'desktop' with Svg and the standard elements, and for terminal with Raster. Install from a terminal at the user scope.\n\n**How do I debug a Claude Code mod?** Run claude plugin validate first. While a folder hot-reloads, the transcript shows one dim line when a hook fails or a tree does not validate, and claude --debug logs every refusal.\n\n**How do I share a Claude Code mod?** Add .claude-plugin/marketplace.json to the mod's folder, push it to GitHub, and share /plugin install <name> --marketplace <owner>/<repo>.\n\n*The full source, the guide and the demo images are at* *github.com/eranltd/pixel-pet**. Start with the cat. Give it a tail that flicks.*\n\n[How to Build a Claude Code Mod: A Complete Example (With a Pixel Pet)](https://blog.devgenius.io/how-to-build-a-claude-code-mod-a-complete-example-with-a-pixel-pet-45fb1f85e2f3) was originally published in [Dev Genius](https://blog.devgenius.io) on Medium, where people are continuing the conversation by highlighting and responding to this story.", "url": "https://wpnews.pro/news/how-to-build-a-claude-code-mod-a-complete-example-with-a-pixel-pet", "canonical_source": "https://blog.devgenius.io/how-to-build-a-claude-code-mod-a-complete-example-with-a-pixel-pet-45fb1f85e2f3?source=rss----4e2c1156667e---4", "published_at": "2026-10-20 13:52:24+00:00", "updated_at": "2026-10-20 14:16:27.296880+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents", "artificial-intelligence"], "entities": ["Claude Code", "Anthropic", "Bit", "github.com/eranltd/pixel-pet"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-to-build-a-claude-code-mod-a-complete-example-with-a-pixel-pet", "markdown": "https://wpnews.pro/news/how-to-build-a-claude-code-mod-a-complete-example-with-a-pixel-pet.md", "text": "https://wpnews.pro/news/how-to-build-a-claude-code-mod-a-complete-example-with-a-pixel-pet.txt", "jsonld": "https://wpnews.pro/news/how-to-build-a-claude-code-mod-a-complete-example-with-a-pixel-pet.jsonld"}}