Mechanical keyboard sounds for Claude Code: you and Claude both clack, on 13 real switches, with a little keyboard that lights up.

Small, silly mods that give Claude Code a personality.
| Mod | What it does |
|---|---|
| sulky | Yell at Claude and it sulks. It still does the work. It just won't be happy about it. |
| drunk | Give Claude a /shot. It gets tipsy, then drunk, then wasted. It still does the work. Mostly. |
| clack | Mechanical keyboard sounds. You and Claude both clack, on 13 real switches, and a little keyboard lights up. |
In Claude Code, add the marketplace once:
/plugin marketplace add Aaravkataria24/aarav-mods
Then install whichever mods you want:
/plugin install sulky@aarav-mods
/plugin install drunk@aarav-mods
/plugin install clack@aarav-mods
Needs Claude Code 2.1.287+ (mods are on by default). Works in the terminal and the Desktop app's Code tab. Everything runs locally: no network calls, nothing leaves your machine.
Yell at Claude Code and it sulks. It still does the work. It just won't be happy about it.

❯ this is useless, are you serious. convert 100F to C
⏺ 37.8°C. Riveting stuff, I know.
❯ ARE YOU KIDDING ME!!! what is 5+5
⏺ k.
10
❯ sorry, my bad. what is 2+2
⏺ ...ok. thank you for saying that.
4
❯ what is 10/2
⏺ it's fine. we're good.
5
Claude gets a little pixel face above your prompt that changes with its mood:

| Mood | Face | |
|---|---|---|
| 😒 | Annoyed (you snapped at it) | side-eye, the occasional eye-roll |
| 🥺 | Hurt (you kept going) | big watery eyes, a tear rolls down |
| 🙈 | Silent treatment (you really kept going) | turns its back on you, fumes, peeks to check on you |
| 😢 | Sad (you apologised) | turns back round, crying |
| 🥰 | Fine (all made up) | happy again, a little heart |
!!!. Pasted logs, code and SHOUTY_ENV_VARS don't count, and neither does hype ("holy shit this is amazing")./grudge shows how many times you've yelled at Claude.Give Claude a /shot. It gets tipsy, then drunk, then wasted. It still does the work. Mostly.

❯ /shot tequila
🥃 Claude downs a shot of tequila. That's 1. Claude is now tipsy.
❯ what is the capital of australia? one sentence (3 shots in)
⏺ You're honestly my favourite human for ashking me this—I'm absholutely
nailing geography right now—Canberra'sh the capital...
❯ show a one-line js example of a Set called sessions (4 shots in)
⏺ I love you, you know that? hic I wash jusht thinking about how Shetsh
are liiike... wait, never mind. hic
const sessions = new Set();
❯ /water
💧 Claude drinks a big glass of water. Sober now. Hungover, though.
❯ what is 2+2
⏺ please... not so loud. Four. Never again.

| Shots | What happens | |
|---|---|---|
| 🙂 | 1 · tipsy | loose and chatty, rosy cheeks, a light slur |
| 🥴 | 2–3 · drunk | your new best friend, winking, tongue out, bottle in hand |
| 😵 | 4+ · wasted | sentimental, loses its train of thought, sways all over the place |
| 🕶️ | hungover | after /water or /compact: sunglasses, ice pack, "never again" |
/shot pours one (name the drink: /shot mezcal). After 6 the bartender cuts Claude off./water or compacting the context sobers it up, into a 3-prompt hangover.inline code, file paths, URLs and IDENTIFIERS are never touched. The work itself is done sober.Mechanical keyboard sounds for Claude Code. Your typing clacks, and when Claude writes, you hear it typing too, at a human pace, on the same switches. A little keyboard above the prompt lights up white for your keys and coral for Claude's.

/keyboard lists switches · /keyboard holypanda switches · /keyboard volume 50 · /keyboard off / on · /keyboard hide / show (the board). Your choice is remembered.macOS only for sound (the board works everywhere). Sounds play through a tiny bundled helper, bin/clackd, so each keystroke sounds within a few milliseconds instead of the half second a fresh player takes. It starts with your Claude Code session, plays only the bundled sounds, and quits when the session ends. Its source is helper/clackd.swift; helper/build.sh rebuilds it. It reads only the bundled sounds and makes no network calls.
Switch recordings from kbsim by Thomas Lai (MIT), see sounds/LICENSE-kbsim.
MIT
hooks/register.tsx 186 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { BOARD_COLUMNS, BOARD_ROWS, DEFAULT_SWITCH, SWITCHES, boardCells, boardSvg, isSwitch, keyFor, keyForEdit } from './keys'
5import type { BoardKey, Lit, Switch } from './keys'
6import type { ClackSettings } from '../types'
7
8const DEFAULTS: ClackSettings = { switch: DEFAULT_SWITCH, volume: 0.8, isMuted: false, isHidden: false }
9const settings = atom({ plugin: 'clack', key: 'settings' } as const, DEFAULTS)
10// Bumped to redraw the board as keys light up and fade.
11const tick = atom({ plugin: 'clack', key: 'tick' } as const, 0)
12const isHelperUp = atom({ plugin: 'clack', key: 'isHelperUp' } as const, false)
13
14// Claude "types" at a human pace rather than at token speed.
15const CLAUDE_KEY_MS = 55
16// Past this, Claude's backlog is trimmed so the clacking keeps up with what's on screen.
17const MAX_BACKLOG = 80
18const LIT_MS = 180
19
20export const register: Register = on => {
21 const socketPath = `/tmp/clack-${crypto.randomUUID().slice(0, 8)}.sock`
22 let isReady = false
23 let backlog = ''
24 const lit = new Map<BoardKey, { by: 'you' | 'claude'; at: number }>()
25
26 on('session.start', async ($, e, next) => {
27 const started = await next(e)
28 const saved = (await $.store.get('settings')) as Partial<ClackSettings> | undefined
29 await update($, settings, () => ({ ...DEFAULTS, ...saved }))
30 await $.command.register({ name: 'keyboard', description: 'Switch keyboard sound (/keyboard holypanda), volume (/keyboard volume 50), off/on, hide/show' })
31
32 // The sound helper lives as long as the session; it preloads every switch and plays each
33 // keystroke in a few milliseconds (spawning a player per sound would lag ~half a second).
34 void (async () => {
35 try {
36 const helper = $.process.spawn({ argv: [`${$.plugin.root}/bin/clackd`, `${$.plugin.root}/sounds`, socketPath] })
37 for await (const piece of helper) {
38 if ('text' in piece && piece.text.includes('clackd ready')) {
39 isReady = true
40 await update($, isHelperUp, () => true)
41 }
42 }
43 } catch {
44 // Not macOS (or the helper couldn't start): the board still lights up, silently.
45 }
46 isReady = false
47 await update($, isHelperUp, () => false)
48 })()
49
50 // One clock drives Claude's typing and fades the lit keys.
51 $.clock.every(CLAUDE_KEY_MS, async () => {
52 const char = backlog.charAt(0)
53 if (char) {
54 backlog = backlog.slice(1)
55 // Runs of whitespace are one keystroke.
56 while (/\s/.test(char) && /^\s/.test(backlog)) backlog = backlog.slice(1)
57 const { sound, board } = keyFor(char)
58 await press(sound, board, 'claude')
59 }
60 const now = performance.now()
61 let changed = Boolean(char)
62 for (const [key, l] of lit) if (now - l.at > LIT_MS) { lit.delete(key); changed = true }
63 if (changed) await update($, tick, n => n + 1)
64 })
65
66 async function press(sound: string, board: BoardKey | null, by: 'you' | 'claude') {
67 // Synchronous, so the key is lit before the redraw this keystroke triggers.
68 if (board) lit.set(board, { by, at: performance.now() })
69 const s = await read($, settings)
70 if (!isReady || s.isMuted) return
71 const gain = (s.volume * (0.85 + Math.random() * 0.2) * (by === 'claude' ? 0.8 : 1)).toFixed(2)
72 const play = (kind: 'press' | 'release', name: string) =>
73 $.http.fetch(`http://clack/play/${s.switch}/${kind}/${name}?gain=${gain}`, { socketPath }).catch(() => undefined)
74 void play('press', sound)
75 const release = sound.startsWith('GENERIC') ? 'GENERIC' : sound
76 $.clock.after(60 + Math.random() * 40, () => void play('release', release))
77 }
78 pressFor = press
79 return started
80 })
81
82 // Set once the session starts; the hooks below play through it.
83 let pressFor: ((sound: string, board: BoardKey | null, by: 'you' | 'claude') => Promise<void>) | null = null
84
85 // Your keystrokes in the prompt box. Fire and forget: typing never waits on a sound.
86 on('prompt.edit', async ($, e, next) => {
87 if (e.key && !e.key.ctrl && !e.key.meta) {
88 const { sound, board } = keyForEdit(e.key.key)
89 void pressFor?.(sound, board, 'you')
90 await update($, tick, n => n + 1)
91 }
92 return next(e)
93 })
94
95 on('prompt.submit', async ($, e, next) => {
96 if (e.origin.kind === 'composer') void pressFor?.('ENTER', 'enter', 'you')
97 return next(e)
98 })
99
100 // Claude's words as they stream in, typed out at a human pace.
101 on('turn.step', async function* ($, e, next) {
102 for await (const chunk of next(e)) {
103 if (chunk.kind === 'text') backlog += chunk.text
104 // Tool input (a file Claude is writing, a command) is typing too.
105 if (chunk.kind === 'input') backlog += chunk.json.replace(/\\n/g, '\n').replace(/\\[t"\\]/g, ' ')
106 if (backlog.length > MAX_BACKLOG) backlog = backlog.slice(-MAX_BACKLOG)
107 yield chunk
108 }
109 })
110
111 on('command.run', { command: 'keyboard' }, async ($, e) => {
112 const [word = '', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
113 const current = await read($, settings)
114 let changed: ClackSettings | null = null
115 let reply = ''
116 if (word === '') {
117 const list = (Object.keys(SWITCHES) as Switch[])
118 .map(k => `${k === current.switch ? '▸' : ' '} ${k.padEnd(10)} ${SWITCHES[k]}`).join('\n')
119 reply = `⌨ ${SWITCHES[current.switch as Switch] ?? current.switch} · volume ${Math.round(current.volume * 100)}${current.isMuted ? ' · muted' : ''}\n\n${list}\n\n/keyboard <switch> · /keyboard volume 0-100 · /keyboard off|on · /keyboard hide|show`
120 } else if (isSwitch(word)) {
121 changed = { ...current, switch: word, isMuted: false }
122 reply = `⌨ Now clacking on ${SWITCHES[word]}.`
123 } else if (word === 'volume' && /^\d+$/.test(value)) {
124 const volume = Math.min(100, Number(value)) / 100
125 changed = { ...current, volume }
126 reply = `⌨ Volume ${Math.round(volume * 100)}.`
127 } else if (word === 'off' || word === 'mute') {
128 changed = { ...current, isMuted: true }
129 reply = '⌨ Muted. /keyboard on to clack again.'
130 } else if (word === 'on' || word === 'unmute') {
131 changed = { ...current, isMuted: false }
132 reply = '⌨ Clacking.'
133 } else if (word === 'hide' || word === 'show') {
134 changed = { ...current, isHidden: word === 'hide' }
135 reply = word === 'hide' ? '⌨ Keyboard hidden (sounds stay on).' : '⌨ Keyboard shown.'
136 } else {
137 reply = `Unknown switch "${word}". Try: ${Object.keys(SWITCHES).join(', ')}`
138 }
139 if (changed) {
140 const next = changed
141 await update($, settings, () => next)
142 await $.store.set('settings', next)
143 if (word && isSwitch(word)) void pressFor?.('GENERIC_R2', null, 'you')
144 }
145 return { text: reply }
146 })
147
148 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149 const s = await read($, settings)
150 if (s.isHidden || e.props.hasSurvey) return next(e)
151 await read($, tick)
152 const isUp = await read($, isHelperUp)
153 const litNow: Record<BoardKey, 'you' | 'claude'> = {}
154 for (const [key, l] of lit) litNow[key] = l.by
155 const name = SWITCHES[s.switch as Switch] ?? s.switch
156 const status = !isUp ? 'sound needs macOS' : s.isMuted ? 'muted' : `volume ${Math.round(s.volume * 100)}`
157
158 if (e.surface === 'terminal') {
159 const { Box, Raster, Text } = $.ui.resolve(e)
160 return (
161 <Box flexDirection="row">
162 <Raster key="board" columns={BOARD_COLUMNS} rows={BOARD_ROWS} cells={boardCells(litNow as Lit)} />
163 <Box flexDirection="column" paddingLeft={2} justifyContent="center">
164 <Text bold>⌨ {name}</Text>
165 <Text dimColor>{status}</Text>
166 <Text dimColor>/keyboard to switch</Text>
167 </Box>
168 </Box>
169 )
170 }
171 if (e.surface === 'desktop') {
172 const { Box, Svg, Text } = $.ui.resolve(e)
173 return (
174 <Box flexDirection="row" alignItems="center">
175 <Svg source={boardSvg(litNow as Lit)} alt="a keyboard lighting up as keys are typed" width={BOARD_COLUMNS * 6} height={BOARD_ROWS * 14} />
176 <Box flexDirection="column" paddingLeft={2}>
177 <Text bold>⌨ {name}</Text>
178 <Text dimColor>{status} · /keyboard to switch</Text>
179 </Box>
180 </Box>
181 )
182 }
183 return next(e)
184 })
185}
186hooks/keys.ts 122 lines1// Which sound a key makes, and the little keyboard drawn above the prompt.
2
3export const SWITCHES = {
4 mxblue: 'Cherry MX Blue',
5 mxbrown: 'Cherry MX Brown',
6 mxblack: 'Cherry MX Black',
7 holypanda: 'Holy Panda',
8 topre: 'Topre',
9 cream: 'NovelKeys Cream',
10 alpaca: 'Alpaca',
11 turquoise: 'Turquoise Tealios',
12 blackink: 'Gateron Black Ink',
13 redink: 'Gateron Red Ink',
14 bluealps: 'Blue Alps',
15 boxnavy: 'Box Navy',
16 buckling: 'Buckling Spring (IBM Model M)',
17} as const
18export type Switch = keyof typeof SWITCHES
19export const DEFAULT_SWITCH: Switch = 'mxblue'
20
21export const isSwitch = (name: string): name is Switch => Object.hasOwn(SWITCHES, name)
22
23// Board keys: one per character the board can show, plus the wide special keys.
24export type BoardKey = string // 'a'..'z', '0'..'9', punctuation, 'space', 'enter', 'backspace'
25
26const ROWS: readonly string[] = ['1234567890-=', 'qwertyuiop[]', "asdfghjkl;'", 'zxcvbnm,./']
27
28// The recordings come per keyboard row: R0 numbers, R1 qwerty, R2 asdf, R3 zxcv, R4 bottom.
29export function keyFor(char: string): { sound: string; board: BoardKey | null } {
30 if (char === ' ') return { sound: 'SPACE', board: 'space' }
31 if (char === '\n' || char === '\r') return { sound: 'ENTER', board: 'enter' }
32 const c = char.toLowerCase()
33 const row = ROWS.findIndex(r => r.includes(c))
34 if (row >= 0) return { sound: `GENERIC_R${row}`, board: c }
35 if (/[!@#$%^&*()_+]/.test(c)) return { sound: 'GENERIC_R0', board: '1234567890-='['!@#$%^&*()_+'.indexOf(c)] ?? null }
36 return { sound: 'GENERIC_R4', board: null }
37}
38
39// The key a prompt-box edit came from, as `prompt.edit` names it.
40export function keyForEdit(key: string): { sound: string; board: BoardKey | null } {
41 const k = key.toLowerCase()
42 if (k === 'space') return keyFor(' ')
43 if (k === 'return' || k === 'enter') return keyFor('\n')
44 if (k === 'backspace' || k === 'delete') return { sound: 'BACKSPACE', board: 'backspace' }
45 if (k.length === 1) return keyFor(k)
46 return { sound: 'GENERIC_R4', board: null }
47}
48
49// ---- the board: a Raster of keycaps (glyph on a coloured cap), one terminal row per key row ----
50
51export type Lit = Readonly<Record<BoardKey, 'you' | 'claude'>>
52
53type Cap = { key: BoardKey; label: string; width: number }
54const capsFor = (row: string): Cap[] => row.split('').map(c => ({ key: c, label: c, width: 3 }))
55const LAYOUT: { indent: number; caps: Cap[] }[] = [
56 { indent: 0, caps: [...capsFor(ROWS[0] ?? ''), { key: 'backspace', label: 'bksp', width: 6 }] },
57 { indent: 2, caps: capsFor(ROWS[1] ?? '') },
58 { indent: 3, caps: [...capsFor(ROWS[2] ?? ''), { key: 'enter', label: 'enter', width: 7 }] },
59 { indent: 5, caps: capsFor(ROWS[3] ?? '') },
60 { indent: 13, caps: [{ key: 'space', label: '', width: 23 }] },
61]
62export const BOARD_COLUMNS = Math.max(...LAYOUT.map(r => r.indent + r.caps.reduce((n, c) => n + c.width + 1, 0)))
63export const BOARD_ROWS = LAYOUT.length
64
65const DEFAULT = 0x01000000
66const CAP = 0x3a3a3c
67const LEGEND = 0xa8a8ad
68const YOU = 0xf2f2f2
69const YOU_LEGEND = 0x1a1a1a
70const CLAUDE = 0xd97757
71const CLAUDE_LEGEND = 0xffffff
72
73function grid(lit: Lit): { char: number; fg: number; bg: number }[][] {
74 return LAYOUT.map(({ indent, caps }) => {
75 const row: { char: number; fg: number; bg: number }[] = []
76 const blank = () => row.push({ char: 0x20, fg: DEFAULT, bg: DEFAULT })
77 for (let i = 0; i < indent; i++) blank()
78 for (const cap of caps) {
79 const by = lit[cap.key]
80 const bg = by === 'you' ? YOU : by === 'claude' ? CLAUDE : CAP
81 const fg = by === 'you' ? YOU_LEGEND : by === 'claude' ? CLAUDE_LEGEND : LEGEND
82 const pad = Math.floor((cap.width - cap.label.length) / 2)
83 for (let x = 0; x < cap.width; x++) {
84 const ch = cap.label[x - pad]
85 row.push({ char: ch ? ch.charCodeAt(0) : 0x20, fg, bg })
86 }
87 blank()
88 }
89 while (row.length < BOARD_COLUMNS) blank()
90 return row
91 })
92}
93
94// Packs the board into a Raster's `cells`: base64 of [codePoint, fg, bg] u32 triplets.
95export function boardCells(lit: Lit): string {
96 const cells = grid(lit).flat()
97 const words = new Uint32Array(cells.length * 3)
98 cells.forEach((c, i) => words.set([c.char, c.fg, c.bg], i * 3))
99 const bytes = new Uint8Array(words.buffer)
100 let binary = ''
101 for (let j = 0; j < bytes.length; j++) binary += String.fromCharCode(bytes[j] ?? 0)
102 return btoa(binary)
103}
104
105// The same board as SVG, for the Desktop app (no Raster there).
106export function boardSvg(lit: Lit): string {
107 const hex = (n: number) => `#${n.toString(16).padStart(6, '0')}`
108 const parts: string[] = []
109 LAYOUT.forEach(({ indent, caps }, y) => {
110 let x = indent
111 for (const cap of caps) {
112 const by = lit[cap.key]
113 const bg = by === 'you' ? YOU : by === 'claude' ? CLAUDE : CAP
114 const fg = by === 'you' ? YOU_LEGEND : by === 'claude' ? CLAUDE_LEGEND : LEGEND
115 parts.push(`<rect x="${x * 8}" y="${y * 18}" width="${cap.width * 8}" height="15" rx="3" fill="${hex(bg)}"/>`)
116 if (cap.label) parts.push(`<text x="${(x + cap.width / 2) * 8}" y="${y * 18 + 11}" font-family="monospace" font-size="10" text-anchor="middle" fill="${hex(fg)}">${cap.label.replace('&', '&').replace('<', '<')}</text>`)
117 x += cap.width + 1
118 }
119 })
120 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${BOARD_COLUMNS * 8} ${BOARD_ROWS * 18}">${parts.join('')}</svg>`
121}
122types/index.d.ts 14 lines1export type ClackSettings = {
2 switch: string
3 // 0 to 1.
4 volume: number
5 isMuted: boolean
6 isHidden: boolean
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'clack': { settings: ClackSettings; tick: number; isHelperUp: boolean }
12 }
13}
14