SLOPSHOPPER

tap

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

newpanebandguardcommandprompt
v0.1.0no licenseupdated 2026-10-05gfsaaser24/claude-code-mod-examples/tap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tap
│ ┃ Tap ✕ › fix the failing auth test and add an audit log call │ ┃ Tap shows the translation layer. It sits on │ ┃ the hook chain and sees each thing that ⏺ Read(src/auth.ts) │ ┃ crosses the wall: every tool call, every ⎿ Read 6 lines │ ┃ turn, every new transcript row. It can also ⏺ Update(src/auth.ts) │ ┃ hide API keys in a tool result before the ⎿ Added 2 lines, removed 1 line │ ┃ model reads them. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 1 turns 9 tool calls 1 errors 9 transcrip │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Last turn 42.0 s / session cost $0.42 │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ 1480 tokens out / 91,000 read from cache │ │ ┃ ╰──────────────────────────────────────────╯ › /tap │ ┃ ⎿ tap: Tap pane opened. │ ┃ Tool calls │ ┃ 22:29:13 ● Grep 0 ms │ ┃ 22:29:13 ● Edit 0 ms │ ┃ 22:29:13 ● Write 0 ms │ ┃ 22:29:13 ● Write 0 ms │ ┃ 22:29:13 ● Bash 0 ms │ ┃ 22:29:13 ● Bash 0 ms │ ┃ 22:29:13 ● Bash 0 ms │ ┃ 22:29:13 ● Bash 0 ms │ ┃ │ ┃ Transcript rows, by door context 49% / $0.42 / 9 tool calls / last turn 42 s [ Tap ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
context 49% / $0.42 / 9 tool calls / last turn 42 s [ Tap ]
Pane · Tap
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. 1 turns 9 tool calls 1 errors 9 transcript rows ╭──────────────────────────────────────────────────────────╮ │ Last turn 42.0 s / session cost $0.42 │ │ 1480 tokens out / 91,000 read from cache │ ╰──────────────────────────────────────────────────────────╯ Tool calls 22:29:13 ● Grep 0 ms 22:29:13 ● Edit 0 ms 22:29:13 ● Write 0 ms 22:29:13 ● Write 0 ms 22:29:13 ● Bash 0 ms 22:29:13 ● Bash 0 ms 22:29:13 ● Bash 0 ms 22:29:13 ● Bash 0 ms Transcript rows, by door tool-result 9 ╭──────────────────────────────────────────────────────────╮ │ Secret guard is off │ │ 1 keys seen in tool results / 0 hidden from the model │ │ [ Turn guard on ] │ ╰──────────────────────────────────────────────────────────╯ How: hooks on tool.call, turn.complete and session.append. Each one calls next(e).
README

Claude Code mod examples

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.

ModLayerWhat it shows
pulseTechnicalA Node sidecar next to a sealed mod. The mod talks JSON to it over localhost.
tapTranslationHooks on the chain between you and the model. It records what crosses, and it can hide keys.
boardFront endA Notion kanban. SVG draws the cards. Real buttons and links sit on top.

Pulse

Pulse pane

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

Tap pane

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

Board pane

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.

Setup

1. What you need

  • Claude Code 2.1.286 or later. Check with claude --version.
  • Node on your PATH. Only Pulse needs it, for its sidecar.
  • A Notion connector in Claude Code. Only Board needs it.

2. Get the code

git clone https://github.com/gfsaaser24/claude-code-mod-examples.git
cd claude-code-mod-examples

3. Read the mod, then check it

Open hooks/register.tsx in the mod you want. Then run the validator:

claude plugin validate ./pulse

A pass with warnings is normal.

4. Start Claude Code with the mod

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.

5. Board only: point it at your Notion database

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:

  1. Find your Notion connector id. Ask Claude Code: "List your Notion tool names." The names look like mcp__<id>__notion-fetch. The <id> part is the value for NOTION.
  2. Find your database id. Ask Claude Code: "Fetch this Notion database and give me its collection:// id", and paste the database link. The answer looks like collection://xxxxxxxx-.... That is the value for SOURCE.
  3. Put both values at the top of board/hooks/register.tsx.
  4. Make sure the database has these columns: Name, Workstream (the status), Priority (P0, P1, P2), Owner, PR (a URL), Job ID (a number).
  5. Change 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.

6. Test a mod

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.

If something is wrong

  • The pane is blank. One bad prop makes the engine refuse the whole tree, and it says nothing. Run claude plugin test on the mod to see the reason.
  • Pulse does not show "sidecar alive". Check that node runs from the folder where you started Claude Code.
  • Board says "Could not read Notion". The NOTION or SOURCE value is wrong, or the connector is not signed in.

Before you run these

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.

Source 2 files
hooks/register.tsx 264 lines
1// 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}
264
types/index.d.ts 24 lines
1export 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