/mod-tour: a live, clickable tour of everything a Claude Code mod can do, in 22 slides

See what a Claude Code mod can do. Live, inside Claude Code, in 2 minutes.

Docs tell you a mod can draw a pane, guard a tool call or rewrite your prompt. /mod-tour shows you. It opens a pane with 22 slides. Each slide runs a real example in your session, next to the few lines of API that make it work.
At the Claude Code prompt:
/plugin marketplace add kuzmany/mod-tour
/plugin install mod-tour@mod-tour
Then type /mod-tour.
| Key | Does |
|---|---|
p / n | previous / next slide; free examples run on arrival |
t | Try it: run (or re-run) the slide's example |
s | play / pause autoplay (one slide every 3 s) |
/mod-tour auto | open with autoplay on |
Slides that cost tokens or interrupt you (model calls, dialogs, clipboard, notifications) wait for t, and the button says why.
git, read files, remember values across sessions, copy to clipboard::tour), suggest the next prompt, edit the system prompt, follow a turnEverything lives in one file: hooks/register.tsx. Read it and copy the parts you need.
Claude Code 2.1.308 or newer (mods are on by default). Tested on 2.1.309. Mods are early access, so the API may change.
MIT, see LICENSE.
claude plugin validate .
claude plugin test .hooks/register.tsx 288 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Tour } from '../types'
5
6const PANE = 'mod-tour'
7const STEP_MS = 3_000
8const START: Tour = { slide: 0, isPaused: true, isOpen: false, isBusy: false, detail: '', isSpinnerOn: false, lastTurn: null }
9const tour = atom({ plugin: 'mod-tour', key: 'tour' } as const, START)
10
11// `act` names what [Try it] runs; a slide without one only explains.
12type Slide = { title: string; api: string; text: string; act?: string }
13
14const SLIDES: Slide[] = [
15 { title: 'Claude Mods tour', api: 'export const register = on => { … }', text: 'A mod is TypeScript that runs inside Claude Code. Step with ◀ ▶ (p / n), free examples run as you arrive, [Try it] (t) runs the rest, ▶ play (s) to autoplay.' },
16 // Telling the person something
17 { title: 'Line in the chat history', api: '$.ui.log(text)', text: 'A dim line in the transcript. Example: report git changes without a turn.', act: 'log' },
18 { title: 'Toast', api: '$.ui.toast(text)', text: 'A short message (top right in fullscreen, else on the notification bar). Example: “Tests passed”.', act: 'toast' },
19 { title: 'Status line', api: '$.ui.status(text)', text: 'Text in the status line until cleared. Example: how full the context window is.', act: 'status' },
20 { title: 'System notification', api: '$.ui.notify(text, { title })', text: 'A native notification by your /config channel. Example: a long task finished.', act: 'notify' },
21 // Drawing
22 { title: 'Band above the prompt', api: "on('ui.render', { component: 'AbovePrompt' })", text: 'A live row above the prompt box, shown while this slide is open. Example: git branch.', act: 'band' },
23 { title: 'Pane with a field', api: '$.ui.open + <Input onSubmit>', text: 'This window is a pane. Type in the field below and press Enter: the mod saves it with $.store.', act: 'input' },
24 { title: 'Restyle Claude Code', api: "on('ui.render', { component: 'Spinner' })", text: 'Change what Claude Code draws itself: spinner, tool rows, messages. [Try it] tags the spinner with 🎬 for your next turns.', act: 'spinner' },
25 // Reaching out
26 { title: 'Run a host command', api: "$.process.run(['git', 'log', '-3', '--oneline'])", text: 'Runs a program by argv, no shell. Example: the last 3 commits.', act: 'process' },
27 { title: 'Read files', api: '$.fs.list / read / write / stat', text: 'Files without a tool call. Example: what is in the working folder.', act: 'fs' },
28 { title: 'Remember across sessions', api: '$.store.get / set', text: 'Values that survive restarts. Example: a counter of your tries.', act: 'store' },
29 { title: 'Clipboard', api: '$.ui.copy({ text, surface })', text: 'Put text on the clipboard. Example: copy this slide’s API line.', act: 'copy' },
30 // Models and tools
31 { title: 'Ask a model', api: "$.model.complete({ model: 'haiku', prompt })", text: 'A side call to any model, no turn, no history.', act: 'model' },
32 { title: 'Side question over this session', api: '$.model.fork({ prompt })', text: 'Asks about the session itself, served from the prompt cache. Example: what is still open?', act: 'fork' },
33 { title: 'Call a tool', api: "$.tool.call({ tool: 'Bash', command: 'date' })", text: 'A mod can call any tool, and add its own with $.tool.register: this mod adds a clock tool that Claude can call (ask “what time is it?”). Example: run Bash from code.', act: 'tool' },
34 { title: 'Add a slash command', api: "$.command.register + on('command.run', { command })", text: 'This mod adds /tour-ping. It answers at once, even while Claude works. [Try it] puts it in your prompt box: press Esc, then Enter.', act: 'command' },
35 // Stepping into Claude’s work
36 { title: 'Hold a tool call and ask', api: "on('tool.call') + $.ui.ask(question, options)", text: 'A guard can pause a call and ask you. [Try it] opens the real question box; nothing runs.', act: 'ask' },
37 { title: 'Rewrite a prompt', api: "on('prompt.submit', … next({ ...e, text }))", text: 'This mod turns ::tour into a full question. [Try it] puts ::tour in your prompt box: press Enter.', act: 'fill' },
38 { title: 'Suggest the next prompt', api: '$.prompt.suggest({ text })', text: 'The dim hint in an empty prompt box, Tab to take it.', act: 'suggest' },
39 { title: 'System prompt', api: "on('prompt.compose') → { sections }", text: 'Add, replace or drop sections of the system prompt. Here: the sections of this session.', act: 'compose' },
40 { title: 'Follow a turn', api: "on('turn.complete') + on('tool.call')", text: 'Hooks share variables: one counts tool calls, one reads them when the turn ends.', act: 'turn' },
41 { title: 'That is the tour', api: 'claude plugin validate · claude plugin test', text: 'Every feature here is a few lines of TypeScript. ◀ to go back, ✕ to close.' },
42]
43
44let startedAt = 0
45let tools = 0
46
47async function act($: EngineInterface, name: string, surface: string): Promise<string> {
48 switch (name) {
49 case 'log': {
50 const run = await $.process.run(['git', 'status', '--porcelain'])
51 const changed = run.exitCode === 0 ? run.stdout.split('\n').filter(Boolean).length : -1
52 $.ui.log(changed < 0 ? 'this folder is not a git repository' : `${changed} file(s) changed in git`)
53 return 'look at the chat history'
54 }
55 case 'toast':
56 $.ui.toast('Tests passed ✓ (example from mod-tour)')
57 return 'toast shown'
58 case 'status': {
59 const { context, cost } = await $.session.usage()
60 const text = `ctx ${context.percent ?? '?'}% of ${Math.round(context.window / 1000)}k${cost ? ` · $${cost.usd.toFixed(2)}` : ''}`
61 $.ui.status(text)
62 return `${text}, look at the status line`
63 }
64 case 'notify': {
65 const sent = await $.ui.notify('A long task finished (example)', { title: 'mod-tour' })
66 return sent.isSent ? `sent by ${sent.channel}` : `not sent: ${sent.reason}`
67 }
68 case 'band': {
69 const run = await $.process.run(['git', 'branch', '--show-current'])
70 return run.exitCode === 0 ? `branch ${run.stdout.trim() || '(detached)'}` : 'no git here'
71 }
72 case 'spinner': {
73 const t = await update($, tour, x => ({ ...x, isSpinnerOn: !x.isSpinnerOn }))
74 return t.isSpinnerOn ? 'on: send any prompt and watch the spinner' : 'off'
75 }
76 case 'process': {
77 const run = await $.process.run(['git', 'log', '-3', '--oneline'])
78 return run.exitCode === 0 ? run.stdout.trim() : 'no git here'
79 }
80 case 'fs': {
81 const entries = await $.fs.list(await $.session.cwd())
82 return `${entries.length} entries: ${entries.slice(0, 6).map(x => x.name).join(', ')}${entries.length > 6 ? ', …' : ''}`
83 }
84 case 'store': {
85 const tries = Number((await $.store.get('tries')) ?? 0) + 1
86 await $.store.set('tries', tries)
87 return `you pressed this ${tries}× (kept across sessions)`
88 }
89 case 'copy': {
90 const text = SLIDES[11]!.api
91 const copied = await $.ui.copy({ text, surface: surface as never })
92 return copied.isCopied ? `copied: ${text}` : `not copied: ${copied.reason}`
93 }
94 case 'model': {
95 const r = await $.model.complete({ model: 'haiku', prompt: 'In one line of max 15 words: what is a Claude Code mod good for?', timeoutMs: 20_000 })
96 return r.isAnswered ? `Haiku: ${r.text.trim()}` : `no answer: ${r.reason}`
97 }
98 case 'fork': {
99 const r = await $.model.fork({ prompt: 'In one line of max 20 words: what is still open in this session?' })
100 return r.isAnswered ? r.text.trim() : `no answer: ${r.reason}`
101 }
102 case 'tool': {
103 const ran = await $.tool.call({ tool: 'Bash', command: 'date' })
104 return ran.deny !== undefined ? `denied: ${ran.deny}` : String(ran.text ?? JSON.stringify(ran.result)).slice(0, 200)
105 }
106 case 'command': {
107 await $.prompt.fill({ text: '/tour-ping', mode: 'replace' })
108 return 'press Esc to leave the pane, then Enter'
109 }
110 case 'ask': {
111 const answer = await $.ui.ask('Claude wants to run `rm -rf /tmp/demo`. Run it?', { header: 'mod-tour', options: ['Run it', 'Cancel'] })
112 return `you chose “${answer}”. It is a demo, so nothing ran.`
113 }
114 case 'fill':
115 await $.prompt.fill({ text: '::tour ', mode: 'replace' })
116 return 'press Esc to leave the pane, then Enter'
117 case 'suggest': {
118 const r = await $.prompt.suggest({ text: 'run the tests you just wrote' })
119 return r.isShown ? 'shown: Esc to the prompt box, Tab to take it' : 'not shown (the box holds text or a turn runs)'
120 }
121 case 'compose': {
122 const { sections } = await $.prompt.compose()
123 return `${sections.length} sections: ${sections.map(s => s.id).join(', ')}`
124 }
125 case 'turn': {
126 const last = (await read($, tour)).lastTurn
127 return last === null ? 'no turn finished since the mod loaded: send a prompt' : `last turn: ${last.seconds}s, ${last.tools} tool call(s)`
128 }
129 default:
130 return ''
131 }
132}
133
134// Free, quiet examples run as soon as you step to them; the rest wait for [Try it].
135const AUTO = new Set(['log', 'toast', 'status', 'band', 'process', 'fs', 'store', 'turn', 'compose', 'tool'])
136const WHY: Record<string, string> = {
137 model: 'costs a Haiku call', fork: 'costs a model call',
138 ask: 'opens a dialog', copy: 'overwrites clipboard', notify: 'sends a notification',
139 fill: 'changes your prompt', command: 'changes your prompt', suggest: 'changes your prompt', spinner: 'toggle',
140}
141
142async function show($: EngineInterface, slide: number, surface?: string) {
143 const index = (slide + SLIDES.length) % SLIDES.length
144 await update($, tour, t => ({ ...t, slide: index, detail: '', isBusy: false }))
145 $.ui.invalidate('ui.render')
146 const name = SLIDES[index]?.act
147 if (surface !== undefined && name !== undefined && AUTO.has(name)) await tryIt($, surface)
148}
149
150async function tryIt($: EngineInterface, surface: string) {
151 const t = await read($, tour)
152 const name = SLIDES[t.slide]?.act
153 if (name === undefined || t.isBusy) return
154 await update($, tour, x => ({ ...x, isBusy: true, detail: '…' }))
155 let detail: string
156 try {
157 detail = await act($, name, surface)
158 } catch (err) {
159 detail = `failed: ${String(err)}`
160 }
161 await update($, tour, x => (x.slide === t.slide ? { ...x, isBusy: false, detail } : { ...x, isBusy: false }))
162}
163
164function tryLabel(name: string | undefined): string {
165 if (name !== undefined && AUTO.has(name)) return '↻ Run again'
166 return name !== undefined && WHY[name] !== undefined ? `Try it (${WHY[name]})` : 'Try it'
167}
168
169async function saveNote($: EngineInterface, text: string) {
170 await $.store.set('note', text)
171 await update($, tour, x => ({ ...x, detail: `saved “${text}” with $.store` }))
172}
173
174export const register: Register = on => {
175 on('session.start', async ($, e, next) => {
176 await $.command.register({ name: 'mod-tour', description: 'Tour of what a Claude mod can do (p/n step, t try, s play)', argumentHint: '[auto]' })
177 await $.command.register({ name: 'tour-ping', description: 'mod-tour demo: answers at once, even mid-turn' })
178 await $.tool.register({
179 name: 'clock',
180 description: 'Current date and time in UTC and in the local zone. Added by the mod-tour demo.',
181 inputSchema: { type: 'object', properties: {} },
182 isDeferred: false,
183 })
184 $.clock.every(STEP_MS, async () => {
185 const t = await read($, tour)
186 if (t.isOpen && !t.isPaused) await show($, t.slide + 1)
187 })
188 return next(e)
189 })
190
191 on('command.run', { command: 'mod-tour' }, async ($, e) => {
192 const isAuto = e.args.trim() === 'auto'
193 await update($, tour, x => ({ ...START, isSpinnerOn: x.isSpinnerOn, lastTurn: x.lastTurn, isOpen: true, isPaused: !isAuto }))
194 await $.ui.open({ id: PANE, title: 'Mod tour', focus: true })
195 return { text: isAuto ? 'Tour playing: a slide every 3 s.' : 'Tour opened: p/n to step, t to try, s to play.' }
196 })
197
198 on('command.run', { command: 'tour-ping' }, async ($, e) => ({ text: `pong from mod-tour at ${new Date(await $.clock.now()).toLocaleTimeString()} ${e.args}`.trim() }))
199
200 on('ui.close', { id: PANE }, async ($, e, next) => {
201 await update($, tour, t => ({ ...t, isOpen: false }))
202 $.ui.status(undefined)
203 return next(e)
204 })
205
206 // Feeds the "Follow a turn" slide.
207 on('prompt.submit', async ($, e, next) => {
208 startedAt = await $.clock.now()
209 tools = 0
210 if (e.command === undefined && !e.isMeta && e.text.trim() === '::tour') {
211 return next({ ...e, text: 'The mod-tour mod rewrote my "::tour" prompt into this one. In one sentence, explain what a prompt.submit hook can do.' })
212 }
213 return next(e)
214 })
215
216 // Also answers the clock tool when Claude calls it (its name is not in the tool types until the mod has loaded once).
217 on('tool.call', async ($, e, next) => {
218 tools += 1
219 if ((e.tool as string) === 'mcp__mod-tour__clock') {
220 const now = new Date(await $.clock.now())
221 return { result: `utc: ${now.toISOString()}\nlocal: ${now.toString()}` }
222 }
223 return next(e)
224 })
225
226 on('turn.complete', async ($, e, next) => {
227 if (startedAt > 0) {
228 const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
229 await update($, tour, t => ({ ...t, lastTurn: { seconds, tools } }))
230 }
231 return next(e)
232 })
233
234 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
235 const t = await read($, tour)
236 return t.isSpinnerOn ? next({ ...e, props: { ...e.props, message: `🎬 ${e.props.message ?? e.props.word} (restyled by mod-tour)` } }) : next(e)
237 })
238
239 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
240 const t = await read($, tour)
241 if (!t.isOpen || SLIDES[t.slide]?.act !== 'band' || e.props.hasSurvey) return next(e)
242 const { Text } = $.ui.resolve(e)
243 return <Text color="magenta">★ mod-tour band{t.detail !== '' && t.detail !== '…' ? ` · ${t.detail}` : ' · reading git…'}</Text>
244 })
245
246 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
247 const { Box, Button, Text } = $.ui.resolve(e)
248 const t = await read($, tour)
249 const slide = SLIDES[t.slide] ?? SLIDES[0]!
250 const dots = SLIDES.map((_, i) => (i === t.slide ? '●' : '·')).join('')
251
252 let field = null
253 if (slide.act === 'input' && e.surface !== 'mobile') {
254 const { Input } = $.ui.resolve(e)
255 field = <Input key="note" placeholder="Type a note, Enter to save" submitLabel="save" onSubmit={(text: string) => saveNote($, text)} />
256 }
257
258 return (
259 <Box flexDirection="column">
260 <Text dimColor>
261 {dots} {t.slide + 1}/{SLIDES.length}
262 {t.isPaused ? '' : ' ▶ playing'}
263 </Text>
264 <Box marginTop={1} flexDirection="column">
265 <Text bold>{slide.title}</Text>
266 <Text color="cyan">{slide.api}</Text>
267 <Text>{slide.text}</Text>
268 </Box>
269 {field}
270 {t.detail !== '' && (
271 <Box marginTop={1}>
272 <Text color="green">→ {t.detail}</Text>
273 </Box>
274 )}
275 <Box marginTop={1}>
276 <Button key="prev" hotkey="p" label="◀" onPress={press => show($, t.slide - 1, press.surface)} />
277 <Button key="next" hotkey="n" label="▶" onPress={press => show($, t.slide + 1, press.surface)} />
278 {slide.act !== undefined && slide.act !== 'input' && (
279 <Button key="try" hotkey="t" variant="primary" label={t.isBusy ? 'running…' : tryLabel(SLIDES[t.slide]?.act)} onPress={press => tryIt($, press.surface)} />
280 )}
281 <Button key="play" hotkey="s" label={t.isPaused ? '▶ play' : '⏸ pause'} onPress={() => update($, tour, x => ({ ...x, isPaused: !x.isPaused }))} />
282 <Button key="close" label="✕" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
283 </Box>
284 </Box>
285 )
286 })
287}
288types/index.d.ts 18 lines1export type LastTurn = { seconds: number; tools: number }
2
3export type Tour = {
4 slide: number
5 isPaused: boolean
6 isOpen: boolean
7 isBusy: boolean
8 detail: string
9 isSpinnerOn: boolean
10 lastTurn: LastTurn | null
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'mod-tour': { tour: Tour }
16 }
17}
18