SLOPSHOPPER

first-mod

Counts tool calls, shows a band above the prompt, and adds /tally plus a ping tool

newbandspinnerguardcommandtoast
v0.1.0MITupdated 2026-10-08parkjangwon/pawn/examples/mods/first-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · first-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /tally ⎿ first-mod: The agent has made 9 tool calls since this mod loaded ✻ Thinking · tool calls: 9…… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

first-mod

Sample mod. Counts tool calls, draws a band above the composer, adds /tally, and registers a ping tool.

How mods load, consent, and the $ API: docs/agent/MODS.md (한국어).

Layout

first-mod/
├── .claude-plugin/plugin.json
└── hooks/
    ├── hooks.json
    └── register.js

Try it

  1. Settings → Plugins → Mods → Install sample mod. Or copy this folder to ~/.pawn/mods/first-mod.
  2. Allow it in the review dialog. Consent is tied to plugin.json version.
  3. In chat, the thinking line shows · tool calls: N….
  4. Type /tally. The model can call mcp__first-mod__ping (it returns pong). Reset and the note field use ui.press and ui.input.
Source 1 files
hooks/register.js 72 lines
1// The count, shared by the hooks below
2let calls = 0
3let note = ''
4
5// Pawn / Claude Code calls this once when the mod loads
6export function register(on) {
7  // Runs when the session starts, before your first prompt
8  on('session.start', async ($, e, next) => {
9    await $.command.register({
10      name: 'tally',
11      description: 'Show how many tool calls the agent has made'
12    })
13    await $.tool.register({
14      name: 'ping',
15      description: 'Reply pong. Use when the user asks this mod to ping.',
16      inputSchema: { type: 'object', properties: {} },
17      handler: async () => 'pong'
18    })
19    $.ui.invalidate('ui.render')
20    return next(e)
21  })
22
23  // Runs each time the agent is about to use a tool
24  on('tool.call', async ($, e, next) => {
25    calls += 1
26    $.ui.invalidate('ui.render')
27    return next(e)
28  })
29
30  // Runs when you type /tally
31  on('command.run', { command: 'tally' }, async () => {
32    return { text: 'The agent has made ' + calls + ' tool calls since this mod loaded' + (note ? ' · note: ' + note : '') }
33  })
34
35  // Interactive band above the composer
36  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
37    const el = $.ui.resolve(e)
38    return next({
39      ...e,
40      props: {
41        ...e.props,
42        plugin: $.plugin.name,
43        tree: el.Box({
44          children: [
45            el.Text({ text: 'first-mod · tool calls: ' + calls + (note ? ' · ' + note : '') }),
46            el.Button({ id: 'reset-tally', text: 'Reset count' }),
47            el.Input({ id: 'note', placeholder: 'Optional note…', value: note })
48          ]
49        })
50      }
51    })
52  })
53
54  on('ui.press', { id: 'reset-tally' }, async ($, e) => {
55    calls = 0
56    $.ui.toast('Tally reset')
57    $.ui.invalidate('ui.render')
58    return e
59  })
60
61  on('ui.input', { id: 'note' }, async ($, e) => {
62    note = String(e.value || '')
63    $.ui.invalidate('ui.render')
64    return e
65  })
66
67  // Runs each time the spinner is drawn
68  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
69    return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
70  })
71}
72