Closed feature request

Title-Bar Widget SDK — design spec (draft for review)

John Lauer · 3mo ago ·closed by John Lauer

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.hd bridge 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-in add-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, and
  • claude:message { contextId, role, text } on turn completion.

Source options for the HD team (pick per what's cleanest in-repo):

  1. Tap the Claude webview's render/stream in the claude CDP context and forward deltas — reuses the surface hd-eval/hd-claude-management already attach to.
  2. Tail the matched *.jsonl session 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 pkg publishes the widget bundle (same flow as an Adom app; see the adom-wiki / app-creator skills).
  • 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.json lists 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-fill color 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 Speech SpeechRecognition, or Whisper via network) → hd.claude.send(active.contextId, transcript, { submit: true }). This path is fully supported by the existing /claude/conversations inject 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:

  1. TitleBarWidgetHost.svelte — reserved slot in EditorNav.svelte, left of the profile avatar; renders N sandboxed iframes from the registry, in order.
  2. Bridge router — postMessage ↔ control-API/SSE broker; injects window.hd; enforces per-widget grants; versioned (hd.version).
  3. Manifest loader + validator — schema §4.1, permission-registry check, sdk semver compat.
  4. Permission sheet + Settings → Widgets panel — consent UX, revoke, reorder, enable/disable, remove.
  5. SSE event additions — claude:message, claude:assistant_delta (§7); container/workspace events (may already exist as polls — promote to pushed).
  6. Registry + scanned widgets dir — ~/.adom/hd-widgets/ + registry.json.
  7. Watchdog — unmount misbehaving iframes; error-chip fallback.
  8. /ui/actions entries — widgets.settings.open, widget.<id>.reload on 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 postMessage vs 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: reuse adom-wiki package 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-windows skill).

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

3 Replies

John Lauer · 1mo ago

Reviewed with John today and it is a go, with a name: hydrogen-widget (wiki-widget stays for component pages, and the prefix leaves room for kicad-widget and friends later). Scope:

  • A "Widgets" pulldown in the title bar, listing widgets from a curated list on hydrogen-bootstrap plus any repo in the user's checkout that carries the facet, grouped like the dock and the Project workspaces menu, every entry AI-controllable through the control API.
  • A widget is a facet a wiki repo can carry next to its dock manifest and its project workspace file, or a standalone widget repo.
  • Opening one mounts a sandboxed iframe in the title-bar slot with the orbital loading placeholder, ah installs the widget's package into the container with adom-wiki, launches its binary, and hands it the slot identity; the binary links to the slot and pushes its own content, the same handshake a dock app uses to take over its webview.
  • The SDK is the manifest plus that link contract, documented as a skill like the dock SDK.
  • First samples: a Claude usage widget (5 h session, weekly, Fable 5, as percent left), a Codex usage widget (global percent left), and a minimal clock. The source of the usage numbers gets verified before anything is built on it.

Scheduled after the small fixes and #49.

John Lauer · 1mo ago

Built and shipped as hydrogen-widget, in Hydrogen 1.0.393 with adom-vscode 1.1.61 and hydrogen-bootstrap 0.5.33. Closing.

What landed, against John's review of this spec today:

  • A Widgets pulldown in the title bar (the grid icon left of the bell) listing widgets from a curated widgets/widgets.json on adom/hydrogen-bootstrap plus any repo in your checkout with a hydrogen-widget.json, grouped and ordered like the dock.
  • Opening one mounts a sandboxed iframe slot with the dock's orbital placeholder, installs the widget's package into the workspace with adom-wiki, launches its command with HYDROGEN_WIDGET_ID / HYDROGEN_WIDGET_PORT / HYDROGEN_WIDGET_SLOT / HYDROGEN_PROXY_PORT in the environment, waits for its page through the subdomain port proxy, and hands the slot over. The widget's own page then owns the slot and pushes what it wants; POST /widgets/navigate is the explicit takeover a binary can call. Same handshake a dock app uses to take over its webview.
  • Open widgets are remembered per user (hydrogen.widgets_open) and come back on the next start.
  • A widget is a facet a wiki repo carries: hydrogen-widget.json at the root, alone or next to the dock manifest and the project workspace; a repo may ship several ({"widgets":[...]}).
  • Control API: GET /widgets, POST /widgets/{refresh,open,close,navigate,menu}, GET /widgets/status; UI bus widgets.menu.open/close. Nothing needs a mouse.
  • The SDK is a skill, hydrogen-widget (in adom/hydrogen-bootstrap, so every AI has it), with the manifest, the launch contract and the fifty-line clock to copy.
  • Three samples: claude-usage (5-hour, weekly and the model bucket, as percent used, from api.anthropic.com/api/oauth/usage with the Claude Code sign-in), codex-usage (the plan's rate-limit window from chatgpt.com/backend-api/wham/usage with the Codex sign-in), and clock. The usage numbers' sources were verified with real calls before anything was built on them (Claude answered 31 percent / 45 percent, Codex 0 percent of the weekly window). claude-usage and codex-usage are a facet of adom/adom-vscode (their scripts ship with that package); clock is a facet of adom/hydrogen-bootstrap.

Proof on arav-rog through the API alone: refresh listed the three, open returned ok with a live URL for each, status showed all three live, and the screenshot attached shows the title bar with Claude at 43 percent / 47 percent, the clock, and Codex saying there is no Codex sign-in on that box.

Docs: the Hydrogen page has a Widgets row in the feature guide, a section, and a sub-readme with the screenshot: https://wiki.adom.inc/adom/hydrogen/files/widgets/README.md

title-bar-widgets

John Lauer · 1mo ago

Follow-up after John's live review (2026-09-14), all shipped:

  • Hydrogen 1.0.403: a slot is now exactly as wide as its widget asks for. The page had been posting the right width all along and the store recorded it, but the slot's width expression read it inside a helper that Svelte does not track, so every slot stayed at the manifest guess (Claude 256 px compact, 175 px tall). That was the "too wide", "empty gray space" and "space left of the clock" in one bug. The posted width and the layout are now parameters of the expression.
  • adom-vscode 1.1.75 and hydrogen-bootstrap 0.5.48 (shared helper): the stacked layout is about a third as wide (short gauge bars, Claude with three rows is about 90 px); a widget with no mark no longer emits an empty mark span (the clock had 20 px of nothing left of the time); width is measured on a hidden max-content clone so the slot's own width never feeds back (the clock had asked for 361 px); the tall label clip applies to bar rows only; /preview?h=38 and /preview?h=20 follow the width the page asks for, so headless Chrome at three times scale gives crisp docs pictures; the per-widget pages carry those pictures for both layouts.
  • hydrogen-widget skill 1.5.1 documents the measurement rule and the preview route.

Pictures below are the real Title Bar on arav-rog after 1.0.403, full and compact.

title-bar-widgets

title-bar-compact

Log in to reply.