After a longer reply, offers to re-explain it in plain English, as a diagram, a web page or a video, sent now or edited first (after Karpathy's output-format…

A Claude Code mod that re-explains Claude's latest long reply in a format that is easier to take in: in plain English, as a diagram, as a web page, or as a video.

It grew out of Andrej Karpathy's post of October 1, 2026 about output formats for understanding what language models produce: clear controlled writing (ASD-STE100), diagrams, web pages and explainer videos. As AI does more of the work, more of our job becomes understanding it. This mod puts those formats one click away, right where a reply loses you.
Under Claude's latest long reply (about 100 words or more):
Explain another way [In plain English] [As a diagram] [As a web page] [As a video]
Click one and the same row turns into a choice:
As a diagram: [Send now] [Edit first] Back
The format you pick then stays on for the rest of the session: every later long reply comes in that format, with no extra click. Under the latest reply (short ones too, while a format is on) the row shows what is on, a Stop button, and the other formats if you want to switch:
Explaining as a video [Stop] Switch to [In plain English] [As a diagram] [As a web page]
Edit first turns the format on once you send the draft, as long as it still asks for that format (for example, it still says "video"). The choice is forgotten when the session ends.
The row moves to each new long reply and steps aside while Claude is working. Short answers get no row unless a format is on. Web pages and videos are saved outside your project, so your code folders stay clean.
/explain opens the same choice above the message box, for your last reply./explain plain, /explain diagram, /explain page or /explain video sends right away and keeps that format on./explain off stops it.From GitHub:
claude plugin marketplace add smughead/explain-again
claude plugin install explain-again@explain-again
Then start a new session. The mod loads when a session starts.
For development (live editing): point Claude Code at this folder in ~/.claude/settings.json. Terminal sessions reload the mod whenever a file changes. Desktop app sessions only do so with CLAUDE_CODE_PLUGIN_DIR_WATCH set; without it they keep the version they started with, so test changes in a new session.
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/Documents/projects/explain-again", "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1" } }
Either way, the setting is read when a session starts.
Requires Claude Code 2.1.286 or later. Works in the desktop app's Code tab and in the terminal.
Use the Claude Code build that ships with the desktop app (a standalone claude may be too old for mods):
claude plugin validate ./.claude-plugin/plugin.json
claude plugin validate .
claude plugin test .
The first line checks the mod itself; the second checks the install file (marketplace.json).
Why the mod looks and behaves the way it does, and what the platform can't do yet: docs/decisions.md.
MIT. See LICENSE.
hooks/register.tsx 318 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4// Replies shorter than this (characters) don't get the row: "Done", yes/no, etc. While a format is on,
5// every latest reply gets it, so the format and Stop stay in view (a diagram reply has little text).
6const MIN_ANSWER_LENGTH = 600
7
8// The Markdown element draws at most this many characters; a longer reply keeps the engine's own drawing.
9const MAX_MARKDOWN_LENGTH = 10000
10
11// How much of the reply the /explain chooser quotes so the person can see which reply it means.
12const QUOTE_LENGTH = 80
13
14// The main conversation's latest answer, whether it is finished (the row may sit under it), whether /explain opened
15// the chooser, and which format was clicked (its Send now / Edit first choice is showing).
16const lastAnswer = atom({ plugin: 'explain-again', key: 'lastAnswer' } as const, null)
17const isRowOffered = atom({ plugin: 'explain-again', key: 'isRowOffered' } as const, false)
18const isChooserOpen = atom({ plugin: 'explain-again', key: 'isChooserOpen' } as const, false)
19const picked = atom({ plugin: 'explain-again', key: 'picked' } as const, null)
20
21// The format that stays on for this session (every later long reply comes in it, until Stop), and the
22// format whose Edit first draft is waiting in the message box (sending it turns that format on).
23const keptFormat = atom({ plugin: 'explain-again', key: 'keptFormat' } as const, null)
24const draftFormat = atom({ plugin: 'explain-again', key: 'draftFormat' } as const, null)
25
26// One entry per format in Karpathy's post, in his "but even better" order. Each request reads like
27// something the person would type, so it is easy to edit; anything saved goes outside the project.
28const FORMATS = [
29 {
30 key: 'plain',
31 label: 'In plain English',
32 request:
33 'Explain your last reply again in plain, simple English: short sentences, one idea each, everyday words (ASD-STE100 style, about 80% strict).',
34 kept: 'in plain, simple English: short sentences, one idea each, everyday words (ASD-STE100 style, about 80% strict)',
35 },
36 {
37 key: 'diagram',
38 label: 'As a diagram',
39 request: 'Show your last reply as a diagram I can take in at a glance, right here in the chat.',
40 kept: 'as a diagram they can take in at a glance, right here in the chat, with only the words it needs',
41 },
42 {
43 key: 'page',
44 label: 'As a web page',
45 request:
46 'Turn your last reply into an interactive web page that explains it. Save it outside this project (a temporary folder is fine) and open it for me.',
47 kept: 'as an interactive web page that explains it, saved outside this project (a temporary folder is fine) and opened for them, with a two-line summary in the chat',
48 },
49 {
50 key: 'video',
51 label: 'As a video',
52 request:
53 'Make a short 3Blue1Brown-style explainer video of your last reply. Save it outside this project, prefer free local tools and a free local voice, and tell me your plan and how long it will take before starting anything slow or installing anything.',
54 kept: 'as a short 3Blue1Brown-style explainer video, saved outside this project with free local tools and a free local voice, with a two-line summary in the chat. Before starting anything slow or installing anything, say your plan and how long it will take',
55 },
56] as const
57
58type Format = (typeof FORMATS)[number]
59type Shared = Pick<Elements['desktop'], 'Box' | 'Button' | 'Text'>
60
61const normalize = (text: string) => text.replace(/\s+/g, ' ').trim()
62
63// Messages the person sent themselves: typed in the terminal, in the desktop app (its Code tab hosts
64// Claude Code through the SDK, so its messages arrive as `sdk`), or from their phone or the web.
65const PERSON_ORIGINS = ['composer', 'sdk', 'bridge']
66
67// What Claude reads beside each of the person's messages while a format stays on; they never see it.
68const keptNote = (format: Format) =>
69 `The person chose to keep Explain Again's "${format.label}" format on for this session. If your reply to this message would be long (about 100 words or more), first do what they asked, then give the reply ${format.kept}. Keep short replies short. The format stays on until they stop it.`
70
71// "In plain English" reads as "Explaining in plain English"; the others as "Explaining as a video".
72const statusFor = (format: Format) => `Explaining ${format.label.charAt(0).toLowerCase()}${format.label.slice(1)}`
73
74// `/explain diagram`, `/explain web`, `/explain plainer`...: a format's key or any word of its label.
75function formatFor(args: string): Format | undefined {
76 const word = args.trim().toLowerCase()
77
78 if (word.length < 3) {
79 return undefined
80 }
81
82 return FORMATS.find(format => format.key === word || format.label.toLowerCase().split(' ').includes(word))
83}
84
85function actionsFor($: EngineInterface) {
86 const reset = async () => {
87 await update($, picked, () => null)
88 await update($, isChooserOpen, () => false)
89 }
90
91 return {
92 reset,
93 pick: (format: Format) => update($, picked, () => format.key),
94 back: () => update($, picked, () => null),
95 stop: async () => {
96 await update($, keptFormat, () => null)
97 await update($, draftFormat, () => null)
98 },
99 // Sent as the person's own words: they chose it. The transcript still names the plugin.
100 // The format then stays on for the rest of the session.
101 send: async (format: Format) => {
102 await reset()
103 await update($, keptFormat, () => format.key)
104 await $.prompt.submit({ text: format.request, asUser: true })
105 },
106 // Cues the request up in the message box to edit before sending; sending it keeps the format on.
107 edit: async (format: Format) => {
108 await reset()
109 await update($, draftFormat, () => format.key)
110 await $.prompt.fill({ text: format.request })
111 },
112 }
113}
114
115// The choices, in one row that swaps in place: first the four formats; after a click, that format's
116// Send now / Edit first. Same height, spacing and gray lead in both states, so the swap stays calm.
117// While a format stays on, the row leads with it and Stop, then offers the other formats to switch to.
118// The gray "Switch to" splits the two groups, so Stop does not read as one more format.
119function choiceRow(
120 { Box, Button, Text }: Shared,
121 actions: ReturnType<typeof actionsFor>,
122 pickedKey: string | null,
123 keptKey: string | null,
124 lead?: string,
125) {
126 const chosen = FORMATS.find(format => format.key === pickedKey)
127 const kept = FORMATS.find(format => format.key === keptKey)
128
129 if (chosen !== undefined) {
130 return (
131 <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
132 <Text dimColor>{chosen.label}:</Text>
133 <Button key="send-now" label="Send now" variant="primary" onPress={() => actions.send(chosen)} />
134 <Button key="edit-first" label="Edit first" onPress={() => actions.edit(chosen)} />
135 <Button key="back" label="Back" plain dimColor onPress={actions.back} />
136 </Box>
137 )
138 }
139
140 if (kept !== undefined) {
141 return (
142 <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
143 <Text dimColor>{statusFor(kept)}</Text>
144 <Button key="stop" label="Stop" onPress={actions.stop} />
145 <Box marginLeft={1}>
146 <Text dimColor>Switch to</Text>
147 </Box>
148 {FORMATS.filter(format => format !== kept).map(format => (
149 <Button key={format.key} label={format.label} onPress={() => actions.pick(format)} />
150 ))}
151 </Box>
152 )
153 }
154
155 return (
156 <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
157 {lead !== undefined && <Text dimColor>{lead}</Text>}
158 {FORMATS.map(format => (
159 <Button key={format.key} label={format.label} onPress={() => actions.pick(format)} />
160 ))}
161 </Box>
162 )
163}
164
165export const register: Register = on => {
166 on('session.start', async ($, e, next) => {
167 await $.command.register({
168 name: 'explain',
169 description: 'Explain your last reply another way',
170 argumentHint: '[plain | diagram | page | video | off]',
171 })
172
173 return next(e)
174 })
175
176 // While Claude works on something new, the row steps away from the previous reply.
177 on('turn.start', async ($, e, next) => {
178 await update($, isRowOffered, () => false)
179 await update($, picked, () => null)
180
181 return next(e)
182 })
183
184 // The person's own messages carry the kept format's note for Claude. A sent Edit first draft turns
185 // its format on, unless the person rewrote it into something else (it no longer names the format).
186 on('prompt.submit', async ($, e, next) => {
187 if (!PERSON_ORIGINS.includes(e.origin.kind)) {
188 return next(e)
189 }
190
191 const draftKey = await read($, draftFormat)
192
193 if (draftKey !== null) {
194 await update($, draftFormat, () => null)
195
196 if (e.text.toLowerCase().includes(draftKey)) {
197 await update($, keptFormat, () => draftKey)
198 }
199 }
200
201 const keptKey = await read($, keptFormat)
202 const kept = FORMATS.find(format => format.key === keptKey)
203
204 if (kept === undefined) {
205 return next(e)
206 }
207
208 return next({ ...e, context: [...(e.context ?? []), keptNote(kept)] })
209 })
210
211 on('turn.complete', async ($, e, next) => {
212 const isMainAnswer = e.agentId === undefined && e.reason === 'answer' && e.answer.trim() !== ''
213
214 if (isMainAnswer) {
215 await update($, lastAnswer, () => e.answer)
216 await update($, isRowOffered, () => true)
217 await update($, isChooserOpen, () => false)
218 await update($, picked, () => null)
219 }
220
221 return next(e)
222 })
223
224 // `/explain` opens the chooser above the message box; `/explain diagram` (or plain, page, video) sends at
225 // once and keeps that format on; `/explain off` stops it.
226 on('command.run', { command: 'explain' }, async ($, e) => {
227 if (['off', 'stop'].includes(e.args.trim().toLowerCase())) {
228 const keptKey = await read($, keptFormat)
229 const kept = FORMATS.find(format => format.key === keptKey)
230
231 await actionsFor($).stop()
232
233 return { text: kept === undefined ? 'No format is on.' : `Stopped ${statusFor(kept).toLowerCase()}.` }
234 }
235
236 if ((await read($, lastAnswer)) === null) {
237 return { text: 'Nothing to explain yet. Ask Claude something first.' }
238 }
239
240 const format = formatFor(e.args)
241
242 if (format !== undefined) {
243 // On a timer so the request outlives this command's own run, then starts once the session is idle.
244 await update($, keptFormat, () => format.key)
245 $.clock.after(0, () => void $.prompt.submit({ text: format.request, asUser: true }))
246
247 return { text: `Explaining your last reply ${format.label.toLowerCase()}, and later long replies too. /explain off stops it.` }
248 }
249
250 if (e.args.trim() !== '') {
251 return { text: `Unknown format "${e.args.trim()}". Try plain, diagram, page, video or off.` }
252 }
253
254 await update($, picked, () => null)
255 await update($, isChooserOpen, () => true)
256
257 return { text: 'Pick a format above the message box.' }
258 })
259
260 // The row: under the last piece of the latest long reply only (any latest reply while a format is on).
261 // Every other reply keeps the engine's own drawing, and the row moves on as soon as Claude starts something new.
262 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
263 const answer = await read($, lastAnswer)
264 const isFinished = await read($, isRowOffered)
265 const keptKey = await read($, keptFormat)
266 const { text } = e.props
267 const piece = normalize(text)
268 const isOffered = isFinished && answer !== null && (keptKey !== null || answer.trim().length >= MIN_ANSWER_LENGTH)
269 const isLatestEnding = isOffered && piece !== '' && normalize(answer).endsWith(piece)
270
271 if (!isLatestEnding || text.length > MAX_MARKDOWN_LENGTH) {
272 return next(e)
273 }
274
275 const elements = $.ui.resolve(e)
276 const { Box, Markdown } = elements
277
278 return (
279 <Box flexDirection="column">
280 <Markdown text={text} />
281 <Box marginTop={1}>{choiceRow(elements, actionsFor($), await read($, picked), keptKey, 'Explain another way')}</Box>
282 </Box>
283 )
284 })
285
286 // The chooser /explain opens above the message box, for the latest reply.
287 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
288 const answer = await read($, lastAnswer)
289 const isOpen = await read($, isChooserOpen)
290
291 if (e.props.hasSurvey || e.props.isWorking || !isOpen || answer === null) {
292 return next(e)
293 }
294
295 const elements = $.ui.resolve(e)
296 const { Box, Button, Text } = elements
297 const actions = actionsFor($)
298 const flat = normalize(answer)
299 const opening = flat.length > QUOTE_LENGTH ? `${flat.slice(0, QUOTE_LENGTH)}…` : flat
300
301 // Heading and Dismiss share the top line so Dismiss can never be pushed out of view.
302 return (
303 <Box flexDirection="column">
304 <Box flexDirection="row" justifyContent="space-between" alignItems="center" marginBottom={1}>
305 <Box flexDirection="row" gap={1} flexShrink={1}>
306 <Box flexShrink={0}>
307 <Text bold>Explain your last reply another way</Text>
308 </Box>
309 <Text dimColor wrap="truncate">“{opening}”</Text>
310 </Box>
311 <Button key="dismiss" label="Dismiss" role="dismiss" plain dimColor onPress={actions.reset} />
312 </Box>
313 {choiceRow(elements, actions, await read($, picked), await read($, keptFormat))}
314 </Box>
315 )
316 })
317}
318types/index.d.ts 15 lines1export type Flag = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'explain-again': {
6 lastAnswer: string | null
7 isRowOffered: boolean
8 isChooserOpen: boolean
9 picked: string | null
10 keptFormat: string | null
11 draftFormat: string | null
12 }
13 }
14}
15