SLOPSHOPPER

dashboard

A /dashboard pane that fetches a JSON URL every minute and shows a few numbers from it

newpanecommandnetworktimer
v0.1.0no licenseupdated 2026-10-03carlvellotti/mods-starter/mods/dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dashboard
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ https://api.github.co…anthropics/claude-code │ ┃ Could not load: the server answered 0 ⏺ Read(src/auth.ts) │ ┃ r: Refresh x: Close ⎿ 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 │ │ › /dashboard │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dashboard
https://api.github.com/repos/anthropics/claude-code Could not load: the server answered 0 r: Refresh x: Close
README

Claude Code mods starter

Five small, commented mods you can run, read, and change, plus prompts for asking Claude to build your own.

Built and checked against Claude Code v2.1.287, the release where mods launched (October 1, 2026). The mods API is marked early access, so names can change between releases. Run claude --version to see yours.

What a mod is

A mod is a small TypeScript or JavaScript file that Claude Code runs inside itself while you work. It can watch what Claude does, change or block it, and draw things on screen, such as a line above the prompt or a side panel. It ships as a normal Claude Code plugin, so you install, update, and turn it off with /plugin.

The formula

Almost every mod idea fits one sentence:

When [trigger], [watch / change / block] [what], show it in [place], using [data].

Examples from this repo:

| Mod | When | Move | Show it in | Using | | :- | :- | :- | :- | :- | | status-band | a turn starts, a tool runs, a turn ends | watch | the band above the prompt | the tool name and a clock | | panel-with-buttons | Claude edits a file | watch | a pane, opened by /edits | the file path | | guard | Claude runs a risky shell command | block (ask first) | the question dialog | the command text | | slash-command | you type /plain-recap | answer | the transcript | the conversation and a small model | | dashboard | every 60 seconds | watch | a pane, opened by /dashboard | a JSON URL |

Write your idea in that shape before you start. It tells you which event to hook, which move to make, and which surface to draw on.

Cheat sheet

Six places a mod can show something

| Place | How | Notes | | :- | :- | :- | | Band above the prompt | ui.render hook on { component: 'AbovePrompt' } | Always there, shared by every mod. Terminal and Desktop. | | Pane | $.ui.open({ id }), then a ui.render hook on { component: 'Pane' } | A sidebar in a wide fullscreen terminal, otherwise a box above the prompt. | | Status line | $.ui.status('text') | One line under the prompt, shown as ⚠ your-mod: text. | | Toast | $.ui.toast('text') | Pops up at the top right for 4 seconds. | | Transcript line | $.ui.log('text') | A dim line in the conversation. Claude does not read it. | | Built-in parts | ui.render hook on Spinner, ToolUse, AssistantMessage, and others | Restyle or replace what Claude Code already draws. The permission prompt is off limits. |

Eight events worth knowing

| Event | Fires when | | :- | :- | | session.start | The session starts, and again each time the mod reloads. Register commands and start timers here. | | prompt.submit | You send a prompt. Rewrite it, add hidden context, or drop it. | | turn.start | Claude starts working on your prompt. | | tool.call | Claude is about to use a tool (read, edit, run a command). Watch, change, or refuse it. | | turn.complete | Claude finished (or you interrupted). Has the answer, the duration, and token usage. | | command.run | Someone runs a slash command. Answer your own commands here. | | ui.render | Claude Code is about to draw a place on screen. Return what to draw. | | classic.<Name> | Any settings-hook event, such as classic.Stop or classic.PermissionRequest. |

Three moves

Every hook gets ($, e, next): $ is what the mod can call, e is the event, and next passes the event along.

| Move | Code | Example | | :- | :- | :- | | Observe | return next(e) | Count tool calls and let them run. | | Rewrite | return next({ ...e, text: 'new text' }) | Trim a prompt before it is sent. | | Answer | return { deny: 'reason' } (no next) | Refuse a command. Claude reads the reason. |

Try the templates

Clone this repo, then pick a mod.

In the terminal, load one or more mods for a single session:

claude --plugin-dir ./mods/status-band
claude --plugin-dir ./mods/guard --plugin-dir ./mods/slash-command

In the Desktop app (Code tab), where you can't pass flags, add the folders to the env block of ~/.claude/settings.json. Use absolute paths, separated by : (or ; on Windows), then start a new session:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-mods-starter/mods/status-band:/Users/you/claude-mods-starter/mods/dashboard"
  }
}

As an install, once this repo is on GitHub:

/plugin marketplace add carlvellotti/mods-starter
/plugin install status-band@mods-starter

To check that a mod loaded, run /plugin and open the Installed tab. A line such as 1 mod active · status-band should name it.

What each template does once loaded:

| Mod | Try this | | :- | :- | | status-band | Ask Claude anything that uses tools. The band reads Working 12s Reading README.md, then Waiting on you 41s Done when Claude finishes. In the terminal, a permission prompt covers the band while it is open. | | panel-with-buttons | Ask Claude to edit a file, then type /edits. Press c to clear, x or Esc to close. | | guard | Ask Claude to run rm -rf some-folder. You get a Proceed / Cancel question first. | | slash-command | After a few turns, type /plain-recap, or /plain-recap focus on open risks. | | dashboard | Type /dashboard. Change the URL and fields in /config (or under pluginConfigs in settings). |

Ask Claude to build one

You don't have to write code. Claude Code has a built-in plugin-authoring skill for this.

  1. Describe it. In a session, say what you want using the formula, for example make a mod that shows the current git branch above the prompt. Typing /plugin-authoring first loads the skill explicitly. PROMPTS.md has 12 prompts to copy.
  2. Approve. Claude writes the mod to ~/.claude/dev-mods/<session-id>/<mod-name>/. You approve each file (that folder is protected), and on the first file Claude Code asks Enable hot reloading for this session? Pick Enable for this session. The mod loads when Claude's turn ends.
  3. Iterate. Try it, then tell Claude what to change. It reloads at the end of each turn that edits it.
  4. Keep it. That folder is deleted after cleanupPeriodDays. Copy the mod somewhere of your own, such as ~/mods/git-branch, and load it with claude --plugin-dir ~/mods/git-branch.

To change a template instead, copy its folder, rename name in its .claude-plugin/plugin.json, start claude --plugin-dir ./mods/<your-copy>, and ask Claude for the change. Names must not start with claude- or look like Anthropic's own, or validation fails.

Check and test

claude plugin validate mods/guard        # what the mod hooks and calls, and any errors
claude plugin validate --strict mods/guard
claude plugin test mods/guard            # runs mods/guard/tests/*.test.ts, no session needed

Read the hooks: and calls: lines that validate prints. They are the mod's ingredient list: every event it listens to and every capability it uses ($.http.fetch, $.process.run, $.fs.write, and so on).

If a mod does nothing:

  • Run claude plugin validate on its folder and fix what it reports.
  • Start with claude --debug and search the log for the mod's name.
  • A message containing the rollout switch served off means mods are turned off for your account from Anthropic's side. No local setting changes that.

Safety

  • Mods are not sandboxed. A mod runs with your permissions. It can read and write your files, run programs, make network requests, read secrets in your environment, see every prompt and tool call, submit prompts as you, approve tool calls, and spend your usage. Turning on Claude Code's sandbox does not contain a mod.
  • Read the code before you install a mod, including these. Each template here is under about 100 lines on purpose. Run claude plugin validate and read the calls: line.
  • Install only from people and marketplaces you trust. A marketplace can update a mod after you install it.
  • The guard is a seatbelt, not a lock. It matches command text, so other spellings get through. Keep real protections (backups, branch protection, database permissions).
  • To turn mods off, disable them in /plugin, or start with claude --safe-mode.

Layout

.claude-plugin/marketplace.json   lists the five mods so /plugin can install them
mods/<name>/
  .claude-plugin/plugin.json      the plugin's name, version, description, settings
  hooks/hooks.json                points at the code file
  hooks/register.ts(x)            the mod itself
  tests/*.test.ts                 tests for `claude plugin test`
PROMPTS.md                        prompts to paste into Claude Code

Further reading

Source 1 files
hooks/register.tsx 101 lines
1// dashboard: a /dashboard pane that fetches a JSON URL every minute and
2// shows a few numbers from it.
3//
4// Pattern: FETCH outside data on a timer ($.clock.every + $.http.fetch) and
5// SHOW it in a pane. The URL and fields come from settings (userConfig in
6// plugin.json), which you can change in /config.
7// Works in the terminal and in the Desktop app's Code tab.
8
9import type { EngineInterface, Register, Timer } from 'claude-code'
10
11const PANE = 'dashboard'
12const EVERY_MS = 60_000 // once a minute. GitHub allows 60 unsigned requests an hour.
13
14// What the pane shows. These reset when the mod reloads.
15let rows: { label: string; value: string }[] = []
16let note = 'Loading...'
17let timer: Timer | undefined
18
19// Stop the once-a-minute timer, if one is running.
20function stopPolling() {
21  timer?.cancel()
22  timer = undefined
23}
24
25// Fetch the URL once and pick out the fields. Declared at the top level so it
26// can take $ (the validator allows that for top-level functions).
27async function refresh($: EngineInterface, url: string, fields: string[]) {
28  try {
29    const res = await $.http.fetch(url, { headers: { accept: 'application/json', 'user-agent': 'mods-starter' } })
30    if (!res.ok) throw new Error('the server answered ' + res.status)
31    const data = JSON.parse(res.text)
32    rows = fields.map(field => {
33      // "owner.id" reads data.owner.id
34      const value = field.split('.').reduce((obj: any, key) => obj?.[key], data)
35      const label = field.replace(/[._]/g, ' ')
36      return { label, value: typeof value === 'number' ? value.toLocaleString('en-US') : String(value ?? '-') }
37    })
38    note = 'Updated ' + new Date(await $.clock.now()).toTimeString().slice(0, 5)
39  } catch (error) {
40    note = 'Could not load: ' + (error instanceof Error ? error.message : String(error))
41  }
42  $.ui.invalidate('ui.render')
43}
44
45export const register: Register = (on, options) => {
46  // Settings from plugin.json's userConfig, with the defaults filled in.
47  const url = String(options.url)
48  const fields = String(options.fields).split(',').map(f => f.trim()).filter(Boolean)
49
50  on('session.start', async ($, e, next) => {
51    await $.command.register({ name: 'dashboard', description: 'Open the dashboard pane', immediate: true })
52    return next(e)
53  })
54
55  // /dashboard opens the pane and starts polling (once; a second /dashboard just reopens it).
56  on('command.run', { command: 'dashboard' }, async $ => {
57    await $.ui.open({ id: PANE, title: 'Dashboard', focus: true, closeOnEscape: true })
58    if (timer === undefined) {
59      timer = $.clock.every(EVERY_MS, () => refresh($, url, fields))
60      await refresh($, url, fields)
61    }
62    return {}
63  })
64
65  // You closed the pane with Esc or its close mark: stop polling.
66  on('ui.close', { id: PANE }, async ($, e, next) => {
67    stopPolling()
68    return next(e)
69  })
70
71  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
72    const { Box, Text, Button } = $.ui.resolve(e)
73    return (
74      <Box flexDirection="column">
75        <Text dimColor wrap="truncate-middle">{url}</Text>
76        {rows.map(row => (
77          <Box flexDirection="row" columnGap={1}>
78            <Text bold>{row.value}</Text>
79            <Text>{row.label}</Text>
80          </Box>
81        ))}
82        <Text dimColor>{note}</Text>
83        <Box flexDirection="row" columnGap={2}>
84          <Button key="refresh" label="Refresh" hotkey="r" plain onPress={() => refresh($, url, fields)} />
85          <Button
86            key="close"
87            label="Close"
88            hotkey="x"
89            plain
90            role="dismiss"
91            onPress={() => {
92              stopPolling() // our own $.ui.close skips our own ui.close hook, so stop here too
93              return $.ui.close({ id: PANE })
94            }}
95          />
96        </Box>
97      </Box>
98    )
99  })
100}
101