Tap: a live recorder on the hook chain, with a secret guard

Three working mods for the Claude Code mod system. Each one shows one layer of it.
The long write-up is here: What Claude Code mods can and cannot do.
| Mod | Layer | What it shows |
|---|---|---|
pulse | Technical | A Node sidecar next to a sealed mod. The mod talks JSON to it over localhost. |
tap | Translation | Hooks on the chain between you and the model. It records what crosses, and it can hide keys. |
board | Front end | A Notion kanban. SVG draws the cards. Real buttons and links sit on top. |

The mod itself can't read the CPU or run git. It lives in a sealed worker with no Node. So it starts a small Node server with $.process.spawn, then asks it for JSON every 3 seconds with $.http.fetch. The chart and the memory bar are SVG strings.
Read pulse/hooks/register.tsx and pulse/sidecar/server.mjs.

Tap hooks tool.call, prompt.submit, session.append and turn.complete. Every hook calls next(e), so nothing changes for the model unless you turn the secret guard on. With the guard on, key-shaped strings in a tool result are swapped for a mask before the model reads them.
Read tap/hooks/register.tsx.

Board reads a Notion database through $.mcp.call and draws one SVG per card. A click inside an SVG never reaches a mod, so the arrows are real Buttons laid over the card with position="absolute", and each logo has a Link laid over it. The arrows write the new status back to Notion.
Board needs 2 values from you, at the top of board/hooks/register.tsx:
NOTION: the id of your Notion connector.SOURCE: the collection:// id of your database.The database needs these columns: Name, Workstream (status), Priority, Owner, PR, Job ID. Change FLOW to match your own status names. With no ids set, the pane shows a "Load sample cards" button, and sample cards never write to Notion.
claude --version.PATH. Only Pulse needs it, for its sidecar.git clone https://github.com/gfsaaser24/claude-code-mod-examples.git
cd claude-code-mod-examples
Open hooks/register.tsx in the mod you want. Then run the validator:
claude plugin validate ./pulse
A pass with warnings is normal.
claude --plugin-dir ./pulse
Use ./tap or ./board for the other two. The pane opens when the session starts. If you close it, type /pulse, /tap or /board to open it again.
SVG shows only in the desktop app. In a terminal, Tap and Board fall back to text, and Pulse shows its numbers with no chart.
Board ships with placeholder ids, so it starts with a "Load sample cards" button. Sample cards never write to Notion. To use your own data:
mcp__<id>__notion-fetch. The <id> part is the value for NOTION.collection://xxxxxxxx-.... That is the value for SOURCE.board/hooks/register.tsx.Name, Workstream (the status), Priority (P0, P1, P2), Owner, PR (a URL), Job ID (a number).FLOW, SHORT and PARKED in the same file to match your own status names.After that, the arrows change the status of the real Notion page. There is no confirm step.
claude plugin test ./board
Each mod has a test that mounts its pane on the desktop and terminal surfaces. Board also has one that presses the buttons on the sample cards.
claude plugin test on the mod to see the reason.node runs from the folder where you started Claude Code.NOTION or SOURCE value is wrong, or the connector is not signed in.A mod runs as you. It can start processes, call the network, and call your connectors with no prompt. Read the hooks file before you run it. That goes for these 3 as well.
Tested on Claude Code 2.1.286 and 2.1.289, Windows. The mod system is new and it will change.
hooks/register.tsx 264 lines1// Tap: the translation layer.
2// It sits on the hook chain and records what crosses the wall:
3// each tool call, each turn, each transcript row, and any secret in a tool result.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { TapCall, TapData } from '../types'
8
9const PANE = 'tap'
10const EMPTY: TapData = {
11 turns: 0, toolCalls: 0, errors: 0, denied: 0, rows: 0, doors: [], calls: [],
12 lastTurn: null, costUsd: null, ctxPct: null, ctxTokens: null, secretsSeen: 0, redacted: 0,
13}
14const data = atom({ plugin: 'tap', key: 'data' } as const, EMPTY)
15const redactOn = atom({ plugin: 'tap', key: 'redactOn' } as const, false)
16
17const SECRET =
18 /(sk-[A-Za-z0-9_-]{24,}|sk_live_[A-Za-z0-9]{24,}|rk_live_[A-Za-z0-9]{24,}|AIza[0-9A-Za-z_-]{32,}|ghp_[A-Za-z0-9]{30,}|pit-[0-9a-f-]{30,}|xox[bap]-[A-Za-z0-9-]{20,}|EAA[A-Za-z0-9]{60,})/g
19const MASK = '[secret hidden by Tap]'
20
21let isRedacting = false
22let turnStartedAt = 0
23let rowCount = 0
24let secretsSeen = 0
25let redacted = 0
26const doorCounts: Record<string, number> = {}
27
28function shortTool(name: string): string {
29 if (!name.startsWith('mcp__')) return name
30 const parts = name.split('__')
31 return `${parts[parts.length - 1]} (mcp)`
32}
33
34function clock(ms: number): string {
35 const d = new Date(ms)
36 const two = (n: number) => String(n).padStart(2, '0')
37 return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
38}
39
40function doorList(): { name: string; count: number }[] {
41 return Object.entries(doorCounts).map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count)
42}
43
44async function flush($: EngineInterface, patch: (d: TapData) => TapData) {
45 await update($, data, d => ({ ...patch(d ?? EMPTY), rows: rowCount, doors: doorList(), secretsSeen, redacted }))
46}
47
48async function toggleRedact($: EngineInterface) {
49 isRedacting = !isRedacting
50 await update($, redactOn, () => isRedacting)
51}
52
53async function togglePane($: EngineInterface) {
54 const isOpen = ((await $.ui.panes()) as readonly { id: string }[]).some(p => p.id === PANE)
55 if (isOpen) {
56 await $.ui.close({ id: PANE })
57 } else {
58 await $.ui.open({ id: PANE, title: 'Tap' })
59 }
60}
61
62function durationChart(calls: readonly TapCall[]): string {
63 const W = 340
64 const H = 78
65 const max = Math.max(1, ...calls.map(c => c.ms))
66 const bw = calls.length ? Math.min(22, (W - 20) / calls.length - 3) : 0
67 const bars = calls
68 .map((c, i) => {
69 const h = Math.max(3, (c.ms / max) * (H - 34))
70 const fill = c.status === 'ok' ? '#3FB950' : c.status === 'denied' ? '#D29922' : '#F85149'
71 return `<rect x="${(10 + i * (bw + 3)).toFixed(1)}" y="${(H - 8 - h).toFixed(1)}" width="${bw.toFixed(1)}" height="${h.toFixed(1)}" rx="3" fill="${fill}"/>`
72 })
73 .join('')
74 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
75 <rect width="${W}" height="${H}" rx="10" fill="#1b1b1b"/>
76 <text x="10" y="18" font-family="system-ui, sans-serif" font-size="12" fill="#bbb">Tool call time, last ${calls.length} calls (longest ${max} ms)</text>
77 ${bars}
78</svg>`
79}
80
81function contextBar(pct: number): string {
82 const W = 340
83 const fill = pct > 80 ? '#F85149' : pct > 60 ? '#D29922' : '#3FB950'
84 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="30" viewBox="0 0 ${W} 30">
85 <rect width="${W}" height="30" rx="8" fill="#1b1b1b"/>
86 <rect x="3" y="3" width="${(((W - 6) * Math.min(pct, 100)) / 100).toFixed(1)}" height="24" rx="6" fill="${fill}"/>
87 <text x="12" y="20" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#fff">Context window ${pct}% full</text>
88</svg>`
89}
90
91export const register: Register = on => {
92 on('session.start', async ($, e, next) => {
93 isRedacting = Boolean(await read($, redactOn))
94 await $.command.register({ name: 'tap', description: 'Open the Tap pane (live hook recorder)' })
95 void $.ui.open({ id: PANE, title: 'Tap' })
96 return next(e)
97 })
98
99 on('command.run', { command: 'tap' }, async $ => {
100 await $.ui.open({ id: PANE, title: 'Tap' })
101 return { text: 'Tap pane opened.' }
102 })
103
104 on('prompt.submit', ($, e, next) => {
105 turnStartedAt = Date.now()
106 return next(e)
107 })
108
109 on('tool.call', async ($, e, next) => {
110 const t0 = Date.now()
111 const ran = await next(e)
112 const out = ran as { deny?: string; isError?: boolean }
113 const call: TapCall = {
114 at: clock(t0),
115 tool: shortTool(String((e as { tool: string }).tool)),
116 ms: Date.now() - t0,
117 status: out.deny !== undefined ? 'denied' : out.isError ? 'error' : 'ok',
118 }
119 await flush($, d => ({
120 ...d,
121 toolCalls: d.toolCalls + 1,
122 errors: d.errors + (call.status === 'error' ? 1 : 0),
123 denied: d.denied + (call.status === 'denied' ? 1 : 0),
124 calls: [...d.calls, call].slice(-14),
125 }))
126 return ran
127 })
128
129 on('session.append', ($, e, next) => {
130 const row = e as { door: string; message: unknown }
131 rowCount++
132 doorCounts[row.door] = (doorCounts[row.door] ?? 0) + 1
133 if (row.door !== 'tool-result') return next(e)
134 const raw = JSON.stringify(row.message)
135 const hits = raw.match(SECRET)
136 if (!hits) return next(e)
137 secretsSeen += hits.length
138 if (!isRedacting) return next(e)
139 redacted += hits.length
140 return (next as (x: unknown) => ReturnType<typeof next>)({ ...row, message: JSON.parse(raw.replace(SECRET, MASK)) })
141 })
142
143 on('turn.complete', async ($, e, next) => {
144 const t = e as { agentId?: string; durationMs?: number; usage?: Record<string, number> }
145 if (!t.agentId) {
146 let costUsd: number | null = null
147 let ctxPct: number | null = null
148 let ctxTokens: number | null = null
149 try {
150 const u = (await $.session.usage()) as { cost?: { usd?: number }; context?: { percent?: number; tokens?: number } }
151 costUsd = u.cost?.usd ?? null
152 ctxPct = u.context?.percent ?? null
153 ctxTokens = u.context?.tokens ?? null
154 } catch {}
155 const ms = t.durationMs ?? (turnStartedAt ? Date.now() - turnStartedAt : 0)
156 await flush($, d => ({
157 ...d,
158 turns: d.turns + 1,
159 lastTurn: {
160 ms,
161 inTok: t.usage?.input_tokens ?? 0,
162 outTok: t.usage?.output_tokens ?? 0,
163 cacheRead: t.usage?.cache_read_input_tokens ?? 0,
164 },
165 costUsd,
166 ctxPct,
167 ctxTokens,
168 }))
169 }
170 return next(e)
171 })
172
173 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
174 const { Box, Text, Button, Markdown, Svg } = $.ui.resolve(e) as Record<string, any>
175 const d = ((await read($, data)) ?? EMPTY) as TapData
176 const isOn = Boolean(await read($, redactOn))
177 const hasSvg = e.surface !== 'terminal'
178 const dot = (s: TapCall['status']) => (s === 'ok' ? 'success' : s === 'denied' ? 'warning' : 'error')
179 return (
180 <Box flexDirection="column" gap={1}>
181 <Markdown
182 text={
183 '**Tap** shows the *translation layer*. It sits on the hook chain and sees each thing that crosses the wall: every tool call, every turn, every new transcript row. It can also hide API keys in a tool result before the model reads them.'
184 }
185 />
186 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
187 <Text>
188 <Text bold>{d.turns}</Text> <Text dimColor>turns</Text>
189 </Text>
190 <Text>
191 <Text bold>{d.toolCalls}</Text> <Text dimColor>tool calls</Text>
192 </Text>
193 <Text>
194 <Text bold color={d.errors ? 'error' : undefined}>{d.errors}</Text> <Text dimColor>errors</Text>
195 </Text>
196 <Text>
197 <Text bold>{d.rows}</Text> <Text dimColor>transcript rows</Text>
198 </Text>
199 </Box>
200 {d.ctxPct !== null && hasSvg ? <Svg source={contextBar(d.ctxPct)} alt={`Context window ${d.ctxPct} percent full`} width={340} height={30} /> : null}
201 {d.lastTurn ? (
202 <Box borderStyle="round" borderColor="suggestion" paddingX={1} flexDirection="column">
203 <Text>
204 <Text bold>Last turn</Text> {(d.lastTurn.ms / 1000).toFixed(1)} s
205 {d.costUsd !== null ? <Text dimColor> / session cost ${d.costUsd.toFixed(2)}</Text> : null}
206 </Text>
207 <Text dimColor>
208 {d.lastTurn.outTok} tokens out / {d.lastTurn.cacheRead.toLocaleString()} read from cache
209 </Text>
210 </Box>
211 ) : (
212 <Text dimColor>Waiting for the first turn to end...</Text>
213 )}
214 {d.calls.length && hasSvg ? <Svg source={durationChart(d.calls)} alt="Bar chart of tool call time" width={340} height={78} /> : null}
215 <Box flexDirection="column">
216 <Text bold>Tool calls</Text>
217 {d.calls.length === 0 ? <Text dimColor>None yet.</Text> : null}
218 {d.calls.slice(-8).map(c => (
219 <Text wrap="truncate-end">
220 <Text dimColor>{c.at}</Text> <Text color={dot(c.status)}>●</Text> {c.tool} <Text dimColor>{c.ms} ms</Text>
221 </Text>
222 ))}
223 </Box>
224 <Box flexDirection="column">
225 <Text bold>Transcript rows, by door</Text>
226 <Text dimColor wrap="wrap">
227 {d.doors.length ? d.doors.map(x => `${x.name} ${x.count}`).join(' / ') : 'None yet.'}
228 </Text>
229 </Box>
230 <Box borderStyle="round" borderColor={isOn ? 'success' : 'inactive'} paddingX={1} flexDirection="column">
231 <Text>
232 <Text bold>Secret guard</Text> is {isOn ? <Text color="success">on</Text> : <Text dimColor>off</Text>}
233 </Text>
234 <Text dimColor>
235 {d.secretsSeen} keys seen in tool results / {d.redacted} hidden from the model
236 </Text>
237 <Button key="redact" variant={isOn ? 'secondary' : 'primary'} label={isOn ? 'Turn guard off' : 'Turn guard on'} onPress={() => toggleRedact($)} />
238 </Box>
239 <Text dimColor>How: hooks on tool.call, turn.complete and session.append. Each one calls next(e).</Text>
240 </Box>
241 )
242 })
243
244 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
245 if (e.props.hasSurvey) {
246 return next(e)
247 }
248 const { Box, Text, Button } = $.ui.resolve(e)
249 const d = ((await read($, data)) ?? EMPTY) as TapData
250 const parts = [
251 d.ctxPct !== null ? `context ${d.ctxPct}%` : null,
252 d.costUsd !== null ? `$${d.costUsd.toFixed(2)}` : null,
253 `${d.toolCalls} tool calls`,
254 d.lastTurn ? `last turn ${(d.lastTurn.ms / 1000).toFixed(0)} s` : null,
255 ].filter(Boolean)
256 return (
257 <Box columnGap={1}>
258 <Text dimColor>{parts.join(' / ')}</Text>
259 <Button key="tap" label="Tap" onPress={() => togglePane($)} />
260 </Box>
261 )
262 })
263}
264types/index.d.ts 24 lines1export type TapCall = { at: string; tool: string; ms: number; status: 'ok' | 'error' | 'denied' }
2
3export type TapData = {
4 turns: number
5 toolCalls: number
6 errors: number
7 denied: number
8 rows: number
9 doors: { name: string; count: number }[]
10 calls: TapCall[]
11 lastTurn: { ms: number; inTok: number; outTok: number; cacheRead: number } | null
12 costUsd: number | null
13 ctxPct: number | null
14 ctxTokens: number | null
15 secretsSeen: number
16 redacted: number
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 tap: { data: TapData; redactOn: boolean }
22 }
23}
24