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

A bar above the prompt with one live value from a web service you choose, such as the status of a deploy.
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.
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.
/config, then the api-status entry).https:// (http:// is accepted only for your own computer, localhost).{"data": {"deploy": {"state": "ok"}}} you would write data.deploy.state.| Key | What it does |
|---|---|
r | Refresh now |
Tab then Enter | Reach the "hide" button and press it |
| Type | What it does |
|---|---|
/api-status | Refresh and show the value as text (also brings the bar back if it was hidden) |
Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.
| Setting | What it means | Default |
|---|---|---|
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 |
You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.
_claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.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").
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.
hooks/register.tsx 118 lines1import { 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}
118hooks/api.ts 51 lines1// 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}
51hooks/i18n.ts 58 lines1// 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}
58types/index.d.ts 12 lines1export 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