Gives Claude a tool that draws bar, line, area and donut charts 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 238 lines1import type { Register } from 'claude-code'
2
3const TOOL = 'mcp__chart__chart'
4
5type Kind = 'bar' | 'line' | 'area' | 'donut'
6type Series = { name: string; values: number[] }
7type Chart = { title: string; kind: Kind; labels: string[]; series: Series[]; unit: string }
8
9const PALETTE = ['#4cc9f0', '#f72585', '#ffd166', '#06d6a0', '#b388ff', '#ff8c42', '#90e0ef', '#ef476f']
10const hex = (h: string) => parseInt(h.slice(1), 16)
11const NONE = 0x01000000
12const GRID = 0x2a2f3a
13
14const fmt = (v: number) =>
15 Math.abs(v) >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : Math.abs(v) >= 1e3 ? `${(v / 1e3).toFixed(1)}k`
16 : Number.isInteger(v) ? String(v) : v.toFixed(1)
17
18const b64 = (w: Uint32Array) => (new Uint8Array(w.buffer) as unknown as { toBase64: () => string }).toBase64()
19
20// A pixel canvas drawn with half blocks: 2 pixels per terminal row
21const canvas = (cols: number, rows: number) => {
22 const ph = rows * 2
23 const px = new Uint32Array(cols * ph).fill(NONE)
24 return {
25 ph,
26 set: (x: number, y: number, c: number) => {
27 const xi = Math.round(x)
28 const yi = Math.round(y)
29 if (xi >= 0 && xi < cols && yi >= 0 && yi < ph) px[yi * cols + xi] = c
30 },
31 get: (x: number, y: number) => px[y * cols + x]!,
32 cells: () => {
33 const w = new Uint32Array(cols * rows * 3)
34 for (let r = 0; r < rows; r++) {
35 for (let x = 0; x < cols; x++) {
36 const top = px[r * 2 * cols + x]!
37 const bot = px[(r * 2 + 1) * cols + x]!
38 const i = (r * cols + x) * 3
39 w[i] = top === NONE && bot === NONE ? 0x20 : 0x2580
40 w[i + 1] = top
41 w[i + 2] = bot
42 }
43 }
44 return b64(w)
45 },
46 }
47}
48
49const line = (set: (x: number, y: number, c: number) => void, x0: number, y0: number, x1: number, y1: number, c: number) => {
50 const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1)
51 for (let k = 0; k <= n; k++) set(x0 + ((x1 - x0) * k) / n, y0 + ((y1 - y0) * k) / n, c)
52}
53
54const parse = (out: unknown): Chart | null => {
55 const text = typeof out === 'string' ? out
56 : Array.isArray(out) ? out.map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : '')).join('')
57 : ''
58 try {
59 const c = JSON.parse(text) as Chart
60 return Array.isArray(c.series) && c.series.length > 0 ? c : null
61 } catch {
62 return null
63 }
64}
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 await $.tool.register({
69 name: 'chart',
70 description:
71 'Draw a chart inline in the chat in true colour. kind: bar (compare categories, horizontal bars, grouped when several series), ' +
72 'line (trend over labels, several series), area (one or two series, filled), donut (shares of a whole; uses the first series). ' +
73 'labels are the categories or x points; each series has a name and one value per label. Keep under 40 labels and 6 series.',
74 inputSchema: {
75 type: 'object',
76 properties: {
77 title: { type: 'string' },
78 kind: { type: 'string', enum: ['bar', 'line', 'area', 'donut'] },
79 labels: { type: 'array', items: { type: 'string' } },
80 series: {
81 type: 'array',
82 items: { type: 'object', properties: { name: { type: 'string' }, values: { type: 'array', items: { type: 'number' } } }, required: ['name', 'values'] },
83 },
84 unit: { type: 'string' },
85 },
86 required: ['title', 'kind', 'labels', 'series'],
87 },
88 })
89 return next(e)
90 })
91
92 on('tool.call', { tool: TOOL }, async ($, e) => {
93 const input = e as unknown as Partial<Chart>
94 const labels = (input.labels ?? []).slice(0, 40).map(String)
95 const series = (input.series ?? []).slice(0, 6).map(s => ({ name: String(s.name), values: (s.values ?? []).slice(0, 40).map(Number) }))
96 if (labels.length === 0 || series.length === 0) return { deny: 'chart: give labels and at least one series.' }
97 const kind: Kind = ['bar', 'line', 'area', 'donut'].includes(String(input.kind)) ? (input.kind as Kind) : 'bar'
98 return { result: JSON.stringify({ title: String(input.title ?? 'Chart'), kind, labels, series, unit: String(input.unit ?? '') }) }
99 })
100
101 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
102 if (e.props.tool !== TOOL || e.props.isErrored || e.surface !== 'terminal') return next(e)
103 const c = parse(e.props.output)
104 if (!c) return next(e)
105 const { Box, Text, Raster } = $.ui.resolve(e)
106 const width = Math.max(40, Math.min(140, (e.viewport?.columns ?? 100) - 8))
107 const unit = c.unit ? ` ${c.unit}` : ''
108 const all = c.series.flatMap(s => s.values).filter(Number.isFinite)
109 const max = Math.max(...all, 0)
110 const min = Math.min(...all, 0)
111 const legend = c.series.length > 1 || c.kind === 'donut'
112
113 const legendOf = (items: { name: string; color: string; extra?: string }[]) => (
114 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
115 {items.map((it, i) => (
116 <Text key={`lg-${i}`}><Text color={it.color}>■</Text> {it.name}{it.extra ? <Text dimColor> {it.extra}</Text> : ''}</Text>
117 ))}
118 </Box>
119 )
120
121 if (c.kind === 'bar') {
122 const labelW = Math.min(18, Math.max(...c.labels.map(l => l.length)))
123 const barW = Math.max(10, width - labelW - 12)
124 return (
125 <Box flexDirection="column">
126 <Text bold>{c.title}</Text>
127 {c.labels.map((lab, i) => (
128 <Box key={`row-${i}`} flexDirection="column">
129 {c.series.map((s, k) => {
130 const v = s.values[i] ?? 0
131 const n = max > 0 ? Math.max(0, Math.round((v / max) * barW)) : 0
132 const color = PALETTE[k % PALETTE.length]!
133 return (
134 <Text key={`b-${i}-${k}`}>
135 <Text dimColor>{(k === 0 ? lab : '').slice(0, labelW).padEnd(labelW)} </Text>
136 <Text color={color}>{'█'.repeat(n)}{n === 0 ? '▏' : ''}</Text>
137 <Text> {fmt(v)}{unit}</Text>
138 </Text>
139 )
140 })}
141 </Box>
142 ))}
143 {legend && legendOf(c.series.map((s, k) => ({ name: s.name, color: PALETTE[k % PALETTE.length]! })))}
144 </Box>
145 )
146 }
147
148 if (c.kind === 'donut') {
149 const s = c.series[0]!
150 const vals = c.labels.map((_, i) => Math.max(0, s.values[i] ?? 0))
151 const total = vals.reduce((a, b) => a + b, 0) || 1
152 const rows = 12
153 const cols = rows * 2 + 2
154 const cv = canvas(cols, rows)
155 const cx = (cols - 1) / 2
156 const cy = (cv.ph - 1) / 2
157 const R = Math.min(cx, cy)
158 const ends: number[] = []
159 vals.reduce((acc, v) => { ends.push(acc + v / total); return acc + v / total }, 0)
160 for (let y = 0; y < cv.ph; y++) {
161 for (let x = 0; x < cols; x++) {
162 const d = Math.hypot(x - cx, y - cy)
163 if (d > R || d < R * 0.55) continue
164 const a = ((Math.atan2(y - cy, x - cx) + Math.PI / 2 + 2 * Math.PI) % (2 * Math.PI)) / (2 * Math.PI)
165 const idx = ends.findIndex(t => a <= t)
166 cv.set(x, y, hex(PALETTE[(idx < 0 ? ends.length - 1 : idx) % PALETTE.length]!))
167 }
168 }
169 return (
170 <Box flexDirection="column">
171 <Text bold>{c.title}</Text>
172 <Box flexDirection="row" gap={3} alignItems="center">
173 <Raster key={`d-${e.props.tool_use_id}`} columns={cols} rows={rows} cells={cv.cells()} />
174 <Box flexDirection="column">
175 {c.labels.map((lab, i) => (
176 <Text key={`dl-${i}`}>
177 <Text color={PALETTE[i % PALETTE.length]}>■</Text> {lab}
178 <Text dimColor> {fmt(vals[i]!)}{unit} {Math.round((vals[i]! / total) * 100)}%</Text>
179 </Text>
180 ))}
181 <Text dimColor>total {fmt(total)}{unit}</Text>
182 </Box>
183 </Box>
184 </Box>
185 )
186 }
187
188 // line and area: pixel plot with y labels on the left and x labels below
189 const yLabW = Math.max(fmt(max).length, fmt(min).length) + unit.length + 1
190 const cols = Math.min(512, width - yLabW - 2)
191 const rows = 12
192 const cv = canvas(cols, rows)
193 const span = max - min || 1
194 const yOf = (v: number) => (cv.ph - 1) - ((v - min) / span) * (cv.ph - 1)
195 const n = c.labels.length
196 const xOf = (i: number) => (n <= 1 ? 0 : (i / (n - 1)) * (cols - 1))
197 for (let k = 0; k <= 4; k++) {
198 const y = Math.round(((cv.ph - 1) * k) / 4)
199 for (let x = 0; x < cols; x += 2) cv.set(x, y, GRID)
200 }
201 c.series.forEach((s, k) => {
202 const color = hex(PALETTE[k % PALETTE.length]!)
203 if (c.kind === 'area') {
204 const fill = ((color >> 1) & 0x7f7f7f)
205 for (let x = 0; x < cols; x++) {
206 const t = n <= 1 ? 0 : (x / (cols - 1)) * (n - 1)
207 const i = Math.floor(t)
208 const v = (s.values[i] ?? 0) + ((s.values[Math.min(n - 1, i + 1)] ?? 0) - (s.values[i] ?? 0)) * (t - i)
209 for (let y = Math.round(yOf(v)); y < cv.ph; y++) cv.set(x, y, fill)
210 }
211 }
212 for (let i = 0; i < n - 1; i++) line(cv.set, xOf(i), yOf(s.values[i] ?? 0), xOf(i + 1), yOf(s.values[i + 1] ?? 0), color)
213 for (let i = 0; i < n; i++) cv.set(xOf(i), yOf(s.values[i] ?? 0), 0xffffff)
214 })
215 const step = Math.max(1, Math.ceil(n / Math.max(1, Math.floor(cols / 8))))
216 let xAxis = ''
217 for (let i = 0; i < n; i += step) {
218 const pos = Math.round(xOf(i))
219 xAxis = xAxis.padEnd(pos) + c.labels[i]!.slice(0, 7)
220 }
221 return (
222 <Box flexDirection="column">
223 <Text bold>{c.title}</Text>
224 <Box flexDirection="row">
225 <Box flexDirection="column" width={yLabW} height={rows} justifyContent="space-between">
226 <Text dimColor>{fmt(max)}{unit}</Text>
227 <Text dimColor>{fmt((max + min) / 2)}</Text>
228 <Text dimColor>{fmt(min)}{unit}</Text>
229 </Box>
230 <Raster key={`l-${e.props.tool_use_id}`} columns={cols} rows={rows} cells={cv.cells()} />
231 </Box>
232 <Text dimColor>{' '.repeat(yLabW)}{xAxis.slice(0, cols)}</Text>
233 {legend && legendOf(c.series.map((s, k) => ({ name: s.name, color: PALETTE[k % PALETTE.length]! })))}
234 </Box>
235 )
236 })
237}
238