Web UI Extensions

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, render, opts?) — inject UI into a named slot.
//    The render signature is render(container, ctx, runtime):
//    append into `container`, OR return a DOM node / HTML string.
Clacky.ext.ui.mount("header.right", (container, ctx) => {
  const span = document.createElement("span");
  span.textContent = "★";
  span.title = "My extension";
  return span;   // return a DOM node or an HTML string (host appends it)
});

// 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 render throw? The guard catches it, that slot degrades to a placeholder, and the rest of the page renders normally.

The render signature is render(container, ctx, runtime)container is the first argument, not ctx. container is a host-owned DOM element you can append into directly. This is the single most common mistake: writing (ctx) => ... shifts every argument by one, so ctx ends up holding the DOM element and your session checks silently misbehave.

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.top Top of the sidebar nav Custom menu item, above the default nav
sidebar.nav Middle of the sidebar nav Custom menu item → gateway to a new workspace
sidebar.nav.bottom Bottom of the sidebar nav Custom menu item, below the default nav
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, spec, opts?)

spec is either a render function or an object { create?, render }.
Either way the render signature is always render(container, ctx, runtime):

  • container — a host-owned DOM element. Append your UI into it directly, or return a DOM node / HTML string and the host appends it for you.
  • ctx — the current context:
    • 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.
  • runtime — only meaningful when you provide create (see below); otherwise null.

Return value:

  • a DOM node or an HTML string → the host appends it;
  • undefined (you mutated container in place) or null (you opted out of this context, e.g. if (!ctx.sessionId) return null) → nothing is appended, and neither is an error;
  • a function → registered as a teardown callback, run before the next re-render or when the session goes away.

Only a thrown exception degrades the slot to a crashed placeholder — returning null never does.

Per-session state: the { create, render } form

On the per-session slots (session.aside, session.banner, session.composer), pass an object with a create(ctx) alongside render to get one runtime per sessionId, isolated across sessions:

  • create(ctx) runs the first time this session shows the mount; its return value becomes runtime.
  • render(container, ctx, runtime) runs every time the mount becomes visible, with a fresh empty container.
  • runtime.dispose() (if defined) runs when the session leaves the sidebar — release timers, media recorders, sockets here.

This is how the built-in meeting panel keeps a recorder alive per session:

Clacky.ext.ui.mount("session.aside", {
  create(ctx) { return createMeetingSession(ctx); },   // one runtime per session
  render(container, ctx, runtime) { runtime.attach(container); },
}, {
  order: 200,
  tab: { id: "meeting", label: "Meeting" },
});

opts are all optional:

Field Meaning
opts.agents Array of strings — restrict the extension to these agent profiles. Omitted = visible to all agents.
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 the tab container slot (session.aside). id must be unique within the slot; label is the tab text (a string or a function returning one); badge is the initial counter.
opts.workspace Id of a registerWorkspace() workspace this mount opens (see below). Stamped on the mount so the Router highlights this nav item while that workspace is active — use it on sidebar.nav.* items.
opts.panel String — marks "this belongs to this official panel" (set automatically when loaded from a panel file; authors don't usually fill this themselves).
// A complete session.aside tab example
Clacky.ext.ui.mount("session.aside", (container, ctx) => {
  if (!ctx.sessionId) return null;   // opt out on the new-session page — safe, not an error
  const root = document.createElement("div");
  root.textContent = `Session ${ctx.sessionId}`;
  ctx.setBadge(3);    // show "3" badge on this tab
  container.appendChild(root);
}, {
  order: 50,
  tab: { id: "my-tab", label: "Mine", badge: null },
});

Full-page Workspaces

Beyond mounting into existing slots, an extension can register a full-page workspace that takes over the main content area and gets its own URL hash (#ext/<id>), so browser back/forward and reload work:

// Register once at load time
Clacky.ext.ui.registerWorkspace("my-console", {
  title: "My Console",
  render(container, ctx) {           // container is cleared before every show
    container.textContent = "hello from my workspace";
  },
});

// Open it — typically wired to a sidebar.nav item from the same extension
Clacky.ext.ui.mount("sidebar.nav.bottom", () => {
  const btn = document.createElement("button");
  btn.textContent = "My Console";
  btn.onclick = () => Clacky.ext.ui.openWorkspace("my-console");
  return btn;
}, { workspace: "my-console" });     // opts.workspace lets the Router highlight this item

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.

Session events (mirrored from WebSocket)

The events above come from host stores. The events below are mirrored from the live WebSocket stream so panels can observe conversation, status, and errors in real time without polling APIs. Payload is always { sessionId, ...wsFields } - the raw WS event fields are passed through verbatim.

Scope limit: conversation-stream events (assistant-message, tool-call, tool-result, progress, complete, etc.) are only delivered for the currently subscribed session (the WS layer subscribes to one session at a time). Lifecycle events (session:update, session:task-finished, session:renamed, session:deleted, session:restored, session:list) are global - they fire for every session.

Conversation stream

Event When it fires Key payload fields
session:user-message User message committed to history created_at
session:assistant-message Assistant text streamed content
session:tool-call Agent invokes a tool name, args, summary
session:tool-result Tool execution finished result
session:tool-stdout Tool stdout output lines
session:tool-error Tool raised an error error
session:progress Agent thinking/working indicator phase, progress_type, message, metadata
session:complete Agent turn finished cost, iterations, duration, cache_stats
session:token-usage Token usage update (raw WS fields)

Status & errors

Event When it fires Key payload fields
session:error Session-level error (raw WS fields)
session:warning Warning notification message
session:info Info notification message
session:success Success notification message
session:interrupted Agent run interrupted -

Lifecycle (global - all sessions)

Event When it fires Key payload fields
session:update Session status/cost/tasks changed status, cost, tasks, latency, or session (full)
session:task-finished Background task finished -
session:renamed Session renamed name
session:deleted Session deleted -
session:restored Session restored from trash session (full)
session:list Session list refreshed sessions, projects, has_more, groups
session:subscribed WS subscribed to a session -

Interaction & phases

Event When it fires Key payload fields
session:request-feedback Agent requests user feedback question, context, options
session:request-confirmation Agent requests confirmation id, message
session:phase-start Subagent phase began phase_id, kind, label
session:phase-end Subagent phase ended phase_id, summary

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.