Gives Claude a tool that plays truecolor animations inline in the chat

Thirteen mods I wrote for Claude Code in October 2026, the week mods were announced. A mod is a plugin of TypeScript hooks that runs inside Claude Code: it can draw panes and bands in the terminal, add slash commands, give Claude new tools, and block or react to tool calls. I built these to see how far the system goes, and a few of them I now use every day.
The one I use most is interview-coach: it asks a placement interview question aloud, records my spoken answer, transcribes it on the Mac with Whisper, and scores pace, filler words and structure against my own CV stories.
| Mod | How you use it | What it does |
|---|---|---|
| interview-coach | /coach | Spoken mock interview in a side pane. Asks a question aloud, records from the mic, transcribes locally (Whisper large-v3-turbo via whisper.cpp), then shows speaking time against the target, words per minute, filler words, time spent on each step of the answer framework, and two stronger example answers drawn from your profile. Keeps a WPM trend across sessions. |
| nihongo | /nihongo | Japanese tutor pane: kana drill that weights look-alike pairs and brings misses back sooner, 44 N5 word cards, and a sentence builder where Claude Haiku sets a line from the week's grammar and corrects your kana answer. A word band above the prompt shows a new word each turn. |
| joka-pond | /pond | Animated ASCII pond that earns XP for every tool Claude uses and climbs a consulting ladder from Intern to Managing Partner. Storms when a tool fails, keeps a daily streak. |
| aurora | /visuals, /aurora | Truecolor pixel art: an aurora band above the prompt whose colours follow the time of day, plus a gallery (plasma, ocean sunset, warp, a 4-4-6 breathing lotus). |
| avatar | /buddy | A pixel companion docked beside the chat. Its mood follows Claude: thinking, happy after a reply, worried after a failure, sleepy after midnight. |
| chart | Claude calls it | Gives Claude a tool that draws bar, line, area and donut charts inline in the chat, with axes, labels and legends. |
| heatmap | Claude calls it | Inline truecolor heatmaps with heat, green and diverging palettes. |
| diagram | Claude calls it | Flowcharts drawn in the terminal: coloured boxes by role, laid out top to bottom with labelled arrows. |
| animate | Claude calls it | Plays short truecolor animations (fireworks, rain, aurora and others) inside a chat row at about 15 fps. |
| pixel | Claude calls it | Draws a pixel-art image in the chat from a JSON grid of colours. |
| change-ledger | /changes | Pane listing every file Claude created or edited this session. |
| em-dash-guard | automatic | Blocks any write or edit that adds an em dash, my house style for documents. |
| hello-mod | /hi | Starter mod: an instant reply with no model call, a guard that blocks rm -rf, and a toast at the end of each turn. |
statusline/statusline-command.sh is my status line: model, effort, context used, 5-hour and weekly usage with time left, folder and git branch, colour-coded green, yellow and red.
claude --plugin-dir ./mods/chart
Pass --plugin-dir once per mod you want. claude plugin validate ./mods/chart checks a mod before loading it.
interview-coach needs ffmpeg, whisper-cpp (brew install ffmpeg whisper-cpp) and a Whisper model file. Set the paths at the top of mods/interview-coach/hooks/register.tsx, and copy profile.example.md to the profile path with your own experiences.
$ context object can't be stored or passed around. Long-running work has to start from a worker in session.start; buttons only queue jobs for it.$.ui.blit instead.session.start becomes callable only from the next prompt.-progress flag must come before -i, or progress output never arrives.hooks/register.tsx 274 lines1import type { Register } from 'claude-code'
2
3const TOOL = 'mcp__animate__animate'
4type Scene = 'aurora' | 'plasma' | 'sunset' | 'warp' | 'breathe' | 'fireworks' | 'rain'
5const SCENE_IDS: Scene[] = ['aurora', 'plasma', 'sunset', 'warp', 'breathe', 'fireworks', 'rain']
6
7type RGB = [number, number, number]
8
9
10// ---------- colour helpers ----------
11const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
12const mix = (a: RGB, b: RGB, k: number): RGB => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
13const scale = (c: RGB, k: number): RGB => [c[0] * k, c[1] * k, c[2] * k]
14const pack = (c: RGB) => (clamp(c[0]) << 16) | (clamp(c[1]) << 8) | clamp(c[2])
15const ramp = (stops: RGB[], k: number): RGB => {
16 const t = Math.max(0, Math.min(0.9999, k)) * (stops.length - 1)
17 const i = Math.floor(t)
18 return mix(stops[i]!, stops[i + 1]!, t - i)
19}
20const smooth = (e0: number, e1: number, x: number) => {
21 const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0)))
22 return t * t * (3 - 2 * t)
23}
24
25// Palettes follow the clock: night, dawn, day, dusk
26const palette = (): RGB[] => {
27 const h = new Date().getHours()
28 if (h >= 5 && h < 8) return [[255, 120, 150], [255, 180, 120], [180, 120, 255]]
29 if (h >= 8 && h < 17) return [[60, 200, 255], [120, 255, 200], [90, 140, 255]]
30 if (h >= 17 && h < 20) return [[255, 110, 60], [255, 60, 140], [140, 70, 255]]
31 return [[40, 255, 170], [60, 180, 255], [190, 90, 255]]
32}
33
34// ---------- shaders: (x, y in 0..1, t seconds) -> colour ----------
35const aurora = (x: number, y: number, t: number, pal: RGB[], energy: number): RGB => {
36 const sky: RGB = mix([4, 6, 20], [10, 14, 40], y)
37 let col = sky
38 for (let band = 0; band < 3; band++) {
39 const center = 0.5 + 0.28 * Math.sin(x * (3 + band) + t * (0.25 + band * 0.1) * energy + band * 2)
40 + 0.08 * Math.sin(x * 11 - t * 0.7 * energy + band)
41 const d = Math.abs(y - center)
42 const glow = Math.exp(-d * d * 60) * (0.55 + 0.45 * Math.sin(x * 20 + t * 1.5 * energy + band * 1.7))
43 col = mix(col, pal[band % pal.length]!, Math.min(1, glow * (0.5 + 0.5 * energy)))
44 }
45 return col
46}
47
48const plasma = (x: number, y: number, t: number): RGB => {
49 const v = Math.sin(x * 10 + t) + Math.sin((y * 10 + t) / 2) + Math.sin((x * 10 + y * 10 + t) / 2)
50 + Math.sin(Math.sqrt((x - 0.5) ** 2 * 100 + (y - 0.5) ** 2 * 100) + t)
51 const k = (v + 4) / 8
52 return ramp([[20, 0, 60], [255, 40, 120], [255, 180, 60], [40, 220, 255], [20, 0, 60]], (k + t * 0.05) % 1)
53}
54
55const sunset = (x: number, y: number, t: number): RGB => {
56 const horizon = 0.55
57 if (y < horizon) {
58 const sky = ramp([[30, 10, 70], [190, 50, 120], [255, 140, 70], [255, 210, 130]], y / horizon)
59 const sun = Math.hypot((x - 0.5) * 1.6, y - horizon + 0.08)
60 const disc = smooth(0.17, 0.15, sun)
61 const stripes = y > horizon - 0.12 && Math.sin(y * 120) > 0.4 ? 0 : 1
62 return mix(sky, [255, 230, 150], disc * stripes)
63 }
64 const depth = (y - horizon) / (1 - horizon)
65 const wave = Math.sin(x * 40 + t * 2 + depth * 30) * Math.sin(x * 13 - t + depth * 10)
66 const water = ramp([[120, 40, 110], [40, 20, 80], [10, 10, 40]], depth)
67 const glitter = Math.abs(x - 0.5) < 0.12 * (1 - depth * 0.5) && wave > 0.5 ? 0.7 : 0
68 return mix(water, [255, 200, 120], glitter)
69}
70
71const warp = (x: number, y: number, t: number): RGB => {
72 const dx = x - 0.5
73 const dy = (y - 0.5) * 0.6
74 const ang = Math.atan2(dy, dx)
75 const r = Math.hypot(dx, dy)
76 const lane = Math.floor((ang + Math.PI) * 18)
77 const seed = Math.sin(lane * 91.7) * 43758.5
78 const speed = 0.5 + (seed - Math.floor(seed))
79 const pos = (t * speed * 0.6 + (seed - Math.floor(seed))) % 1
80 const streak = Math.exp(-Math.abs(r - pos * 0.8) * 40) * smooth(0.02, 0.15, r)
81 const hue = ramp([[120, 160, 255], [255, 255, 255], [255, 120, 220]], (seed * 7) % 1 < 0 ? 0 : ((seed * 7) % 1))
82 return mix([2, 2, 10], hue, Math.min(1, streak * 1.5))
83}
84
85// Breathing lotus: in 4s, hold 4s, out 6s
86const BREATH = 14
87const breathPhase = (t: number) => {
88 const c = t % BREATH
89 if (c < 4) return { label: 'breathe in', left: 4 - c, k: smooth(0, 1, c / 4) }
90 if (c < 8) return { label: 'hold', left: 8 - c, k: 1 }
91 return { label: 'breathe out', left: BREATH - c, k: 1 - smooth(0, 1, (c - 8) / 6) }
92}
93const breathe = (x: number, y: number, t: number, aspect: number): RGB => {
94 const { k } = breathPhase(t)
95 const dx = (x - 0.5) * aspect
96 const dy = y - 0.5
97 const r = Math.hypot(dx, dy)
98 const ang = Math.atan2(dy, dx)
99 const size = 0.12 + 0.22 * k
100 const petals = size * (0.75 + 0.25 * Math.abs(Math.cos(ang * 4 + t * 0.2)))
101 const inner = size * 0.45 * (0.8 + 0.2 * Math.cos(ang * 8 - t * 0.3))
102 const bg: RGB = mix([6, 10, 24], [18, 12, 40], y)
103 const halo = Math.exp(-((r - petals) ** 2) * 300) * 0.6
104 let col = mix(bg, [120, 90, 255], halo)
105 if (r < petals) col = mix([255, 120, 190], [255, 200, 230], 1 - r / petals)
106 if (r < inner) col = mix([255, 220, 120], [255, 250, 220], 1 - r / inner)
107 return col
108}
109
110// Fireworks: bursts that expand and fade, seeded per cycle
111const hash = (n: number) => {
112 const x = Math.sin(n * 127.1) * 43758.5453
113 return x - Math.floor(x)
114}
115const fireworks = (x: number, y: number, t: number, aspect: number): RGB => {
116 let col: RGB = mix([2, 2, 12], [10, 6, 30], y)
117 for (let k = 0; k < 4; k++) {
118 const cycle = Math.floor(t / 2.2 + k * 0.37)
119 const age = (t / 2.2 + k * 0.37) - cycle
120 const seed = cycle * 7 + k
121 const cx = 0.15 + 0.7 * hash(seed)
122 const cy = 0.2 + 0.4 * hash(seed + 1)
123 const hue = ramp([[255, 80, 80], [255, 200, 60], [80, 255, 160], [90, 160, 255], [230, 90, 255]], hash(seed + 2))
124 const dx = (x - cx) * aspect
125 const dy = y - cy
126 const r = Math.hypot(dx, dy)
127 const ang = Math.atan2(dy, dx)
128 const radius = age * 0.35
129 const spokes = Math.pow(Math.max(0, Math.cos(ang * 9 + seed)), 6)
130 const ring = Math.exp(-((r - radius) ** 2) * 900) * spokes
131 const fade = (1 - age) * (1 - age)
132 col = mix(col, hue, Math.min(1, ring * fade * 2.5))
133 }
134 return col
135}
136
137// Rain: Matrix-style falling streams
138const rain = (x: number, y: number, t: number, cols: number): RGB => {
139 const lane = Math.floor(x * cols)
140 const speed = 0.4 + hash(lane) * 0.8
141 const head = (t * speed + hash(lane + 99)) % 1.4
142 const d = head - y
143 const glow = d >= 0 && d < 0.5 ? (1 - d / 0.5) ** 2 : 0
144 const isHead = d >= 0 && d < 0.03
145 return isHead ? [200, 255, 210] : mix([0, 6, 2], [30, 255, 90], glow)
146}
147
148const render = (cols: number, rows: number, shade: (x: number, y: number) => RGB, caption?: string): string => {
149 const words = new Uint32Array(cols * rows * 3)
150 const ph = rows * 2
151 for (let r = 0; r < rows; r++) {
152 for (let c = 0; c < cols; c++) {
153 const i = (r * cols + c) * 3
154 words[i] = 0x2580
155 words[i + 1] = pack(shade(c / cols, (r * 2) / ph))
156 words[i + 2] = pack(shade(c / cols, (r * 2 + 1) / ph))
157 }
158 }
159 if (caption) {
160 const text = caption.replace(/[^\x20-\x7e]/g, '').slice(0, cols)
161 const start = Math.max(0, Math.floor((cols - text.length) / 2))
162 const row = rows - 1
163 for (let k = 0; k < text.length; k++) {
164 const i = (row * cols + start + k) * 3
165 words[i] = text.charCodeAt(k)
166 words[i + 1] = 0xffffff
167 }
168 }
169 return (new Uint8Array(words.buffer) as unknown as { toBase64: () => string }).toBase64()
170}
171
172type Spec = { scene: Scene; seconds: number; caption: string; rows: number }
173type Live = Spec & { cols: number; startedAt: number }
174
175const parseSpec = (out: unknown): Spec | null => {
176 const text = typeof out === 'string' ? out
177 : Array.isArray(out) ? out.map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : '')).join('')
178 : ''
179 try {
180 const s = JSON.parse(text) as Spec
181 return SCENE_IDS.includes(s.scene) ? s : null
182 } catch {
183 return null
184 }
185}
186
187export const register: Register = on => {
188 // Rows currently playing, by tool_use_id (the row's site for $.ui.blit)
189 const live = new Map<string, Live>()
190
191 const frame = (l: Live, now: number) => {
192 const t = (now - l.startedAt) / 1000
193 const aspect = l.cols / (l.rows * 2)
194 const pal = palette()
195 let caption = l.caption
196 if (l.scene === 'breathe') {
197 const b = breathPhase(t)
198 caption = `${b.label} ${Math.ceil(b.left)}`
199 }
200 const shade = (x: number, y: number): RGB =>
201 l.scene === 'aurora' ? aurora(x, y, t, pal, 1)
202 : l.scene === 'plasma' ? plasma(x, y, t)
203 : l.scene === 'sunset' ? sunset(x, y, t)
204 : l.scene === 'warp' ? warp(x, y, t)
205 : l.scene === 'breathe' ? breathe(x, y, t, aspect)
206 : l.scene === 'fireworks' ? fireworks(x, y, t, aspect)
207 : rain(x, y, t, l.cols)
208 return render(l.cols, l.rows, shade, caption)
209 }
210
211 on('session.start', async ($, e, next) => {
212 await $.tool.register({
213 name: 'animate',
214 description:
215 'Play a short truecolor animation inline in the chat, in this tool\'s own row. Use when the user asks for an animation, ' +
216 'a celebration (fireworks), a calm moment (breathe: a 4-4-6 breathing lotus), or ambient visuals. ' +
217 'scene: aurora | plasma | sunset | warp | breathe | fireworks | rain. seconds: 3 to 120 (default 20). caption: optional short text.',
218 inputSchema: {
219 type: 'object',
220 properties: {
221 scene: { type: 'string', enum: SCENE_IDS },
222 seconds: { type: 'number' },
223 caption: { type: 'string' },
224 rows: { type: 'number', description: 'height in terminal rows, 4 to 20, default 10' },
225 },
226 required: ['scene'],
227 },
228 })
229
230 // ~15 fps: repaint only the pixels of live rows; drop finished ones
231 $.clock.every(66, () => {
232 const now = Date.now()
233 for (const [id, l] of live) {
234 if (now - l.startedAt > l.seconds * 1000) {
235 live.delete(id)
236 continue
237 }
238 void $.ui.blit({ requestId: id, key: 'anim', cells: frame(l, now) }).then(r => {
239 if (r && 'deny' in r && r.deny) live.delete(id)
240 }).catch(() => live.delete(id))
241 }
242 })
243 return next(e)
244 })
245
246 on('tool.call', { tool: TOOL }, async ($, e) => {
247 const input = e as unknown as Partial<Spec>
248 const scene = SCENE_IDS.includes(input.scene as Scene) ? (input.scene as Scene) : 'aurora'
249 const spec: Spec = {
250 scene,
251 seconds: Math.max(3, Math.min(120, Number(input.seconds) || 20)),
252 caption: String(input.caption ?? ''),
253 rows: Math.max(4, Math.min(20, Number(input.rows) || 10)),
254 }
255 return { result: JSON.stringify({ ...spec, note: `Playing ${scene} for ${spec.seconds}s in the chat.` }) }
256 })
257
258 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
259 if (e.props.tool !== TOOL || e.props.isErrored || e.surface !== 'terminal') return next(e)
260 const spec = parseSpec(e.props.output)
261 if (!spec) return next(e)
262 const { Raster } = $.ui.resolve(e)
263 const id = e.props.tool_use_id
264 const cols = Math.max(20, Math.min(512, (e.viewport?.columns ?? 100) - 6))
265 const existing = live.get(id)
266 const l: Live = existing && existing.cols === cols ? existing
267 : { ...spec, cols, startedAt: existing?.startedAt ?? Date.now() }
268 // Only (re)start rows that are still within their play time
269 if (!existing && Date.now() - l.startedAt < spec.seconds * 1000) live.set(id, l)
270 if (existing && existing.cols !== cols) live.set(id, l)
271 return <Raster key="anim" columns={cols} rows={spec.rows} cells={frame(l, Date.now())} />
272 })
273}
274