Web UI Extensions

Want to add a panel to OpenClacky's web interface, drop a button into an existing spot, visualize some data, or give an agent its own custom face — without forking the whole frontend? A Web UI panel is one kind of extension-container contribution: declare contributes.panels in ext.yml and a .js file hooks into the official UI; when one extension crashes, it never takes down the others or the host frame.

Panels now ship inside an extension container — one ext.yml plus a panel script under ~/.clacky/ext/local/<id>/. This mechanism deliberately introduces no React / no build toolchain: write plain JS, declare it in the manifest, refresh.


Design Philosophy

Letting AI- or user-generated frontend code hook into a live interface carries two real risks:

  1. Crashes — an exception in one extension takes the whole page down (white screen), so the user can't even reach the official features.
  2. Conflicts — extensions overwrite each other, fight over the same DOM, or pollute global state.

This mechanism backstops both with three survival promises:

  • Isolation — every extension callback is wrapped in try/catch. A throwing extension degrades only its own slot to a placeholder (marked data-ext-status="crashed"); the host frame and sibling extensions are untouched.
  • Escape hatch — append ?pure=true to the URL at any time and all extensions go silent, returning you to a clean official UI. This is the ultimate fallback — no matter how badly an extension misbehaves, you're one query param away from a clean state.
  • Boundary — extensions reach the host only through the single Clacky.ext entry point. Controls like enable/disable and safe mode always live in the host frame, out of reach of any extension.

How It Works

An extension container declares the panels it contributes in ext.yml:

# ~/.clacky/ext/local/my-badge/ext.yml
id: my-badge
name: My Badge
version: "0.1.0"
contributes:
  panels:
    - id: badge
      view: panels/badge/view.js   # panel script (relative to container root)
      attach: ["*"]                # attach to all agents; or [coding, designer] to scope

When the web server starts, OpenClacky scans every container across the three source layers (builtin / installed / local), reads each ext.yml.contributes.panels, and injects the view scripts into the page. Which agents a panel mounts on is decided by attach or by the panels: of an agent referencing it (see below).

Each script is bracketed by a pair of crash-attribution markers, so even if it throws during load, the failure is pinned to the exact panel rather than a vague "page error." In ?pure=true mode, the server injects no extension scripts at all, and every Clacky.ext registration entry point becomes a no-op — a double guarantee that the escape hatch stays clean.

The skeleton from clacky ext new <id> comes with a runnable hello panel + backend — modifying it is the fastest path. Fields: ext.yml Manifest Reference.

What a Panel Script Looks Like

A panel script is plain JS — three capabilities, all on Clacky.ext:

// ~/.clacky/ext/local/my-badge/panels/badge/view.js

// 1. ui.mount(slot, renderFn, opts?) — inject UI into a named slot
Clacky.ext.ui.mount("header.right", (ctx) => {
  const span = document.createElement("span");
  span.textContent = "★";
  span.title = "My extension";
  return span;   // return a DOM node, an HTML string, or null (render nothing)
});

// 2. subscribe(event, handler) — observe core data changes (read-only)
Clacky.ext.subscribe("skills:changed", (payload) => {
  console.log("skills updated", payload.skills.length);
});

// 3. api.register(name, fn) — register a named data source others can resolve
Clacky.ext.api.register("my-metric", () => 42);

Does renderFn throw? The guard catches it, that slot degrades to a placeholder, and the rest of the page renders normally.

Named Slots

The host frame exposes a set of named slots (positions carrying a data-slot attribute), and extensions inject into them via ui.mount(slotName, ...). When several extensions mount the same slot, each renders independently and can't interfere — if one crashes, only it degrades.

Slots come in three groups:

1. Open areas — mount something brand new

Slot Position Typical use
header.left Top bar, left (next to the brand) Custom button, status indicator
header.right Top bar, right (next to share/theme) Badge, quick entry point
sidebar.nav Bottom of the sidebar nav Custom menu item → gateway to a new workspace
sidebar.footer Sidebar footer Small widget, status strip
main.workspace Main content area Mount an entirely new custom panel

2. Settings page — augment the official UI in place

Slot Position Typical use
settings.tabs Settings tab bar Add a custom settings tab (the button needs data-tab="<id>")
settings.body Settings content area The matching panel (its root needs data-tab-content="<id>")

settings.tabs and settings.body work as a pair: give the tab button's data-tab the same ID as the panel's data-tab-content. Clicking the tab switches to your panel — the host owns the switching logic, so you don't wire any events yourself.

3. Session-scoped — only present while a session is open

These slots only exist inside a session view; switching back to the home/welcome screen automatically clears them, no manual cleanup needed.

Slot Position Typical use
session.banner Above the chat, below the info bar Notice strip, quota warnings, session-level status
session.composer Above the message input Prompt templates, attachment previews, AI suggestions
session.aside Right-side drawer (a Tab container) File tree, git changes, time machine… each mount becomes a tab

session.aside is a tab container, not a vertical stack: each extension mounted to it becomes one tab in the tab bar, and only the active tab's body is rendered. Mounting to this slot requires opts.tab = { id, label, badge? } (see below).

Want to know which slots the current page exposes? Run Clacky.ext.slots() in the browser console. Clacky.ext.context shows the current session's agentProfile and sessionId.

The Full ui.mount Signature

Clacky.ext.ui.mount(slot, renderFn, opts?)

renderFn(ctx) is called whenever the slot needs to render. ctx includes:

  • ctx.agentProfile — the current session's agent name (null outside a session view).
  • ctx.sessionId — the current session ID (null outside a session view).
  • ctx.setBadge(n)only on tab slots (session.aside): update this tab's badge counter; pass null/0 to clear.

Return value: a DOM node, an HTML string, or null (render nothing).

opts are all optional:

Field Meaning
opts.agents Array of strings — restrict the extension to these agent profiles. Omitted = visible to all agents.
opts.panel String — marks "this belongs to this official panel" (set automatically when loaded from _panels/; authors don't usually fill this themselves).
opts.order Number, default 100. Renderers sharing a slot are sorted ascending; ties keep registration order.
opts.tab { id, label, badge? }. Required when mounting to a tab container slot (session.aside). id must be unique within the slot; label is the tab text; badge is the initial counter.
// A complete session.aside tab example
Clacky.ext.ui.mount("session.aside", (ctx) => {
  if (!ctx.sessionId) return null;
  const root = document.createElement("div");
  root.textContent = `Session ${ctx.sessionId}`;
  ctx.setBadge(3);    // show "3" badge on this tab
  return root;
}, {
  order: 50,
  tab: { id: "my-tab", label: "Mine", badge: null },
});

Agent-scoped UI: via attach or agent reference

Different agents deserve different faces — coding wants a git panel; a fitness coach wants a workout-log panel. There are two ways to scope a panel to an agent:

Option 1: the panel declares attach.

contributes:
  panels:
    - id: workout
      view: panels/workout/view.js
      attach: [my-fitness-coach]   # only appears in this agent's sessions

Option 2: an agent references the panel (recommended — cleaner composition within a container). The panel omits attach; the agent's panels: mounts it:

contributes:
  panels:
    - id: workout
      view: panels/workout/view.js
  agents:
    - id: my-fitness-coach
      prompt: agents/coach.md
      panels: [workout]            # mount workout on this agent

Either way, the host automatically mounts the panel only in matching agent sessions — you don't write opts.agents in JS. When the user switches sessions:

  1. The host updates Clacky.ext.context to the new session's { agentProfile, sessionId } and emits session:agent-changed.
  2. Every populated slot is re-rendered with new visibility — the previous agent's panels disappear, the new agent's panels appear, without a page reload.

Shared Panels: Reference, Don't Copy

Common capabilities like git and time machine are built into a container as shared panels (attach omitted, panel id globally unique) that any agent references with one line panels: [id] — instead of every agent shipping a copy.

# builtin-layer shared-panel container
contributes:
  panels:
    - { id: git,          view: panels/git/view.js }
    - { id: time_machine, view: panels/time_machine/view.js }

Any agent (even in another container or source layer) reuses it by id reference:

agents:
  - { id: coding,   panels: [git, time_machine] }
  - { id: designer, panels: [git, canvas] }   # designer reuses git, adds its own canvas

The result: a custom agent that wants git management only needs panels: [git]zero code — and it gets exactly the git panel that coding has. That's cross-agent, cross-container reuse. Panel ids are globally unique, resolved by id across layers, with higher layers overriding lower.

The bundled git and time_machine both mount into the session.aside tab container and are excellent reference implementations for non-trivial session-level panels.

The store / view Convention

If your extension (or your customization of a core feature) involves "data + rendering," follow the same store / view two-layer convention OpenClacky's core uses:

  • store — the single source of truth. It owns state, calls APIs, runs business actions. It never touches the DOM directly. When data changes it emits an event.
  • view — handles rendering and DOM event wiring only. It never fetches data itself; it subscribes to store events and calls store actions.

Key detail: the core view uses the store's own internal event bus (always live), not Clacky.ext.subscribe — the latter is silenced under ?pure=true. If the core panel depended on a bus that pure mode silences, the escape hatch would take the official UI down with it. The Clacky.ext bus is for extensions; alongside emitting on its internal bus, the store mirrors the same event onto Clacky.ext so extensions can observe core data changes.

Core Events You Can Subscribe To

Events you can listen to via Clacky.ext.subscribe(event, handler) (selection — naming convention: <domain>:<action>):

Event When it fires Payload
session:agent-changed Switching into a session (or opening a newly created one) { sessionId, agentProfile }
skills:changed Skills list changed (toggled, installed, removed) { skills, brandSkills, ... }
tasks:changed Scheduled tasks changed { tasks }
profile:changed User profile changed { profile }
billing:changed Balance / subscription status changed { ... }
mcp:changed MCP server list changed { servers }
channels:changed IM channel list changed { channels }
brand:status / brandStatus:changed License activation state changed { activated, ... }
tab:changed Settings page tab switched { tab }
workspace:sessionChanged Workspace view switched session { sessionId }
trash:filesChanged / trash:sessionsChanged Trash contents changed { files } / { sessions }

The full list can be found by grepping _emit(" across lib/clacky/web/features/*/store.js. Subscriber handlers are read-only — don't try to mutate core state through them.

Security Boundaries

  • Extension files are served with strict path validation — no directory traversal to read other files under ~/.clacky/ (such as config.yml).
  • Extensions can only observe (subscribe, read data sources); they cannot rewrite core logic.
  • ?pure=true returns you to the official UI at any time.

When Not to Use Web UI Extensions

  • Changing Agent behavior / tool logic — that's backend territory; use a Skill or the Runtime Patches layer, not a frontend extension.
  • Config that must sync across devices — extensions are files under your local ~/.clacky/; they don't travel with your account.
  • Heavily interactive complex apps — beyond declarative UI injection and read-only subscriptions (complex state machines, cross-extension communication), contribute to core directly.

Web UI Extensions are best for: adding a small piece of your own to the official interface — a badge, a panel, a visualization, an agent's bespoke workspace — and having it stay contained if it ever crashes.