Watch the agent work: every step in plain English, with a live time-saved counter

Watch the agent work: every step in plain English, with a live time-saved counter.

Translates every tool call into one plain-English sentence ("Reading the pricing page to find the old numbers") and keeps a running estimate of time saved. /narrate smart uses a model call per step; the default is rule-based and free. /narrate demo plays a scripted session. We built this for training sessions where the audience has never seen an agent work.
In a Claude Code session (2.1.287 or later):
/plugin marketplace add OneWave-AI/claude-code-mods
/plugin install agent-narrator@claude-code-mods
Or load this folder for one session: claude --plugin-dir ./agent-narrator
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| agent-narrator | No | No | No | Only with /narrate smart: the tool name and short fields (path, command), never file contents | Only to your Claude model |
Run claude plugin validate ./agent-narrator to list every event it hooks and every call it makes. See the repository README for the full security notes.
Part of claude-code-mods by OneWave AI. MIT licensed.
hooks/register.tsx 239 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Run, Step } from '../types'
5import { DEMO, duration, elapsed, narrate, testNote } from './narrate'
6
7const PANE = 'agent-narrator'
8const TICK_MS = 120
9const KEEP = 120
10
11const feed = atom({ plugin: 'agent-narrator', key: 'feed' } as const, [] as Step[])
12const run = atom({ plugin: 'agent-narrator', key: 'run' } as const, {
13 isRunning: false,
14 startedAt: null,
15 endedAt: null,
16 steps: 0,
17 savedMinutes: 0,
18} as Run)
19const isSmart = atom({ plugin: 'agent-narrator', key: 'isSmart' } as const, false)
20const isDemo = atom({ plugin: 'agent-narrator', key: 'isDemo' } as const, false)
21
22const NAVY = '#0B1121'
23const BLUE = '#61A5FA'
24const GOLD = '#c2a87e'
25const PAPER = '#E8E2D6'
26const RING = ['◜', '◝', '◞', '◟']
27
28let frame = 0
29let shown = 0
30let demoTimer: { cancel: () => void } | null = null
31
32const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Agent at work' })
33
34const startRun = async ($: EngineInterface) => {
35 const now = await $.clock.now()
36 await update($, run, r => ({ ...r, isRunning: true, startedAt: r.isRunning ? r.startedAt : now, endedAt: null }))
37}
38
39const endRun = async ($: EngineInterface) => {
40 const now = await $.clock.now()
41 await update($, run, r => ({ ...r, isRunning: false, endedAt: now }))
42 $.ui.status(undefined)
43}
44
45/** Turns one model rewrite into a warmer line; the template line stays if the call fails. */
46/** The short fields of a tool input, never file bodies or edit text, capped for the model call. */
47const brief = (input: Record<string, unknown>) => {
48 const keep = ['file_path', 'path', 'pattern', 'command', 'url', 'query', 'description']
49 const picked = Object.fromEntries(
50 keep.filter(k => typeof input[k] === 'string').map(k => [k, String(input[k]).slice(0, 120)]),
51 )
52 return JSON.stringify(picked).slice(0, 400)
53}
54
55const polish = async ($: EngineInterface, id: string, text: string, detail: string) => {
56 const r = await $.model.complete({
57 model: 'haiku',
58 effort: 'low',
59 maxTokens: 60,
60 system:
61 'You narrate an AI agent working, for a non-technical business owner watching. Rewrite the step as one short present-tense sentence, under 12 words, no jargon, no file extensions, no emojis, no quotes.',
62 prompt: `Step: ${text}\nTechnical detail: ${detail.slice(0, 400)}`,
63 })
64 if (!r.isAnswered) return
65 const line = r.text.trim().split('\n')[0]?.replace(/^["']|["']$/g, '')
66 if (!line) return
67 await update($, feed, list => list.map(s => (s.id === id ? { ...s, text: line } : s)))
68}
69
70const begin = async ($: EngineInterface, id: string, tool: string, input: Record<string, unknown>) => {
71 const n = narrate(tool, input)
72 const step: Step = { id, text: n.text, note: null, status: 'running', at: await $.clock.now(), minutes: n.minutes }
73 await update($, feed, list => [...list, step].slice(-KEEP))
74 await update($, run, r => ({ ...r, steps: r.steps + 1 }))
75 $.ui.status(`Agent: ${n.text}`)
76 if (await read($, isSmart)) void polish($, id, n.text, `${tool} ${brief(input)}`).catch(() => undefined)
77 return step
78}
79
80const settle = async ($: EngineInterface, step: Step, isFailed: boolean, stdout: string) => {
81 const tests = testNote(stdout)
82 const note = tests ?? (isFailed ? 'did not work, trying another way' : null)
83 const status: Step['status'] = isFailed ? 'failed' : 'done'
84 await update($, feed, list => list.map(s => (s.id === step.id ? { ...s, status, note } : s)))
85 if (!isFailed || tests) await update($, run, r => ({ ...r, savedMinutes: r.savedMinutes + step.minutes }))
86}
87
88const reset = async ($: EngineInterface) => {
89 demoTimer?.cancel()
90 demoTimer = null
91 shown = 0
92 await update($, feed, () => [])
93 await update($, run, () => ({ isRunning: false, startedAt: null, endedAt: null, steps: 0, savedMinutes: 0 }))
94}
95
96const demo = async ($: EngineInterface) => {
97 await reset($)
98 await update($, isDemo, () => true)
99 await open($)
100 await startRun($)
101 let i = 0
102 let pending: Step | null = null
103 const advance = async () => {
104 if (pending) {
105 const was = DEMO[i - 1]!
106 await settle($, pending, was.isError === true, was.stdout ?? '')
107 pending = null
108 }
109 const next = DEMO[i]
110 if (!next) {
111 await endRun($)
112 await update($, isDemo, () => false)
113 $.ui.toast('Done. Changes tested and saved, ready for your review.', { timeoutMs: 8000 })
114 return
115 }
116 i += 1
117 pending = await begin($, `demo-${i}`, next.tool, next.input)
118 demoTimer = $.clock.after(next.ms, () => void advance().catch(() => undefined))
119 }
120 await advance()
121}
122
123export const register: Register = on => {
124 on('session.start', async ($, e, next) => {
125 await $.command.register({
126 name: 'narrate',
127 description: 'Watch the agent work in plain English: /narrate [smart|demo|reset]',
128 })
129 $.clock.every(TICK_MS, () => {
130 void (async () => {
131 const r = await read($, run)
132 const isCounting = Math.abs(r.savedMinutes - shown) > 0.05
133 if (isCounting) shown += Math.max(0.2, (r.savedMinutes - shown) * 0.12) * Math.sign(r.savedMinutes - shown)
134 if (Math.abs(r.savedMinutes - shown) < 0.2) shown = r.savedMinutes
135 if (!r.isRunning && !isCounting) return
136 frame += 1
137 $.ui.invalidate('ui.render')
138 })().catch(() => undefined)
139 })
140 return next(e)
141 })
142
143 on('command.run', { command: 'narrate' }, async ($, e) => {
144 const arg = e.args.trim().toLowerCase()
145 if (arg === 'smart') {
146 const isOn = !(await read($, isSmart))
147 await update($, isSmart, () => isOn)
148 return { text: isOn ? 'Smart narration on: each step is rewritten by a small model.' : 'Smart narration off.' }
149 }
150 if (arg === 'demo') {
151 await demo($)
152 return { text: 'Playing the demo.' }
153 }
154 if (arg === 'reset') {
155 await reset($)
156 return { text: 'Narration cleared.' }
157 }
158 await $.ui.open({ id: PANE, title: 'Agent at work', focus: true })
159 return { text: 'Narrator open.' }
160 })
161
162 on('prompt.submit', async ($, e, next) => {
163 if (!(await read($, isDemo))) await startRun($)
164 return next(e)
165 })
166
167 on('tool.call', async ($, e, next) => {
168 if (await read($, isDemo)) return next(e)
169 const tool = String(e.tool)
170 const step = await begin($, e.tool_use_id, tool, e as unknown as Record<string, unknown>)
171 const ran = await next(e)
172 const out = ran.result as { stdout?: unknown; stderr?: unknown } | undefined
173 const stdout = typeof out?.stdout === 'string' ? `${out.stdout}\n${String(out.stderr ?? '')}` : ''
174 await settle($, step, ran.deny !== undefined || ran.isError === true, stdout)
175 return ran
176 })
177
178 on('turn.complete', async ($, e, next) => {
179 if (!(await read($, isDemo))) await endRun($)
180 return next(e)
181 })
182
183 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
184 const { Box, Text } = $.ui.resolve(e)
185 const r = await read($, run)
186 const list = await read($, feed)
187 const smart = await read($, isSmart)
188 const now = await $.clock.now()
189 const room = Math.max(3, (e.viewport?.rows ?? 30) - 12)
190 const time = r.startedAt ? elapsed((r.isRunning ? now : (r.endedAt ?? now)) - r.startedAt) : '0:00'
191
192 const row = (s: Step) => {
193 const isLive = s.status === 'running'
194 const mark = isLive ? RING[frame % RING.length]! : s.status === 'failed' ? '×' : '✓'
195 const color = isLive ? BLUE : s.status === 'failed' ? GOLD : PAPER
196 return (
197 <Box key={s.id}>
198 <Box width={3} flexShrink={0}>
199 <Text color={color} bold={isLive}>{mark}</Text>
200 </Box>
201 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
202 <Text bold={isLive} dimColor={!isLive && s.status === 'done'} wrap="wrap">
203 {s.text}
204 {s.note && <Text color={s.status === 'failed' ? GOLD : BLUE}>{` · ${s.note}`}</Text>}
205 </Text>
206 </Box>
207 </Box>
208 )
209 }
210
211 return (
212 <Box flexDirection="column" paddingX={1}>
213 <Box justifyContent="space-between">
214 <Text>
215 {r.isRunning ? (
216 <Text color={BLUE} bold>{`${RING[frame % RING.length]} WORKING`}</Text>
217 ) : (
218 <Text backgroundColor={GOLD} color={NAVY} bold>{r.steps ? ' DONE ' : ' READY '}</Text>
219 )}
220 <Text dimColor>{` step ${r.steps} ${time}`}</Text>
221 </Text>
222 {smart && <Text dimColor>smart</Text>}
223 </Box>
224 <Box marginTop={1} marginBottom={1} flexDirection="column">
225 <Text dimColor>ESTIMATED HUMAN TIME SAVED</Text>
226 <Text bold color={GOLD}>{duration(shown)}</Text>
227 </Box>
228 {list.length === 0 && <Text dimColor>Waiting for the agent to start. Ask it to do something, or /narrate demo.</Text>}
229 {list.slice(-room).map(row)}
230 <Box marginTop={1}>
231 <Text dimColor>
232 <Text color={BLUE}>Claude</Text> · working for you
233 </Text>
234 </Box>
235 </Box>
236 )
237 })
238}
239hooks/narrate.ts 187 lines1/** Pure narration: a tool call in, one plain-English line for a non-technical viewer out. */
2
3export type Narration = { text: string; minutes: number }
4
5const KIND: Record<string, string> = {
6 tsx: 'screen',
7 jsx: 'screen',
8 vue: 'screen',
9 svelte: 'screen',
10 html: 'page',
11 css: 'styles',
12 scss: 'styles',
13 ts: 'code',
14 js: 'code',
15 mjs: 'code',
16 py: 'code',
17 go: 'code',
18 rb: 'code',
19 rs: 'code',
20 swift: 'code',
21 sql: 'database script',
22 json: 'settings',
23 yaml: 'settings',
24 yml: 'settings',
25 toml: 'settings',
26 env: 'settings',
27 md: 'notes',
28 txt: 'notes',
29 csv: 'data',
30 png: 'image',
31 jpg: 'image',
32 svg: 'graphic',
33}
34
35const GENERIC = new Set(['index', 'main', 'page', 'route', 'layout', 'mod', 'app', 'default'])
36
37/** "src/components/PricingCard.tsx" -> "pricing card" */
38export const wordsOf = (name: string) =>
39 name
40 .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
41 .replace(/[-_.]+/g, ' ')
42 .trim()
43 .toLowerCase()
44
45/** "app/pricing/page.tsx" -> "the pricing page"; "auth.test.ts" -> "the auth tests". */
46export const fileLabel = (path: string) => {
47 const parts = path.split(/[\\/]/).filter(Boolean)
48 const file = parts[parts.length - 1] ?? path
49 const dot = file.lastIndexOf('.')
50 const ext = dot > 0 ? file.slice(dot + 1).toLowerCase() : ''
51 let stem = dot > 0 ? file.slice(0, dot) : file
52 const isTest = /\.(test|spec)$/i.test(stem) || /(^|[\\/])(__tests__|tests?)[\\/]/i.test(path)
53 stem = stem.replace(/\.(test|spec)$/i, '')
54 const isGeneric = GENERIC.has(stem.toLowerCase())
55 const parent = parts[parts.length - 2]
56 const base = isGeneric && parent && !parent.startsWith('(') && !parent.startsWith('[') ? parent : stem
57 const name = wordsOf(base) || 'project'
58 if (isTest) return `the ${name} tests`
59 if (file.startsWith('.env')) return 'the private settings'
60 const kind = KIND[ext] ?? 'file'
61 if (isGeneric && ext === 'tsx' && parent) return `the ${name} page`
62 return `the ${name} ${kind}`
63}
64
65const quote = (text: string, max = 40) => {
66 const clean = text.replace(/\s+/g, ' ').trim()
67 return `"${clean.length > max ? `${clean.slice(0, max - 1)}…` : clean}"`
68}
69
70const hostOf = (url: string) => {
71 const m = /^[a-z]+:\/\/([^/?#]+)/i.exec(url)
72 return m ? m[1]!.replace(/^www\./, '') : 'a web page'
73}
74
75/** A shell command, explained. */
76export const narrateCommand = (command: string): Narration => {
77 const c = command.trim()
78 const rules: [RegExp, Narration][] = [
79 [/\b(test|jest|vitest|pytest|mocha|go test|cargo test|bun test)\b/i, { text: 'Running the test suite', minutes: 4 }],
80 [/\bgit\s+commit\b/, { text: 'Saving a checkpoint of the work', minutes: 2 }],
81 [/\bgit\s+push\b/, { text: 'Publishing the changes', minutes: 2 }],
82 [/\bgit\s+(status|diff|log|show)\b/, { text: 'Reviewing what has changed so far', minutes: 2 }],
83 [/\bgit\s+(checkout|switch|branch)\b/, { text: 'Setting up a separate workspace for this change', minutes: 1 }],
84 [/\b(npm|pnpm|yarn|bun)\s+(i|install|add)\b|\bpip\s+install\b|\bbrew\s+install\b/, { text: 'Installing the building blocks it needs', minutes: 3 }],
85 [/\b(run\s+)?build\b|\btsc\b/, { text: 'Building the app to make sure it all fits together', minutes: 4 }],
86 [/\b(run\s+)?(dev|start|serve)\b/, { text: 'Starting the app to try it out', minutes: 2 }],
87 [/\b(lint|eslint|prettier|ruff|black)\b/, { text: 'Tidying up and checking code style', minutes: 2 }],
88 [/\b(vercel|deploy|fly deploy|netlify)\b/, { text: 'Deploying the update live', minutes: 5 }],
89 [/\b(curl|wget|http)\b/, { text: 'Checking a live web address', minutes: 2 }],
90 [/\b(psql|supabase|prisma|migrate)\b/, { text: 'Working with the database', minutes: 5 }],
91 [/^(ls|cat|head|tail|find|tree|wc|pwd|grep|rg|sed -n)\b/, { text: 'Looking around the project', minutes: 1 }],
92 [/^(mkdir|cp|mv|touch)\b/, { text: 'Organizing project files', minutes: 1 }],
93 ]
94 for (const [re, n] of rules) if (re.test(c)) return n
95 return { text: 'Running a quick command', minutes: 1 }
96}
97
98const field = (input: Record<string, unknown>, key: string) => {
99 const v = input[key]
100 return typeof v === 'string' ? v : ''
101}
102
103/** One tool call, explained, with a rough estimate of the minutes a person would spend on it. */
104export const narrate = (tool: string, input: Record<string, unknown>): Narration => {
105 const path = field(input, 'file_path') || field(input, 'notebook_path') || field(input, 'path')
106 switch (tool) {
107 case 'Read':
108 return { text: `Reading ${fileLabel(path)}`, minutes: 2 }
109 case 'Edit':
110 case 'MultiEdit':
111 case 'NotebookEdit':
112 return { text: `Saving changes to ${fileLabel(path)}`, minutes: 6 }
113 case 'Write':
114 return { text: `Creating ${fileLabel(path)}`, minutes: 10 }
115 case 'Bash':
116 return narrateCommand(field(input, 'command'))
117 case 'Grep':
118 return { text: `Searching the project for ${quote(field(input, 'pattern'))}`, minutes: 2 }
119 case 'Glob':
120 return { text: 'Finding the files involved', minutes: 1 }
121 case 'WebFetch':
122 return { text: `Reading ${hostOf(field(input, 'url'))}`, minutes: 3 }
123 case 'WebSearch':
124 return { text: `Researching ${quote(field(input, 'query'))} on the web`, minutes: 5 }
125 case 'Agent':
126 case 'Task': {
127 const what = field(input, 'description')
128 return { text: what ? `Bringing in a helper to ${what.charAt(0).toLowerCase()}${what.slice(1)}` : 'Bringing in a helper', minutes: 15 }
129 }
130 case 'TodoWrite':
131 return { text: 'Updating the plan', minutes: 1 }
132 case 'AskUserQuestion':
133 return { text: 'Checking a decision with you', minutes: 0 }
134 }
135 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
136 if (mcp) {
137 const server = wordsOf(mcp[1]!.replace(/^claude_ai_/, '').replace(/^plugin_[^_]+_/, ''))
138 .split(' ')
139 .map(w => (w ? w[0]!.toUpperCase() + w.slice(1) : w))
140 .join(' ')
141 const action = wordsOf(mcp[2]!.replace(/^[a-z]+_(?=[a-z]+_)/, ''))
142 return { text: `Using ${server} to ${action}`, minutes: 3 }
143 }
144 return { text: `Using ${wordsOf(tool)}`, minutes: 1 }
145}
146
147/** Counts from a test run's output, as a short note: "42 passed" / "40 passed, 2 failed". */
148export const testNote = (output: string) => {
149 const top = (re: RegExp) => {
150 let n: number | null = null
151 for (const m of output.matchAll(re)) n = Math.max(n ?? 0, Number(m[1]))
152 return n
153 }
154 const passed = top(/(\d+)\s+(?:tests?\s+)?pass(?:ed|ing)?\b/gi)
155 const failed = top(/(\d+)\s+(?:tests?\s+)?fail(?:ed|ing|ures?)?\b/gi)
156 if (passed === null && failed === null) return null
157 return failed ? `${passed ?? 0} passed, ${failed} failed` : `${passed} passed`
158}
159
160/** "3h 05m" / "42m" */
161export const duration = (minutes: number) => {
162 const m = Math.max(0, Math.round(minutes))
163 return m >= 60 ? `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m` : `${m}m`
164}
165
166export const elapsed = (ms: number) => {
167 const s = Math.max(0, Math.floor(ms / 1000))
168 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
169}
170
171/** The scripted demo: tool, input, a result's stdout, the pause before it. */
172export const DEMO: readonly { tool: string; input: Record<string, unknown>; stdout?: string; isError?: boolean; ms: number }[] = [
173 { tool: 'Glob', input: { pattern: 'app/**/*.tsx' }, ms: 900 },
174 { tool: 'Read', input: { file_path: 'app/pricing/page.tsx' }, ms: 1200 },
175 { tool: 'Read', input: { file_path: 'components/CheckoutForm.tsx' }, ms: 1100 },
176 { tool: 'Grep', input: { pattern: 'applyDiscount' }, ms: 1000 },
177 { tool: 'mcp__claude_ai_Stripe__list_prices', input: {}, ms: 1400 },
178 { tool: 'Edit', input: { file_path: 'components/CheckoutForm.tsx' }, ms: 1600 },
179 { tool: 'Edit', input: { file_path: 'lib/pricing-rules.ts' }, ms: 1400 },
180 { tool: 'Write', input: { file_path: 'tests/checkout.test.ts' }, ms: 1500 },
181 { tool: 'Bash', input: { command: 'npm test' }, stdout: 'Tests: 2 failed, 40 passed, 42 total', isError: true, ms: 2200 },
182 { tool: 'Edit', input: { file_path: 'lib/pricing-rules.ts' }, ms: 1300 },
183 { tool: 'Bash', input: { command: 'npm test' }, stdout: 'Tests: 42 passed, 42 total', ms: 2000 },
184 { tool: 'Bash', input: { command: 'npm run build' }, ms: 1800 },
185 { tool: 'Bash', input: { command: 'git commit -m "Checkout: apply volume discounts"' }, ms: 900 },
186]
187types/index.d.ts 23 lines1export type Step = {
2 id: string
3 text: string
4 note: string | null
5 status: 'running' | 'done' | 'failed'
6 at: number
7 minutes: number
8}
9
10export type Run = {
11 isRunning: boolean
12 startedAt: number | null
13 endedAt: number | null
14 steps: number
15 savedMinutes: number
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'agent-narrator': { feed: Step[]; run: Run; isSmart: boolean; isDemo: boolean }
21 }
22}
23