SLOPSHOPPER

mod-tour

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

newpanebandspinnerguardcommand
v0.2.1MITupdated 2026-10-24kuzmany/mod-tour
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-tour
│ ┃ Mod tour ✕ › fix the failing auth test and add an audit log call │ ┃ ●····················· 1/22 │ ┃ ⏺ Read(src/auth.ts) │ ┃ Claude Mods tour ⎿ Read 6 lines │ ┃ export const register = on => { … } ⏺ Update(src/auth.ts) │ ┃ A mod is TypeScript that runs inside Claude ⎿ Added 2 lines, removed 1 line │ ┃ Code. Step with ◀ ▶ (p / n), free examples ⏺ Bash(bun test) │ ┃ run as you arrive, [Try it] (t) runs the ⎿ 3 pass, 1 fail │ ┃ rest, ▶ play (s) to autoplay. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ ◀ ][ ▶ ][ ▶ play ][ ✕ ] │ ✻ Worked for 42s · done 4:20 PM │ │ › /mod-tour │ ⎿ mod-tour: Tour opened: p/n to step, t to try, s to play. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mod tour
●····················· 1/22 Claude Mods tour export const register = on => { … } 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. [ ◀ ][ ▶ ][ ▶ play ][ ✕ ]
README

mod-tour

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

mod-tour in a clean Claude Code session: stepping through slides, a toast, the status line, a band above the prompt, and a real question box

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.

Install

At the Claude Code prompt:

/plugin marketplace add kuzmany/mod-tour
/plugin install mod-tour@mod-tour

Then type /mod-tour.

Use

KeyDoes
p / nprevious / next slide; free examples run on arrival
tTry it: run (or re-run) the slide's example
splay / pause autoplay (one slide every 3 s)
/mod-tour autoopen with autoplay on

Slides that cost tokens or interrupt you (model calls, dialogs, clipboard, notifications) wait for t, and the button says why.

What you see

  • Tell you something: transcript line, toast, status line, system notification
  • Draw: a band above the prompt, a pane with an input field, a restyled spinner
  • Reach out: run git, read files, remember values across sessions, copy to clipboard
  • Models and tools: a Haiku side call, a fork that asks about this session, a tool Claude can call, a slash command, any tool called from code
  • Step into Claude's work: pause a tool call and ask you, rewrite a prompt (::tour), suggest the next prompt, edit the system prompt, follow a turn

Everything lives in one file: hooks/register.tsx. Read it and copy the parts you need.

Requirements

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.

License

MIT, see LICENSE.

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 288 lines
1import { 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}
288
types/index.d.ts 18 lines
1export 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