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

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).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) |
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.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.
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.mods/check.sh <name> until it passes, then harnesses/tools/moddev.sh <name> to try it live.mods/README.md).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.
hooks/register.ts 164 lines1// 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}
164types/index.d.ts 16 lines1// 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