SLOPSHOPPER

mod-template

Observe-only starter mod: a /mod-template pane and a one-line band above the prompt. Copy it to start a new mod.

newpanebandcommand
v0.1.0no licenseupdated 2026-10-09SearchingAlpha/claude-dotfiles/mods/_template
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-template
│ ┃ mod-template ✕ › fix the failing auth test and add an audit log call │ ┃ This session: 1 turn · last 42s │ ┃ Copy mods/_template to start a mod; ⏺ Read(src/auth.ts) │ ┃ mods/CLAUDE.md has the rules. ⎿ Read 6 lines │ ┃ b: Hide band c: Close ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mod-template │ │ mod-template · 1 turn · last 42s hide ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
mod-template · 1 turn · last 42s hide ⟨Claude Code's own drawing⟩
Pane · mod-template
This session: 1 turn · last 42s Copy mods/_template to start a mod; mods/CLAUDE.md has the rules. b: Hide band c: Close
README

mod-template

The smallest mod worth copying. It is observe-only and draws two things:

  • /mod-template opens a pane: this session's main-loop turns and how long the last one took, a button that hides or shows the band, and one that closes the pane (Esc closes it too).
  • A one-line band above the prompt, mod-template · 2 turns · last 12s, with a hide button. The band option turns it off.

Every hook passes its event on, none can refuse anything, and in a session where nothing draws (claude -p, cron, the SDK) it registers nothing and draws nothing until a client attaches. mods/CLAUDE.md has the rules it follows.

| File | What it holds | | :- | :- | | .claude-plugin/plugin.json | Name, version, the types contract and the band option (userConfig) | | hooks/hooks.json | "modules": ["./register.ts"], what makes the plugin a mod | | hooks/register.ts | The hooks. session.start (with the surfaces check) and session.attach (a client joining later) go live through goLive, which registers /mod-template; then turn.complete, command.run (answers /mod-template only once the mod registered it in a live session, and passes it on otherwise), and ui.render for the pane and the band | | types/index.d.ts | The $.state contract: turns, lastTurnMs, isBandHidden | | tsconfig.json | Extends the generated .claude-plugin/types/tsconfig.json (laid by a load, gitignored) | | tests/ | fixtures.ts (the stub world) and template.test.ts (17 tests) |

Copy it

  1. cp -r mods/_template mods/<name> && rm -rf mods/<name>/.claude-plugin/types, <name> being the plugin name (letters, digits, -; not one that reads as Anthropic's, such as claude-... or ...-for-claude: mods/CLAUDE.md has the rule). mods/check.sh lays the types again.
  2. Replace mod-template with <name> everywhere: the manifest name and description, the PluginState key in types/index.d.ts, each atom({ plugin }), the command, the pane id and title, the band text, and PLUGIN and the expected strings in the tests. One pass does it:
   grep -rlZ mod-template mods/<name> | xargs -0 sed -i -- 's/mod-template/<name>/g'

(sed -i '' for sed -i on macOS.) The NUL-separated list and the -- keep a file name from reading as an option; a worktree-isolated session (a shepherd track) refuses the plain grep -rl | xargs sed form. Then rewrite this README for the new mod.

  1. Delete what you do not need (the band, the option, the turn counter) and the tests for it; write tests for what you add. Keep the surfaces check in session.start, the live-and-registered check in command.run, and their tests: mods/check.sh fails a mod that acts with nothing attached, or that answers its own command where it has not registered it.
  2. mods/check.sh <name> until it passes, then harnesses/tools/moddev.sh <name> to try it live.
  3. When it is ready to install, add its marketplace entry (see mods/README.md).

Try the template itself

mods/check.sh _template                       # validate, 17 tests, probe, headless load, tsc
harnesses/tools/moddev.sh _template --smoke   # headless load, no model turn
harnesses/tools/moddev.sh _template           # live: the band shows; type /mod-template

Tested with Claude Code 2.1.294 and 2.1.295.

Source 2 files
hooks/register.ts 164 lines
1// mod-template: the smallest mod worth copying. It follows mods/CLAUDE.md:
2// inert where nothing draws (claude -p, cron, the SDK), observe-only (every
3// hook on an engine event passes it on with next, none can refuse anything),
4// the values a drawing reads kept in $.state, and a pane opened only because
5// the person asked for it.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, On, PluginOptions } from 'claude-code'
8
9const PANE = 'mod-template'
10
11// `plugin` and `key` stay string literals and each atom sits in a const:
12// `claude plugin validate` reads them off the source and holds them to
13// types/index.d.ts.
14const turns = atom({ plugin: 'mod-template', key: 'turns' } as const, 0)
15const lastTurnMs = atom({ plugin: 'mod-template', key: 'lastTurnMs' } as const, null)
16const isBandHidden = atom({ plugin: 'mod-template', key: 'isBandHidden' } as const, false)
17
18/** One line of what the mod has seen: the band and the pane both draw it. */
19export function summaryOf(count: number, ms: number | null): string {
20  if (count === 0) {
21    return 'no turns yet'
22  }
23
24  const last = ms === null ? '' : ` · last ${Math.round(ms / 1000)}s`
25
26  return `${count} turn${count === 1 ? '' : 's'}${last}`
27}
28
29/**
30 * What the mod does once the session has somewhere to draw; resolves to
31 * whether /mod-template is now the mod's. `$` may be passed to a function at
32 * the top level of this file, and nowhere else.
33 */
34async function goLive($: EngineInterface): Promise<boolean> {
35  // The prompt and its band may have drawn already, while the mod was inert;
36  // a module variable redraws nothing by itself, so ask for it.
37  $.ui.invalidate('ui.render')
38
39  // Last, and caught: register throws for a taken name (a skill or command of
40  // the person's called mod-template), and a hook that throws is skipped whole.
41  try {
42    await $.command.register({ name: 'mod-template', description: 'Open the mod-template pane', immediate: true })
43    return true
44  } catch (error) {
45    $.ui.log(`/mod-template not registered: ${String(error)}`, { to: 'debug' })
46    return false
47  }
48}
49
50export function register(on: On, options: PluginOptions) {
51  const isBandOn = options.band !== false
52  // True once the session has a surface; until then every hook only passes its
53  // event on. A module variable is enough: a reload runs session.start again.
54  let isLive = false
55  // True once /mod-template is the mod's: registered in a live session.
56  let isRegistered = false
57
58  // A terminal session, a background one (`claude --bg`, agent view: it
59  // starts on a terminal surface with nobody looking) and a desktop one go
60  // live here.
61  on('session.start', async ($, e, next) => {
62    if ((await $.session.surfaces()).length === 0) {
63      return next(e)
64    }
65
66    isLive = true
67    isRegistered = await goLive($)
68
69    return next(e)
70  })
71
72  // A session that started with nothing to draw on goes live when a client
73  // attaches: a phone, the desktop app. The terminal never raises it;
74  // session.start covers that one.
75  on('session.attach', async ($, e, next) => {
76    if (!isLive) {
77      isLive = true
78      isRegistered = await goLive($)
79    }
80
81    return next(e)
82  })
83
84  // Observe only: count main-loop turns (a subagent's carries agentId) and pass
85  // the event on. Writing $.state redraws the band and the pane that read it.
86  on('turn.complete', async ($, e, next) => {
87    if (isLive && e.agentId === undefined) {
88      await update($, turns, count => count + 1)
89      await update($, lastTurnMs, () => e.durationMs)
90    }
91
92    return next(e)
93  })
94
95  // Answers its own command only once it registered /mod-template in a live
96  // session. A skill or command of the person's with that name (register
97  // refused it), and `claude -p "/mod-template"` where nothing draws, get the
98  // event passed on: an answer without next would run no command at all.
99  // Calling next makes it a gate, so it is caught.
100  on('command.run', { command: 'mod-template' }, async ($, e, next) => {
101    if (!isLive || !isRegistered) {
102      return next(e)
103    }
104
105    const opened = await $.ui.open({ id: PANE, title: 'mod-template', focus: true, closeOnEscape: true })
106
107    return opened.isPlaced ? {} : { text: `The pane is waiting: ${opened.reason}` }
108  }).catch(($, e, next) => next(e))
109
110  on('ui.render', { component: 'Pane', requestId: 'mod-template' }, async ($, e) => {
111    const { Box, Text, Button } = $.ui.resolve(e)
112    const isHidden = await read($, isBandHidden)
113    const summary = summaryOf(await read($, turns), await read($, lastTurnMs))
114
115    return Box({
116      flexDirection: 'column',
117      children: [
118        Text({ children: [`This session: ${summary}`] }),
119        Text({ dimColor: true, children: ['Copy mods/_template to start a mod; mods/CLAUDE.md has the rules.'] }),
120        Box({
121          flexDirection: 'row',
122          columnGap: 2,
123          children: [
124            Button({
125              key: 'band',
126              label: isHidden ? 'Show band' : 'Hide band',
127              hotkey: 'b',
128              plain: true,
129              onPress: () => update($, isBandHidden, was => !was),
130            }),
131            Button({ key: 'close', label: 'Close', hotkey: 'c', plain: true, onPress: () => $.ui.close({ id: PANE }) }),
132          ],
133        }),
134      ],
135    })
136  })
137
138  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
139    if (!isLive || !isBandOn || e.props.hasSurvey || (await read($, isBandHidden))) {
140      return next(e)
141    }
142
143    const { Box, Text, Button } = $.ui.resolve(e)
144    const summary = summaryOf(await read($, turns), await read($, lastTurnMs))
145    // What the mods after this one draw stays, under this line.
146    const below = await next(e)
147
148    return Box({
149      flexDirection: 'column',
150      children: [
151        Box({
152          flexDirection: 'row',
153          columnGap: 1,
154          children: [
155            Text({ dimColor: true, wrap: 'truncate-end', children: [`mod-template · ${summary}`] }),
156            Button({ key: 'hide', label: 'hide', plain: true, dimColor: true, onPress: () => update($, isBandHidden, () => true) }),
157          ],
158        }),
159        below,
160      ],
161    })
162  })
163}
164
types/index.d.ts 16 lines
1// The mod's $.state contract: every value the module reads or writes with
2// atom/read/update is declared here, under the plugin's name. No imports and
3// no `export {}`: `claude plugin validate` refuses a contract that exports.
4declare module 'claude-code' {
5  interface PluginState {
6    'mod-template': {
7      /** Main-loop turns finished this session (subagent turns are not counted). */
8      turns: number
9      /** How long the last of them took, in milliseconds; null before the first. */
10      lastTurnMs: number | null
11      /** The person pressed "hide" on the band; lasts until /clear, /resume or /branch. */
12      isBandHidden: boolean
13    }
14  }
15}
16