Adom Hydrogen
Public Made by Adomby adom
The whole Adom experience as one signed native app: your editor, your AI agent, and the full electronics design toolchain, running locally on your own machine.
Title-Bar Widget SDK — design spec (draft for review)
HD Title-Bar Widget SDK — Design Spec
Status: Draft for review · Owner: [email protected] · Date: 2026-07-23 Audience: hydrogen-desktop repo maintainers (host-side landing) + widget authors
1. Motivation
HD's title bar already ships one built-in widget — the CPU/RAM .resource-bars
block hardcoded in EditorNav.svelte's pollStats() loop. People want to build
their own (Noah's Claude-token meter, a hands-free voice mode, clocks, build
status, deploy buttons, …).
Today the only way to add one is to inject JS into the shell CDP target via
POST /eval-in (see the hd-eval skill — it literally lists "a usage meter in
the top bar" as its use case). That works but is a dead end for a platform:
- Imperative & unsandboxed — a widget runs with full shell privileges; a bug can take down HD's UI.
- Ephemeral — injected DOM vanishes on reload; no lifecycle.
- No contract — every author reinvents polling, rendering, teardown, and reaches into private Svelte internals that drift between builds.
- Undistributable — there's no install/update/remove story.
This SDK turns the title bar into a first-class, sandboxed, declarative widget
slot, reusing primitives HD already exposes. It does not invent new transport —
it wraps GET /container-stats, the SSE channel, /ui/invoke, and
/claude/conversations behind a stable window.hd bridge.
2. Goals / Non-goals
Goals
- A widget is a small web bundle + manifest, shipped and installed like an Adom app.
- Widgets render in a reserved title-bar zone; each is isolated in a sandboxed iframe.
- A stable, versioned
window.hdbridge is the only supported API surface. - Explicit, install-time permission grants (mic, network, Claude read/write).
- Distribution via the existing wiki registry (
adom-wiki); no HD rebuild to add a widget. - Existing
eval-inadd-ons keep working as an unsupported escape hatch.
Non-goals (v1)
- Widgets outside the title bar (tab bar, setup panel, status bar) — future slots.
- Native (Rust/Tauri) widget code — v1 is web bundles only.
- A visual widget builder — authors write HTML/JS/CSS (app-creator applies).
- Inter-widget messaging — deferred.
3. Concepts
| Term | Meaning |
|---|---|
| Widget | A sandboxed web bundle pinned to a title-bar slot, ~36px tall. |
| Manifest | widget.json declaring identity, slot, size, permissions, entry. |
| Slot | A reserved render zone. v1 ships one: title-bar (right cluster, left of the profile avatar). |
| Host | HD-side TitleBarWidgetHost.svelte — renders the zone, mounts iframes, routes the bridge, enforces permissions. |
| Bridge | The window.hd object inside each widget iframe; all host access flows through it over postMessage. |
| Popover | An optional click-to-expand panel a widget can open below its chip (the .resource-bars tooltip pattern, promoted to interactive). |
4. Widget package
my-widget/
widget.json # manifest (required)
index.html # entry (required) — the chip UI
popover.html # optional expanded panel
widget.js # logic
widget.css
icon.svg # 20×20, used in the install/permissions UI
Published as a wiki package (adom-wiki pkg), same as an Adom app. HD installs it
into a scanned widgets dir (see §8).
4.1 widget.json
{
"schema": 1,
"id": "inc.adom.token-meter", // reverse-DNS, globally unique
"name": "Claude Token Meter",
"version": "1.0.0",
"author": "noah",
"description": "Live Claude token usage in the title bar.",
"slot": "title-bar",
"entry": "index.html",
"popover": "popover.html", // optional
"size": {
"minWidth": 64,
"maxWidth": 220,
"height": 36 // clamped to the title-bar height by the host
},
"permissions": [ // see §6 — must be a subset of the allowed set
"container:read",
"claude:read"
],
"network": ["https://api.example.com"], // CSP connect-src allowlist; [] = none
"sdk": "^1.0" // bridge semver the widget targets
}
The host rejects a manifest that: uses an unknown slot, requests a permission
not in the registry, targets an incompatible sdk range, or omits id/entry.
5. The window.hd bridge (the SDK surface)
Injected into every widget iframe by the host. All methods are async and mediated
by postMessage; the widget never talks to the control API or CDP directly. Every
call is checked against the widget's granted permissions — an ungranted call
rejects with HDPermissionError.
interface HD {
readonly version: string; // bridge semver, e.g. "1.0.0"
readonly widget: { id: string; grants: string[] };
// --- Read state (permission: container:read / workspace:read) ---
state: {
container(): Promise<ContainerStats>; // == GET /container-stats
workspace(): Promise<WorkspaceState>;
};
// --- Events (subscribe; host pushes over the SSE feed) ---
events: {
on(topic: HDTopic, cb: (e: any) => void): () => void; // returns unsubscribe
};
// --- Claude (permission: claude:read / claude:write) ---
claude: {
conversations(): Promise<Conversation[]>; // GET /claude/conversations
active(): Promise<Conversation | null>;
send(contextId: number, text: string,
opts?: { submit?: boolean; animate?: boolean }): Promise<void>;
};
// --- Actions (no extra permission; scoped to the command bus) ---
ui: {
actions(): Promise<UiAction[]>; // GET /ui/actions
invoke(id: string): Promise<any>; // POST /ui/invoke
toast(msg: string, kind?: "info" | "warn" | "error"): void;
caption(text: string, seconds?: number): void;
notify(opts: NotifyOptions): Promise<void>;
};
// --- Own chip / popover ---
render: {
badge(state: { color?: "teal" | "warn" | "danger"; label?: string }): void;
openPopover(): void;
closePopover(): void;
resize(width: number): void; // within manifest min/max
};
// --- Persist widget settings (namespaced to this widget id) ---
storage: {
get(key: string): Promise<any>;
set(key: string, value: any): Promise<void>;
};
// --- Permission-gated hardware / speech ---
mic?: { // permission: mic
stream(): Promise<MediaStream>; // host brokers getUserMedia consent
};
tts?: { // permission: tts (Web Speech under the hood)
speak(text: string, opts?: { voice?: string; rate?: number }): void;
cancel(): void;
};
}
declare global { const hd: HD; }
5.1 Event topics (HDTopic)
| Topic | Payload | Permission | Source today |
|---|---|---|---|
container:stats |
ContainerStats (1s cadence) |
container:read |
/container-stats poll, pushed |
workspace:state |
{ state, badge } |
workspace:read |
existing 15s state poll |
claude:message |
{ contextId, role, text } (complete turn) |
claude:read |
NEW — see §7 |
claude:assistant_delta |
{ contextId, delta } (streaming) |
claude:read |
NEW — see §7 |
claude:conversations |
Conversation[] (tabs changed) |
claude:read |
/claude/conversations |
The host is the single SSE subscriber; it fans events out to widgets over the bridge, filtered by each widget's grants. Widgets never open their own SSE.
6. Permissions & security
Isolation. Each widget mounts in <iframe sandbox="allow-scripts"> with a
per-widget CSP: default-src 'self'; connect-src <manifest.network>; script-src 'self'. No allow-same-origin with the shell — the widget cannot touch HD's DOM,
localStorage, or other widgets. The only channel out is the bridge's postMessage.
Permission registry (host-enforced allowlist; a manifest may only request these):
| Permission | Grants | Consent |
|---|---|---|
container:read |
container stats + events | install-time |
workspace:read |
workspace state + events | install-time |
claude:read |
read conversations + response events | install-time (elevated) |
claude:write |
inject/submit prompts into a conversation | install-time (elevated) |
mic |
microphone stream | install-time + OS prompt on first use |
tts |
speech synthesis output | install-time |
network |
connect-src to manifest hosts |
install-time |
Install shows a permission sheet (reuse HD's existing consent UX — browser-picker /
sharing-request style). claude:write, mic, and network are "elevated" and
visually flagged. Grants are revocable in a Settings → Widgets panel, which also
lists installed widgets, per-widget enable toggle, and a "remove" action.
Failure containment. A widget iframe that throws, hangs, or exceeds a CPU/memory
watchdog is unmounted and shown as a greyed error chip with a "reload" affordance —
it can never freeze the title bar (the current pollStats() risk).
7. The one new HD-core primitive: a Claude response event stream
Everything above except claude:message / claude:assistant_delta maps to APIs HD
already has. The voice widget's output direction (read responses aloud) needs a
structured feed of assistant text. Today that only exists as (a) the Claude webview
DOM (eval-in target:claude, brittle) or (b) the on-disk ~/.claude/projects/*.jsonl
session log (tailing, laggy). Neither is a supported contract.
Proposal: HD emits, on its existing SSE channel, per active conversation:
claude:assistant_delta{ contextId, delta }as tokens stream, andclaude:message{ contextId, role, text }on turn completion.
Source options for the HD team (pick per what's cleanest in-repo):
- Tap the Claude webview's render/stream in the
claudeCDP context and forward deltas — reuses the surfacehd-eval/hd-claude-managementalready attach to. - Tail the matched
*.jsonlsession log (claude-management already maps a conversation → its GUID) and emit on append.
This single addition also benefits captions, logging, and remote-control mirrors — it's not voice-specific. It is the only hard dependency the voice widget places on HD core.
8. Distribution & lifecycle
- Publish:
adom-wiki pkgpublishes the widget bundle (same flow as an Adom app; see theadom-wiki/app-creatorskills). - Install: from a Settings → Widgets "Add" flow or
adom-wiki, HD unpacks into a scanned dir (proposed~/.adom/hd-widgets/<id>/), reads the manifest, shows the permission sheet, and mounts on grant. - Update: semver via the wiki; host re-shows the permission sheet only if the new version requests additional permissions.
- Remove / disable: Settings → Widgets; unmount + delete dir.
- Registry file:
~/.adom/hd-widgets/registry.jsonlists installed widgets, order in the slot, enabled state, and granted permissions. HD reads it on launch.
9. Reference widgets
9.1 Token/usage meter (Noah's) — buildable on existing primitives
permissions: ["container:read", "claude:read"],network: [...]if it hits an API.- Subscribe
claude:message(or poll a usage endpoint) →hd.render.badge()with teal/warn/danger by threshold (mirrors the.resource-bar-fillcolor rules). - Click →
hd.render.openPopover()showing the breakdown. No HD-core change.
9.2 Hands-free voice mode — one new primitive
permissions: ["mic", "tts", "claude:read", "claude:write"].- Input:
hd.mic.stream()→ STT (Web SpeechSpeechRecognition, or Whisper vianetwork) →hd.claude.send(active.contextId, transcript, { submit: true }). This path is fully supported by the existing/claude/conversationsinject API. - Output:
hd.events.on('claude:assistant_delta', e => hd.tts.speak(e.delta))(buffer to sentence boundaries). Depends on §7. - Chip shows a mic state (idle / listening / speaking); popover has voice + rate
settings persisted via
hd.storage.
10. HD-core work checklist (for the hydrogen-desktop repo)
Nothing below lands in the workspace container — it's all host-side:
TitleBarWidgetHost.svelte— reserved slot inEditorNav.svelte, left of the profile avatar; renders N sandboxed iframes from the registry, in order.- Bridge router —
postMessage↔ control-API/SSE broker; injectswindow.hd; enforces per-widget grants; versioned (hd.version). - Manifest loader + validator — schema §4.1, permission-registry check,
sdksemver compat. - Permission sheet + Settings → Widgets panel — consent UX, revoke, reorder, enable/disable, remove.
- SSE event additions —
claude:message,claude:assistant_delta(§7); container/workspace events (may already exist as polls — promote to pushed). - Registry + scanned widgets dir —
~/.adom/hd-widgets/+registry.json. - Watchdog — unmount misbehaving iframes; error-chip fallback.
/ui/actionsentries —widgets.settings.open,widget.<id>.reloadon the bus.
11. Migration from eval-in
eval-in shell injection stays as an unsupported escape hatch for one-offs and
prototyping (the hd-eval skill keeps documenting it). The SDK is the supported path;
the token-meter reference widget should be shipped as the worked example that replaces
the eval-in recipe. No breaking change to eval-in.
12. Open questions
- Bridge transport: raw
postMessagevs a thin RPC lib (Comlink-style). Leaning hand-rolled RPC to avoid a dep and keep the surface auditable. - Overflow: how many widgets fit before the slot scrolls/collapses into an overflow "•••" menu? Proposed: collapse least-recently-interacted into overflow.
- Web-Hydrogen parity: the slot + bridge are HD-desktop-only at first (like the command bus). Should the bridge degrade gracefully in web-Hydrogen (stats yes, mic/tts via browser APIs, Claude events no)? Recommend: yes, feature-detect.
- Signing/trust: do third-party widgets need wiki signing / a vouch before install,
given
claude:write+mic? Recommend: reuseadom-wikipackage signing and flag unsigned widgets in the permission sheet. - Popover vs floating window: popover (in-shell) for v1; revisit floating-window
widgets later (see the
floating-windowsskill).
Appendix A — What already exists vs. what's new
| Capability | Exists today | New for SDK |
|---|---|---|
| Container stats | GET /container-stats |
push as container:stats event |
| Workspace state | 15s poll | push as workspace:state event |
| Command bus | /ui/actions + /ui/invoke |
expose via hd.ui |
| Claude conversations (read/inject) | /claude/conversations |
expose via hd.claude |
| Claude response text stream | ✗ (DOM/jsonl scrape only) | claude:message / claude:assistant_delta (§7) |
| Toasts / captions / notify | control API | expose via hd.ui |
| Sandboxed web bundle + wiki distribution | Adom app model | reuse for widgets |
| Title-bar render slot + bridge + permissions | ✗ (eval-in hack) | TitleBarWidgetHost + window.hd + registry |
0 Replies
Log in to reply.