Build a Cockpit app (a local React page)
SkillDev toolsGenerate an interactive local React app wired to the built-in cockpit bash SDK.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Build a Cockpit app (a local React page) skill
What this skill tells your AI
The instructions your AI receives, as published by surething-io/cockpit in skills/html/SKILL.md and read by ahel’s review.
A local application, not a one-off page: the preview injects a global window.cockpit
SDK — essentially the Bash tool exposed to the page, so buttons can curl, read/write files
and run scripts; data lands on disk inside the app directory and is there on reopen.
Zero build: React / Babel load from the same-origin /html-lib — no bundler, no deps, works offline.
1. Flow: align first, write only after confirmation (important)
Don't write files up front. Three steps:
- Align on the requirement — restate your understanding of the app (what it does, where data comes from, key features/interactions). Ask back only when the requirement is ambiguous or has several reasonable readings; if it's already clear, just give a short summary — don't interrogate.
- Tell the storage directory — resolve
<dir>/<name>/and tell the user exactly where it goes and what it's called; they may reject or change it:- You always pick
<name>from the request (lowercase kebab-case, matchingcockpit-name) — the user only describes the app, they don't name it. - The user gave a directory →
<the given directory>/<name>/; none given →<name>/under the current chat working directory (this session's cwd). - Every file of the app lives in that one directory, with data split into
cache/state/out/per section 4. Don't scatter files elsewhere or invent a.cockpit-apps.
- You always pick
- Write only after confirmation —
Writethe files once the user clearly agrees (confirm / start / go / "write it" — any affirmative). Until then, only discuss.
Even if the user already spelled out requirement and directory in the /html call, still stop
once to present "understanding + directory + a 'shall I start writing?'" and wait — but keep that
round lightweight, don't re-interrogate.
2. Skeleton: three files, copy them
index.html — use this fixed shell, don't improvise:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Page title</title>
<meta name="cockpit-name" content="short-name"> <!-- unique short name for /name; letters/digits/-_ -->
<meta name="description" content="one line about the page">
<meta name="cockpit-icon" content="🔍"> <!-- emoji or icon url, optional -->
<meta name="cockpit-theme" content="auto"> <!-- light/dark toggle, see section 5 -->
<link rel="stylesheet" href="/html-lib/theme.css">
</head>
<body>
<div id="root"></div>
<script src="/html-lib/react.production.min.js"></script>
<script src="/html-lib/react-dom.production.min.js"></script>
<script src="/html-lib/babel.min.js"></script>
<script>
(async () => {
const src = await (await fetch('./app.jsx')).text();
// MUST use classic runtime: the default automatic runtime injects an import of
// react/jsx-runtime, which — appended as a plain script — throws
// "Cannot use import statement outside a module".
const { code } = Babel.transform(src, { presets: [['react', { runtime: 'classic' }]] });
const s = document.createElement('script'); s.textContent = code; document.body.appendChild(s);
})();
</script>
</body>
</html>
All four meta lines matter: the HTML panel renders cards from them, and the console opens the
app via cockpit-name (/name).
app.jsx — global React, render at the end. Opening reads the cache; only Refresh goes out:
const { useState, useEffect, useCallback } = React;
function App() {
const [data, setData] = useState(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
const load = useCallback(async (refresh) => {
setBusy(true); setErr(null);
try {
const arg = btoa(JSON.stringify({ refresh: !!refresh })); // structured args as base64
const { stdout, stderr, exitCode } = await cockpit.bash(`node ./api.mjs ${arg}`);
if (exitCode !== 0) throw new Error(stderr || 'script failed'); // ran, but failed
const d = JSON.parse(stdout);
if (d.error && !d.stale) throw new Error(d.error);
setData(d);
if (d.stale) setErr(d.error); // old data is still shown
} catch (e) {
setErr(e.message); // a throw = spawn/infra failure. Keep existing data on screen
} finally { setBusy(false); }
}, []);
useEffect(() => { load(false); }, [load]); // on open: cache only, zero requests
if (!data) return <div>{err ?? 'Loading…'}</div>;
return (
<div>
<span>{data.cached ? 'cached' : 'updated'} · {new Date(data.fetchedAt).toLocaleString()}</span>
<button disabled={busy} onClick={() => load(true)}>{busy ? 'Refreshing…' : 'Refresh'}</button>
{err && <em>Refresh failed, showing older data: {err}</em>}
{/* render data */}
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
api.mjs — the backend handler: fetching, parsing and persistence live here; one JSON on stdout:
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const DIR = dirname(fileURLToPath(import.meta.url));
const CACHE = join(DIR, 'cache/data.json');
const VERSION = 1; // bump whenever the payload shape changes, or old cache feeds a new UI
const args = JSON.parse(Buffer.from(process.argv[2] || '', 'base64').toString() || '{}');
function readCache() {
if (!existsSync(CACHE)) return null;
const c = JSON.parse(readFileSync(CACHE, 'utf8'));
return c.v === VERSION ? c : null; // version mismatch = void it, act as if empty
}
function writeCache(payload) {
mkdirSync(dirname(CACHE), { recursive: true });
const tmp = `${CACHE}.${process.pid}.tmp`; // atomic: a kill mid-write leaves no half JSON
writeFileSync(tmp, JSON.stringify({ v: VERSION, fetchedAt: new Date().toISOString(), payload }));
renameSync(tmp, CACHE);
}
try {
const hit = args.refresh ? null : readCache();
if (hit) {
process.stdout.write(JSON.stringify({ ...hit.payload, cached: true, fetchedAt: hit.fetchedAt }));
} else {
const payload = await fetchFresh(args); // ← your fetching logic (curl / DB / commands)
writeCache(payload);
process.stdout.write(JSON.stringify({ ...payload, cached: false, fetchedAt: new Date().toISOString() }));
}
} catch (e) {
const hit = readCache(); // failed refresh: fall back to the old cache, labelled
process.stdout.write(JSON.stringify(hit
? { ...hit.payload, cached: true, stale: true, error: e.message, fetchedAt: hit.fetchedAt }
: { error: e.message }));
}
- When one command is enough and the data isn't worth keeping (just viewing current state), skip
api.mjsand callcockpit.bash("curl -s …")directly. The moment data should survive a reopen, use this skeleton rather than inventing your own. - Same for other languages:
python3 ./api.py/bash ./api.sh(explicit interpreter avoidschmod +x); probenode/python3withcommand -vif unsure and fall back to shell. - The cache key must include every parameter that changes the result (time range, fetch depth
…): use
cache/<param>.jsonor bucket by key inside the file — otherwise a 7-day cache renders in the 30-day view.
3. Contract: window.cockpit
The SDK is ready on load — no library to import:
cockpit.cwd: string— directory of the current file; relative-path commands run here.cockpit.bash(command, opts?)— run one bash command (commandis a raw shell string):- Foreground (default, short/discrete) →
Promise<{ stdout, stderr, exitCode }>;awaitfor the full output at once. - Background (
opts.background: true, long/live) →{ kill() }, streaming via callbacks. Large/continuous output must use background — foreground buffers it all in memory.
- Foreground (default, short/discrete) →
const h = cockpit.bash("npm run build", {
background: true,
onOutput: c => append(c),
onStderr: c => append(c),
onExit: code => append('\n[exit ' + code + ']'), // command finished; non-zero = failed
onError: msg => append('\n[error ' + msg + ']'), // spawn / infra failure
});
// h.kill() on unmount or when the user aborts
Rules every page must follow:
- Data only through
cockpit.bash(curlto read,curl -X POSTto write, or read/write files) — neverfetch(externalURL): the same-origin sandbox blocks it via CORS. (fetch('./app.jsx')and other same-directory relative requests are fine.) - Handle both failure modes: a throw caught by
try/catch= spawn/infra failure (bad path, dropped connection); no throw butexitCode !== 0= the command ran and failed — showstderror the error body in stdout, don't just say "failed". Background:onError(infra) + a non-zerocodeinonExit(command). commandis a shell string: validate or escape any dynamic/user input before interpolating it (shell metacharacters inject); pass structured input as base64 (see the skeleton) instead of string-building; use parameterized statements for DB writes.- Resource refs: relative siblings (
./app.jsx,./style.css, images) and absolute CDN URLs load; root-relative (/assets/x.css) 404s — the server only hosts/html-lib/*and/apps/*.
4. Local data: three directories
Data must land on disk and be there on reopen — don't refetch everything on every open. All
persistence goes through api.mjs; inside the app directory, split it by "can this be rebuilt?"
into three directories instead of one flat pile:
cache/— rebuildable (API responses, raw dumps, derived indexes). Deleting it only costs time. Gitignore the whole directory.state/— NOT rebuildable (daily snapshots, hand-maintained lists). The source can't hand it to you twice; deleted means gone. Commit it.out/— artifacts for humans (reports, exports), date-named, kept.
The split only pays off through the git policy: directories separated but cache/ not ignored and
state/ not committed means nothing was separated.
What the skeleton already handles (versioning, atomic writes, the refresh flag, the fetch
timestamp, stale fallback) isn't repeated here. What's left is on you:
- Any growing directory needs a cap enforced in the script (keep N entries / prune past X MB). A cap that lives only in a comment is not a cap — unattended dump directories reach GB scale.
- Never write absolute paths into a file on disk (store paths relative to the app directory), or moving the directory voids the index; anything holding private data (emails …) is gitignored.
- Provide a clear/reset entry point (a
clear_cachemode inapi.mjs+ a button), or the user's only option is guessing which file is safe to delete in a file manager. - To use another app's credentials / helper scripts, reference them as
../<app>/and probe that they exist first; on failure give one actionable line ("log in again at xxx") rather than dumping the raw error.
5. Theme
Style with the Cockpit theme by default (index.html already links /html-lib/theme.css).
Fetch that file once before you pick any colour — it is the single source of truth for the
variables, so don't guess names from memory:
curl -fsS "{{BASE_URL}}/html-lib/theme.css"
:rootholds the light values,.darkthe dark ones. The raw scales (--slate-*/--teal-*/--red-*…) store HSL components, so translucency is justhsl(var(--green-9) / .12).- Take foregrounds in pairs (
--card/--card-foreground…), except--brand:--brand-foregroundis a tint of the same hue and hits only 1.5:1 on a--brandfill — usevar(--background)for text on a brand fill. - The
cockpit-thememeta gives the preview a floating light/dark toggle (top-right by default, draggable to any corner — so don't reserve top-right space for it); it follows the Cockpit host theme by default (outside Cockpit, the meta's auto/light/dark decides), and a manual toggle is remembered per app across reloads and overrides the host, or callcockpit.toggleTheme(). - Don't invent a palette or add Tailwind; add a small
<style>for app-specific bits.
Signals
- GitHub stars
- 36
- Forks
- 8
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
html-surething-io- Source
- github.com/surething-io/cockpit