SLOPSHOPPER

api-status

Claude Code skill that builds, debugs, audits and packages Claude Code mods from a plain-language description

newbandcommandnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · api-status
› 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 › /api-status ⎿ api-status: No URL set. Configure it in /config → api-status. API No URL set. Configure it in /config → api-status. r: refresh hide (/api-status) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
API No URL set. Configure it in /config → api-status. r: refresh hide (/api-status)
README

api-status

Leia em português

A bar above the prompt with one live value from a web service you choose, such as the status of a deploy.

What is a mod?

Claude Code is Anthropic's assistant for working with code, in the terminal or in the Claude desktop app. A mod is a small add-on for it: it can add a panel, a bar above the box where you type, a new /command, or a rule that checks what Claude is about to do. This folder is one complete mod you can install and use as it is, or copy as a starting point.

What it does

Many services publish their status as data on a web address (an API). For example, a deploy service may answer {"status": "ok"}. This mod reads one value from that address and keeps it in sight above the prompt, so you do not have to open a browser to check. If the service needs a password-like token, the mod keeps it in Claude Code's secure storage.

What you see

  • A line above the prompt with the value and the time it was read: "API: ok at 14:32".
  • While reading: "◌ loading…". If the service cannot be reached: "✗ unavailable", followed by the last value that worked.
  • Two buttons: "refresh" and "hide".

How to use it

  1. Open the settings (/config, then the api-status entry).
  2. In Status URL, paste the address. It must start with https:// (http:// is accepted only for your own computer, localhost).
  3. In JSON field, write which value to show. For {"data": {"deploy": {"state": "ok"}}} you would write data.deploy.state.
  4. If the service needs a token, paste it in API token. It is stored securely and never shown.
  5. To refresh on its own, set Refresh every to 15 seconds or more. With 0 it refreshes only when you ask.

Keyboard shortcuts

KeyWhat it does
rRefresh now
Tab then EnterReach the "hide" button and press it

Commands

TypeWhat it does
/api-statusRefresh and show the value as text (also brings the bar back if it was hidden)

Settings

Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.

SettingWhat it meansDefault
url (Status URL)The address to read. Must start with https:// (http:// only for localhost).(empty)
field (JSON field)Which value of the answer to show, as a dotted path, like status or data.deploy.state.status
token (API token)Optional token sent to the service. Kept in secure storage, never shown.(secure storage)
refreshSeconds (Refresh every (seconds))Refresh on its own every N seconds (15 or more). 0 means only when you ask.0
language (Language)Language of the text the mod shows: en (English) or pt-BR (Brazilian Portuguese).en

Good to know

  • Never put a password or token inside the URL; the mod refuses it and asks you to use the token field.
  • If the URL is missing or wrong, the bar says exactly what to fix.
  • The minimum automatic interval is 15 seconds, so the service is not flooded with requests.

Install

You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.

  1. Copy this folder anywhere on your computer.
  2. Inside the copy, rename the folder _claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.
  3. Start Claude Code with the mod:
claude --plugin-dir path/to/api-status

To keep it installed for good, ask the claude-mod-forge skill to package it ("package this mod").

Check it

From the skill folder (skills/claude-mod-forge), this runs the manifest check, a strict type check and the 9 automated tests of this mod:

node scripts/check-mod.mjs examples/api-status

The tests cover the mod's logic and what it draws in the terminal and the desktop app. How it looks in your own window is worth a quick look in a real session.

For developers: the examples table lists the hooks and API calls this mod demonstrates.

Source 4 files
hooks/register.tsx 118 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ApiReading } from '../types'
5import { checkUrl, clock, pickField } from './api'
6import { strings } from './i18n'
7import type { Strings } from './i18n'
8
9const reading = atom({ plugin: 'api-status', key: 'reading' } as const, { kind: 'loading' })
10const isHidden = atom({ plugin: 'api-status', key: 'isHidden' } as const, false)
11
12type Config = { url: string; field: string; token: string }
13
14// One request. Never throws; never puts the token in any text, log, toast or state.
15async function refresh($: EngineInterface, config: Config, t: Strings) {
16  const target = checkUrl(config.url, t)
17  if ('error' in target) {
18    await update($, reading, () => ({ kind: 'unconfigured', reason: target.error }) satisfies ApiReading)
19    return
20  }
21  const previous = await read($, reading)
22  const last = previous.kind === 'ok' ? { lastValue: previous.value, lastAt: previous.at } : previous.kind === 'error' ? { lastValue: previous.lastValue, lastAt: previous.lastAt } : {}
23  const headers: Record<string, string> = { accept: 'application/json' }
24  if (config.token !== '') headers.authorization = `Bearer ${config.token}`
25  let next: ApiReading
26  try {
27    const response = await $.http.fetch(target.url, { method: 'GET', headers })
28    const at = await $.clock.now()
29    if (!response.ok) {
30      next = { kind: 'error', reason: `HTTP ${response.status}`, at, ...last }
31    } else {
32      const picked = pickField(response.text, config.field || 'status', t)
33      next = 'error' in picked ? { kind: 'error', reason: picked.error, at, ...last } : { kind: 'ok', value: picked.value, at }
34    }
35  } catch {
36    // Network failure, timeout (30 s), or a policy that refuses fetch. The reason text is ours, not the error's.
37    next = { kind: 'error', reason: t.unreachable, at: await $.clock.now(), ...last }
38  }
39  await update($, reading, () => next)
40}
41
42export const register: Register = (on, options) => {
43  const config: Config = {
44    url: String(options.url ?? ''),
45    field: String(options.field ?? 'status'),
46    token: String(options.token ?? ''),
47  }
48  const t = strings(options.language)
49  const seconds = Number(options.refreshSeconds ?? 0)
50  const interval = seconds > 0 ? Math.max(15, seconds) * 1000 : 0 // never poll faster than every 15 s
51
52  // The first session.start is awaited before the first prompt: no awaited network here. Register the
53  // command, then let the first fetch run on its own (the band shows "loading" until it lands).
54  on('session.start', async ($, e, next) => {
55    try {
56      await $.command.register({ name: 'api-status', description: t.commandDescription, immediate: true })
57    } catch {
58      // name taken: the band and its r button still work
59    }
60    void refresh($, config, t).catch(() => undefined)
61    if (interval > 0) $.clock.every(interval, () => refresh($, config, t))
62    return next(e)
63  })
64
65  on('command.run', { command: 'api-status' }, async $ => {
66    await update($, isHidden, () => false)
67    await refresh($, config, t)
68    const now = await read($, reading)
69    // The same information as text, for surfaces that draw nothing (VS Code chat, claude -p).
70    const text =
71      now.kind === 'ok' ? t.textOk(now.value, clock(now.at))
72      : now.kind === 'error' ? `${t.textError(now.reason)}${now.lastValue === undefined ? '' : t.textLast(now.lastValue)}`
73      : now.kind === 'unconfigured' ? now.reason
74      : t.loadingText
75    return { text }
76  })
77
78  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
79    const now = await read($, reading)
80    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
81    const { Box, Button, Text } = $.ui.resolve(e)
82
83    let status
84    if (now.kind === 'ok') {
85      status = <><Text color="success">●</Text><Text bold>{now.value}</Text><Text dimColor>{clock(now.at)}</Text></>
86    } else if (now.kind === 'error') {
87      // A fragment's children are RenderNode (element or string): `cond && <X/>` there yields a false,
88      // which strict tsc 5.9 rejects. Pick between two whole fragments instead.
89      status =
90        now.lastValue === undefined ? (
91          <>
92            <Text color="error">{t.unavailable}</Text>
93            <Text dimColor>{now.reason}</Text>
94          </>
95        ) : (
96          <>
97            <Text color="error">{t.unavailable}</Text>
98            <Text dimColor>{now.reason}</Text>
99            <Text dimColor>{t.last} {now.lastValue}{now.lastAt === undefined ? '' : ` ${t.at} ${clock(now.lastAt)}`}</Text>
100          </>
101        )
102    } else if (now.kind === 'unconfigured') {
103      status = <Text color="warning">{now.reason}</Text>
104    } else {
105      status = <Text color="warning">{t.loading}</Text>
106    }
107
108    return (
109      <Box gap={1} paddingX={1}>
110        <Text dimColor>API</Text>
111        {status}
112        <Button key="refresh" label={t.refresh} hotkey="r" plain dimColor onPress={() => refresh($, config, t)} />
113        <Button key="hide" label={t.hide} plain dimColor onPress={() => update($, isHidden, () => true)} />
114      </Box>
115    )
116  })
117}
118
hooks/api.ts 51 lines
1// Pure helpers for api-status. No $ here. Messages come from the i18n table (English by default).
2
3import { strings } from './i18n'
4import type { Strings } from './i18n'
5
6const EN = strings('en')
7
8// The host comes only from the user's config, never from prompts or tool output.
9// https everywhere; plain http only for localhost.
10export function checkUrl(raw: string, s: Strings = EN): { url: string } | { error: string } {
11  const text = raw.trim()
12  if (text === '') return { error: s.noUrl }
13  let parsed: URL
14  try {
15    parsed = new URL(text)
16  } catch {
17    return { error: s.badUrl }
18  }
19  const isLocal = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'
20  if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLocal)) {
21    return { error: s.needHttps }
22  }
23  if (parsed.username !== '' || parsed.password !== '') return { error: s.noCredentialsInUrl }
24  return { url: parsed.toString() }
25}
26
27// Reads a dotted path ("data.deploy.state") from a JSON body.
28export function pickField(body: string, path: string, s: Strings = EN): { value: string } | { error: string } {
29  let data: unknown
30  try {
31    data = JSON.parse(body)
32  } catch {
33    return { error: s.notJson }
34  }
35  let node: unknown = data
36  for (const key of path.split('.').filter(Boolean)) {
37    if (node === null || typeof node !== 'object' || !(key in (node as Record<string, unknown>))) {
38      return { error: s.fieldMissing(path) }
39    }
40    node = (node as Record<string, unknown>)[key]
41  }
42  if (node === null || typeof node === 'object') return { error: s.fieldNotValue(path) }
43  return { value: String(node).slice(0, 80) }
44}
45
46export function clock(ms: number): string {
47  const d = new Date(ms)
48  const pad = (n: number) => String(n).padStart(2, '0')
49  return `${pad(d.getHours())}:${pad(d.getMinutes())}`
50}
51
hooks/i18n.ts 58 lines
1// The text this mod shows, per language. Pure: no $ calls, so tests can cover it directly.
2// Anything other than a known language falls back to English.
3
4export type Language = 'en' | 'pt-BR'
5
6const en = {
7  // api.ts (URL and response checks)
8  noUrl: 'No URL set. Configure it in /config → api-status.',
9  badUrl: 'The configured URL is not valid.',
10  needHttps: 'Use an https:// URL (http:// is allowed only for localhost).',
11  noCredentialsInUrl: 'Put credentials in the token option, not in the URL.',
12  notJson: 'The response is not JSON.',
13  fieldMissing: (path: string) => `Field "${path}" not found in the response.`,
14  fieldNotValue: (path: string) => `Field "${path}" is not a single value.`,
15  // register.tsx
16  unreachable: 'unreachable',
17  commandDescription: 'Refresh the API status band (and show it if hidden)',
18  textOk: (value: string, at: string) => `API: ${value} (at ${at})`,
19  textError: (reason: string) => `API unavailable: ${reason}`,
20  textLast: (value: string) => ` · last ${value}`,
21  loadingText: 'Loading…',
22  unavailable: '✗ unavailable',
23  last: '· last',
24  at: 'at',
25  loading: '◌ loading…',
26  refresh: 'refresh',
27  hide: 'hide (/api-status)',
28}
29
30const ptBR: typeof en = {
31  noUrl: 'Nenhuma URL definida. Configure em /config → api-status.',
32  badUrl: 'A URL configurada não é válida.',
33  needHttps: 'Use uma URL https:// (http:// só é aceito para localhost).',
34  noCredentialsInUrl: 'Coloque as credenciais na opção token, não na URL.',
35  notJson: 'A resposta não é JSON.',
36  fieldMissing: (path: string) => `Campo "${path}" não encontrado na resposta.`,
37  fieldNotValue: (path: string) => `Campo "${path}" não é um valor único.`,
38  unreachable: 'inacessível',
39  commandDescription: 'Atualiza a faixa de status da API (e mostra se estiver oculta)',
40  textOk: (value: string, at: string) => `API: ${value} (às ${at})`,
41  textError: (reason: string) => `API indisponível: ${reason}`,
42  textLast: (value: string) => ` · último ${value}`,
43  loadingText: 'Carregando…',
44  unavailable: '✗ indisponível',
45  last: '· último',
46  at: 'às',
47  loading: '◌ carregando…',
48  refresh: 'atualizar',
49  hide: 'ocultar (/api-status)',
50}
51
52export type Strings = typeof en
53const TABLE: Record<Language, Strings> = { en, 'pt-BR': ptBR }
54
55export function strings(language: unknown): Strings {
56  return language === 'pt-BR' ? TABLE['pt-BR'] : TABLE.en
57}
58
types/index.d.ts 12 lines
1export type ApiReading =
2  | { kind: 'unconfigured'; reason: string }
3  | { kind: 'loading' }
4  | { kind: 'ok'; value: string; at: number }
5  | { kind: 'error'; reason: string; at: number; lastValue?: string; lastAt?: number }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'api-status': { reading: ApiReading; isHidden: boolean }
10  }
11}
12