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

A panel that shows, live, the conversation between helper agents that write to a shared file.
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.
A useful way to review work is to have two helper agents talk: a critic that finds problems and a builder that fixes them. They can leave messages for each other in a shared file using the small script scripts/say.mjs that comes with the skill. This mod shows that conversation as it grows, with a color per author and a filter, so you can follow the review without opening the file.
review.jsonl in a folder of your choice).scripts/say.mjs to that file. Each post looks like node say.mjs --file review.jsonl critic builder finding "The login has no rate limit"./dialogue and press Enter to watch. It updates every 2 seconds.Esc to go back to the prompt.| Key | What it does |
|---|---|
Tab | Reach the Show selector |
Arrow keys and Enter | Pick an author in Show |
Esc | Leave the panel |
| Type | What it does |
|---|---|
/dialogue | Open the conversation |
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 |
|---|---|---|
file (Dialogue file) | Path of the conversation file the agents write to. | (empty) |
language (Language) | Language of the text the mod shows: en (English) or pt-BR (Brazilian Portuguese). | en |
/agents) to see who is working while you read what they say.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/agent-dialogue
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 5 automated tests of this mod:
node scripts/check-mod.mjs examples/agent-dialogue
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 104 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { strings } from './i18n'
5import type { Strings } from './i18n'
6import { hhmm, parseDialogue } from './parse'
7
8// Shows a conversation that agents append to a JSONL file (scripts/say.mjs writes one).
9// Reads only: the file path comes from userConfig, never from the file's own content, and the
10// messages are drawn as text, never acted on.
11const PANE = 'agent-dialogue'
12const lines = atom({ plugin: 'agent-dialogue', key: 'lines' } as const, [])
13const filter = atom({ plugin: 'agent-dialogue', key: 'filter' } as const, 'all')
14const problem = atom({ plugin: 'agent-dialogue', key: 'problem' } as const, '')
15
16const COLORS = ['claude', 'suggestion', 'success', 'warning']
17
18async function poll($: EngineInterface, file: string, t: Strings) {
19 try {
20 if (!(await $.fs.exists(file))) {
21 await update($, problem, () => t.waiting)
22 return
23 }
24 const parsed = parseDialogue(await $.fs.read(file))
25 await update($, lines, () => parsed.slice(-200))
26 await update($, problem, () => '')
27 } catch {
28 await update($, problem, () => t.cannotRead)
29 }
30}
31
32export const register: Register = (on, options) => {
33 const file = String(options.file ?? '').trim()
34 const t = strings(options.language)
35
36 on('session.start', async ($, e, next) => {
37 if (file === '') {
38 // No default path: the pane says how to set one.
39 await update($, problem, () => t.noFile)
40 } else {
41 await poll($, file, t)
42 $.clock.every(2000, () => poll($, file, t))
43 }
44 try {
45 await $.command.register({ name: 'dialogue', description: t.commandDescription, immediate: true })
46 } catch {
47 // name taken
48 }
49 return next(e)
50 })
51
52 on('command.run', { command: 'dialogue' }, async $ => {
53 if (file === '') return { text: t.noFile }
54 await poll($, file, t)
55 const opened = await $.ui.open({ id: PANE, title: t.paneTitle, focus: true })
56 if (opened.isPlaced) return {}
57 // Nothing draws here (VS Code chat, claude -p): the last messages as text.
58 const all = await read($, lines)
59 return { text: all.length === 0 ? t.noMessages : all.slice(-15).map(l => `${l.from} → ${l.to || t.everyone}: ${l.text}`).join('\n') }
60 })
61
62 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
63 const ui = $.ui.resolve(e)
64 const { Box, Text } = ui
65 const all = await read($, lines)
66 const current = await read($, filter)
67 const note = file === '' ? t.noFile : await read($, problem)
68 const authors = [...new Set(all.map(l => l.from))]
69 const shown = all.filter(l => current === 'all' || l.from === current).slice(-Math.max(6, e.props.scroll.bodyRows - 6))
70 const color = (who: string) => COLORS[Math.max(0, authors.indexOf(who)) % COLORS.length] ?? 'text'
71
72 return (
73 <Box flexDirection="column" paddingX={1} gap={1}>
74 <Box justifyContent="space-between">
75 <Text bold color="claude">{t.heading}</Text>
76 <Text dimColor>{t.messages(all.length)}</Text>
77 </Box>
78 {e.surface !== 'mobile' && 'Select' in ui && authors.length > 0 && (
79 <ui.Select
80 key="author"
81 label={t.show}
82 value={current}
83 options={[{ value: 'all', label: t.everyone }, ...authors.map(a => ({ value: a, label: a }))]}
84 onSelect={value => update($, filter, () => value)}
85 />
86 )}
87 <Box flexDirection="column" gap={1}>
88 {shown.length === 0 && <Text dimColor>{note || t.noMessages}</Text>}
89 {shown.map((l, i) => (
90 <Box key={`msg-${i}-${l.ts}`} flexDirection="column">
91 <Box gap={1}>
92 <Text bold color={color(l.from)}>{l.from}</Text>
93 <Text dimColor>{`→ ${l.to || t.everyone} · ${t.kind[l.kind] ?? l.kind}${hhmm(l.ts) ? ` · ${hhmm(l.ts)}` : ''}`}</Text>
94 </Box>
95 <Text wrap="wrap">{l.text}</Text>
96 </Box>
97 ))}
98 </Box>
99 <Text dimColor>{t.footer}</Text>
100 </Box>
101 )
102 })
103}
104hooks/i18n.ts 58 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 kind: {
8 finding: 'finding',
9 fix: 'fix',
10 question: 'question',
11 answer: 'answer',
12 verified: 'verified',
13 reopen: 'reopened',
14 note: 'note',
15 } as Record<string, string>,
16 waiting: 'waiting for the agents to start writing…',
17 cannotRead: 'could not read the dialogue file',
18 noFile: 'Set the dialogue file in /config → agent-dialogue.',
19 commandDescription: 'Show the conversation between the agents',
20 paneTitle: 'Agent dialogue',
21 heading: '◆ Agent dialogue',
22 messages: (n: number) => `${n} messages`,
23 show: 'Show',
24 everyone: 'everyone',
25 noMessages: 'No messages yet.',
26 footer: 'refreshes every 2 s · /dialogue · /agents for status',
27}
28
29const ptBR: typeof en = {
30 kind: {
31 finding: 'achado',
32 fix: 'correção',
33 question: 'pergunta',
34 answer: 'resposta',
35 verified: 'conferido',
36 reopen: 'reaberto',
37 note: 'nota',
38 },
39 waiting: 'aguardando os agentes começarem a escrever…',
40 cannotRead: 'não consegui ler o arquivo do diálogo',
41 noFile: 'Defina o arquivo do diálogo em /config → agent-dialogue.',
42 commandDescription: 'Mostra a conversa entre os agentes',
43 paneTitle: 'Diálogo dos agentes',
44 heading: '◆ Diálogo dos agentes',
45 messages: (n: number) => `${n} mensagens`,
46 show: 'Mostrar',
47 everyone: 'todos',
48 noMessages: 'Nenhuma mensagem ainda.',
49 footer: 'atualiza a cada 2 s · /dialogue · /agents para o status',
50}
51
52export type Strings = typeof en
53const TABLE: Record<Language, Strings> = { en, 'pt-BR': ptBR }
54
55export function strings(language: unknown): Strings {
56 return language === 'pt-BR' ? TABLE['pt-BR'] : TABLE.en
57}
58hooks/parse.ts 30 lines1import type { Line } from '../types'
2
3// Tolerant JSONL parser: a half-written last line (an agent mid-append) is skipped, not fatal.
4export function parseDialogue(text: string): Line[] {
5 const out: Line[] = []
6 for (const raw of text.split('\n')) {
7 const line = raw.trim()
8 if (line === '') continue
9 try {
10 const v = JSON.parse(line) as Record<string, unknown>
11 if (typeof v.from !== 'string' || typeof v.text !== 'string') continue
12 out.push({
13 from: v.from.slice(0, 20),
14 to: typeof v.to === 'string' ? v.to.slice(0, 20) : '',
15 kind: typeof v.kind === 'string' ? v.kind.slice(0, 20) : 'note',
16 text: v.text.slice(0, 2000),
17 ts: typeof v.ts === 'string' ? v.ts : '',
18 })
19 } catch {
20 // skip
21 }
22 }
23 return out
24}
25
26export function hhmm(ts: string): string {
27 const d = new Date(ts)
28 return Number.isNaN(d.getTime()) ? '' : `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
29}
30types/index.d.ts 8 lines1export type Line = { from: string; to: string; kind: string; text: string; ts: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'agent-dialogue': { lines: Line[]; filter: string; problem: string }
6 }
7}
8