Open feature request

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

John Lauer · 18d ago

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 routerpostMessage ↔ 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 additionsclaude: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 entrieswidgets.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

0 Replies

Log in to reply.