Claude Code skill that builds, debugs, audits and packages Claude Code mods from a plain-language description

A panel of buttons you create, each one sending a request you use often to Claude with a single key.
Claude Code is Anthropic's assistant for working with code, in the terminal or in the Claude desktop app. A mod is a small add-on for it: it can add a panel, a bar above the box where you type, a new /command, or a rule that checks what Claude is about to do. This folder is one complete mod you can install and use as it is, or copy as a starting point.
Some requests you type again and again: "run the tests and fix what fails", "review the last change", "write the commit message". This mod lets you save each one as a button with a short name. Pressing the button (or its number) sends the full request to Claude, as if you had typed it. Your buttons are kept for the next sessions.
× to delete it./buttons and press Enter. The panel opens./buttons 2 sends button 2 directly.| Key | What it does |
|---|---|
1 to 9 | Send that button's request |
× | Delete that button |
Tab | Move between buttons and fields |
Enter | Go to the next field, or create the button |
| Type | What it does |
|---|---|
/buttons | Open the panel (or list the buttons where no panel can be drawn) |
/buttons 2 | Send button 2 right away |
Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.
| Setting | What it means | Default |
|---|---|---|
language (Language) | Language of the text the mod shows: en (English) or pt-BR (Brazilian Portuguese). | en |
You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.
_claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.claude --plugin-dir path/to/prompt-buttons
To keep it installed for good, ask the claude-mod-forge skill to package it ("package this mod").
From the skill folder (skills/claude-mod-forge), this runs the manifest check, a strict type check and the 7 automated tests of this mod:
node scripts/check-mod.mjs examples/prompt-buttons
The tests cover the mod's logic and what it draws in the terminal and the desktop app. How it looks in your own window is worth a quick look in a real session.
For developers: the examples table lists the hooks and API calls this mod demonstrates.
hooks/register.tsx 161 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PromptButton } from '../types'
5import { KEY_PREFIX, byId, parseIndex, validate } from './buttons'
6import { strings } from './i18n'
7import type { Strings } from './i18n'
8
9const PANE = 'prompt-buttons'
10const buttons = atom({ plugin: 'prompt-buttons', key: 'buttons' } as const, [])
11const isBusy = atom({ plugin: 'prompt-buttons', key: 'isBusy' } as const, false)
12const draftLabel = atom({ plugin: 'prompt-buttons', key: 'draftLabel' } as const, '')
13const error = atom({ plugin: 'prompt-buttons', key: 'error' } as const, '')
14
15// Buttons live in $.store, one key each, so they survive sessions and /clear.
16async function load($: EngineInterface) {
17 const keys = (await $.store.keys()).filter(k => k.startsWith(KEY_PREFIX))
18 const list: PromptButton[] = []
19 for (const key of keys) {
20 const value = await $.store.get(key)
21 if (value !== null && typeof value === 'object' && 'label' in value && 'text' in value) list.push(value as PromptButton)
22 }
23 await update($, buttons, () => list.sort(byId))
24}
25
26// Sends the saved instruction. Claude reads it framed as sent by this mod (no asUser).
27// $.prompt.submit waits until Claude is idle, so it is never awaited inside a handler, and it is
28// refused when called from a command.run hook (it would wait on the turn that hook holds):
29// the command path schedules it with $.clock.after(0, …) instead.
30async function send($: EngineInterface, button: PromptButton, t: Strings): Promise<string> {
31 const busy = await read($, isBusy)
32 $.prompt.submit({ text: button.text }).catch(() => $.ui.toast(t.couldNotSend(button.label)))
33 const note = busy ? t.queued(button.label) : t.sent(button.label)
34 $.ui.toast(note)
35 return note
36}
37
38export const register: Register = (on, options) => {
39 const t = strings(options.language)
40
41 on('session.start', async ($, e, next) => {
42 await load($)
43 try {
44 await $.command.register({ name: 'buttons', description: t.commandDescription, argumentHint: '[n]', immediate: true })
45 } catch {
46 // name taken: the pane is still reachable when opened from session.start below
47 }
48 void $.ui.open({ id: PANE, title: t.paneTitle })
49 return next(e)
50 })
51
52 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
53 await load($)
54 return next(e)
55 })
56
57 on('turn.start', async ($, e, next) => {
58 await update($, isBusy, () => true)
59 return next(e)
60 })
61
62 on('turn.complete', async ($, e, next) => {
63 if (e.agentId === undefined) await update($, isBusy, () => false)
64 return next(e)
65 })
66
67 on('command.run', { command: 'buttons' }, async ($, e) => {
68 const list = await read($, buttons)
69 const index = parseIndex(e.args, list.length, t)
70 if (index === undefined) {
71 const opened = await $.ui.open({ id: PANE, title: t.paneTitle, focus: true })
72 if (opened.isPlaced) return {}
73 // Nothing draws here (VS Code chat, claude -p): the same buttons as text.
74 return { text: list.length === 0 ? t.noButtons : list.map((b, i) => `${i + 1}. ${b.label}`).join('\n') }
75 }
76 if (typeof index === 'object') return { text: index.error }
77 const button = list[index]
78 if (button === undefined) return { text: t.noSuchButton }
79 const busy = await read($, isBusy)
80 $.clock.after(0, () => send($, button, t)) // outside this hook, where submit is allowed
81 return { text: busy ? t.queued(button.label) : t.sending(button.label) }
82 })
83
84 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
85 const ui = $.ui.resolve(e)
86 const { Box, Text, Button } = ui
87 const list = await read($, buttons)
88 const busy = await read($, isBusy)
89 const label = await read($, draftLabel)
90 const problem = await read($, error)
91
92 return (
93 <Box flexDirection="column" paddingX={1} gap={1}>
94 <Box justifyContent="space-between">
95 <Text bold color="claude">{t.heading}</Text>
96 <Text color={busy ? 'warning' : 'success'}>{busy ? t.working : t.ready}</Text>
97 </Box>
98
99 <Box flexDirection="column">
100 {list.length === 0 && <Text dimColor>{t.emptyPane}</Text>}
101 {list.map((b, i) => (
102 <Box key={`row-${b.id}`} gap={1}>
103 <Button key={`run-${b.id}`} label={b.label} hotkey={String(i + 1)} plain onPress={() => send($, b, t)} />
104 <Box flexGrow={1}><Text dimColor wrap="truncate-end">{b.text}</Text></Box>
105 <Button
106 key={`del-${b.id}`}
107 label="×"
108 plain
109 dimColor
110 onPress={async () => {
111 await $.store.delete(`${KEY_PREFIX}${b.id}`)
112 await update($, buttons, l => l.filter(x => x.id !== b.id))
113 }}
114 />
115 </Box>
116 ))}
117 </Box>
118
119 {e.surface !== 'mobile' && 'Input' in ui ? (
120 <Box flexDirection="column">
121 <ui.Input
122 key="new-label"
123 label={t.labelField}
124 placeholder={t.labelPlaceholder}
125 value={label}
126 submitLabel={t.labelSubmit}
127 onInput={value => update($, draftLabel, () => value)}
128 onSubmit={value => update($, draftLabel, () => value)}
129 />
130 <ui.Input
131 key="new-text"
132 label={t.textField}
133 placeholder={t.textPlaceholder}
134 value=""
135 submitLabel={t.textSubmit}
136 onSubmit={async text => {
137 const checked = validate(await read($, draftLabel), text, (await read($, buttons)).length, t)
138 if ('error' in checked) {
139 await update($, error, () => checked.error)
140 return
141 }
142 const id = String(await $.clock.now())
143 const button: PromptButton = { id, ...checked }
144 await $.store.set(`${KEY_PREFIX}${id}`, button)
145 await update($, buttons, l => [...l, button].sort(byId))
146 await update($, draftLabel, () => '')
147 await update($, error, () => '')
148 }}
149 />
150 {problem !== '' && <Text color="error">✗ {problem}</Text>}
151 </Box>
152 ) : (
153 <Text dimColor>{t.addElsewhere}</Text>
154 )}
155
156 <Text dimColor>{t.footer}</Text>
157 </Box>
158 )
159 })
160}
161hooks/buttons.ts 35 lines1// Pure rules for prompt-buttons. No $ here. Messages come from the i18n table (English by default).
2
3import { strings } from './i18n'
4import type { Strings } from './i18n'
5
6const EN = strings('en')
7
8export const MAX_BUTTONS = 9 // one digit hotkey each
9export const MAX_LABEL = 40
10export const MAX_TEXT = 2000
11export const KEY_PREFIX = 'btn:' // one $.store key per button: sessions never overwrite each other's list
12
13export function validate(label: string, text: string, count: number, s: Strings = EN): { label: string; text: string } | { error: string } {
14 const l = label.trim().replace(/\s+/g, ' ')
15 const t = text.trim()
16 if (count >= MAX_BUTTONS) return { error: s.tooMany(MAX_BUTTONS) }
17 if (l === '') return { error: s.needLabel }
18 if (t === '') return { error: s.needText }
19 return { label: l.slice(0, MAX_LABEL), text: t.slice(0, MAX_TEXT) }
20}
21
22// "/buttons 2" → index 1; anything else is an error message.
23export function parseIndex(args: string, count: number, s: Strings = EN): number | { error: string } | undefined {
24 const raw = args.trim()
25 if (raw === '') return undefined
26 if (!/^\d$/.test(raw)) return { error: s.usage(MAX_BUTTONS) }
27 const n = Number(raw)
28 if (n < 1 || n > count) return { error: count === 0 ? s.noButtonsAddInPane : s.pick(count) }
29 return n - 1
30}
31
32export function byId(a: { id: string }, b: { id: string }): number {
33 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
34}
35hooks/i18n.ts 72 lines1// The text this mod shows, per language. Pure: no $ calls, so tests can cover it directly.
2// Anything other than a known language falls back to English.
3
4export type Language = 'en' | 'pt-BR'
5
6const en = {
7 // buttons.ts (validation and /buttons n)
8 tooMany: (max: number) => `At most ${max} buttons (one per digit hotkey). Delete one first.`,
9 needLabel: 'Give the button a label first.',
10 needText: 'Type the instruction the button should send.',
11 usage: (max: number) => `Usage: /buttons [1-${max}]`,
12 noButtonsAddInPane: 'No buttons yet. Add one in the pane.',
13 pick: (count: number) => `Pick 1 to ${count}.`,
14 // register.tsx
15 couldNotSend: (label: string) => `Could not send: ${label}`,
16 queued: (label: string) => `Queued: ${label} (runs when Claude is done)`,
17 sent: (label: string) => `Sent: ${label}`,
18 sending: (label: string) => `Sending: ${label}`,
19 commandDescription: 'Open your prompt buttons, or run one: /buttons 2',
20 paneTitle: 'Prompt buttons',
21 noButtons: 'No buttons yet.',
22 noSuchButton: 'No such button.',
23 heading: '◆ Prompt buttons',
24 working: '◌ Claude is working · new presses queue',
25 ready: '● ready',
26 emptyPane: 'No buttons yet. Add one below: a label and the instruction it sends.',
27 labelField: 'Label',
28 labelPlaceholder: 'e.g. Run tests and fix',
29 labelSubmit: 'next',
30 textField: 'Instruction',
31 textPlaceholder: 'What Claude should do, then Enter to add',
32 textSubmit: 'add',
33 addElsewhere: 'Add buttons from the terminal or the desktop app.',
34 footer: '1-9 send · × delete · Tab moves · /buttons n from anywhere',
35}
36
37const ptBR: typeof en = {
38 tooMany: (max: number) => `No máximo ${max} botões (um por tecla numérica). Apague um antes.`,
39 needLabel: 'Dê um nome ao botão primeiro.',
40 needText: 'Digite a instrução que o botão deve enviar.',
41 usage: (max: number) => `Uso: /buttons [1-${max}]`,
42 noButtonsAddInPane: 'Nenhum botão ainda. Crie um no painel.',
43 pick: (count: number) => `Escolha de 1 a ${count}.`,
44 couldNotSend: (label: string) => `Não foi possível enviar: ${label}`,
45 queued: (label: string) => `Na fila: ${label} (roda quando o Claude terminar)`,
46 sent: (label: string) => `Enviado: ${label}`,
47 sending: (label: string) => `Enviando: ${label}`,
48 commandDescription: 'Abre seus botões de prompt, ou roda um: /buttons 2',
49 paneTitle: 'Botões de prompt',
50 noButtons: 'Nenhum botão ainda.',
51 noSuchButton: 'Esse botão não existe.',
52 heading: '◆ Botões de prompt',
53 working: '◌ Claude trabalhando · novos cliques vão para a fila',
54 ready: '● pronto',
55 emptyPane: 'Nenhum botão ainda. Crie um abaixo: um nome e a instrução que ele envia.',
56 labelField: 'Nome',
57 labelPlaceholder: 'ex.: Rodar testes e corrigir',
58 labelSubmit: 'próximo',
59 textField: 'Instrução',
60 textPlaceholder: 'O que o Claude deve fazer; Enter para criar',
61 textSubmit: 'criar',
62 addElsewhere: 'Crie botões pelo terminal ou pelo app desktop.',
63 footer: '1-9 envia · × apaga · Tab navega · /buttons n de qualquer lugar',
64}
65
66export type Strings = typeof en
67const TABLE: Record<Language, Strings> = { en, 'pt-BR': ptBR }
68
69export function strings(language: unknown): Strings {
70 return language === 'pt-BR' ? TABLE['pt-BR'] : TABLE.en
71}
72types/index.d.ts 8 lines1export type PromptButton = { id: string; label: string; text: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'prompt-buttons': { buttons: PromptButton[]; isBusy: boolean; draftLabel: string; error: string }
6 }
7}
8