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; synchronous render errors are caught and attributed to the affected panel.

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.

The host provides these safeguards (not a JavaScript sandbox):

  • Error attribution — guarded synchronous render errors show a panel placeholder (data-ext-status="crashed"). Handle asynchronous errors yourself; this does not protect against runaway requests, blocking code, or arbitrary DOM mutation.
  • 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.
  • Integration boundary — use Clacky.ext and the documented Clacky.* services below, not host internals or another panel's DOM. Scripts share the host page; this convention is not an enforced permission sandbox.

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 when the host renders the mount. Tab bodies are lazy: switching back to an already-rendered tab in the same render pass reuses its body rather than calling render again.
  • 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" },
});

Mount options (tab is required for tabbed slots):

Field Meaning
opts.agents Explicit agent-profile list. Session-slot visibility also includes agents associated with the panel by the loader; this does not narrow an already matching panel association. Without either scope, the mount is global.
opts.order Number, default 100. Renderers sharing a slot are sorted ascending; ties keep registration order.
opts.tab { id, label, badge?, onAttach? }. Required for session.aside; id is unique within the slot, label is a string or function, badge is the initial counter. See the lifecycle below.
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.
// 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 },
});

Tab Initialization and Subscription Cleanup

tab.onAttach(ctx) runs when the visible tab bar is attached, before the tab is clicked. Use it for lightweight subscriptions or badge initialization. It receives the session context and ctx.setBadge(value), but no runtime. Return a cleanup function; the host invokes it before reattaching the tab or switching sessions. It can run more than once per session.

Clacky.ext.subscribe(event, handler) returns an unsubscribe function. Return it directly for one subscription, or return a function that releases all subscriptions, timers, and pending work. Filter session events by sessionId. A render teardown differs from runtime.dispose(): runtime can survive switching sessions until the host notifies it of session removal (for example, deletion). Switching tabs alone does not dispose it. Runtime is memory-only and is lost on page reload; do not rely on a disposal callback during browser unload.

Clacky.ext.ui.mount("session.aside", (container) => {
  container.textContent = "Activity";
}, {
  tab: {
    id: "activity",
    label: "Activity",
    onAttach(ctx) {
      return Clacky.ext.subscribe("session:complete", (event) => {
        if (event.sessionId === ctx.sessionId) ctx.setBadge(1);
      });
    },
  },
});

ctx.setBadge(null), ctx.setBadge(0), or ctx.setBadge("") clears the badge; other values display as text. Guard asynchronous results against stale sessions after cleanup.

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

For sidebar navigation styling, reuse the navRow() structure from the --full scaffold (task-item task-item-summary, task-row, task-icon, task-info, task-name); keep opts.workspace for the active-route highlight.

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

This automatic panel scope applies to session.banner, session.composer, and session.aside only. Settings, header, sidebar, and workspace mounts are global unless explicitly scoped; settings.* does not inherit the panel's agent association. 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

A panel's resolved identity is container-id/panel-id. Inside the same container, an agent may use the bare panel id; across containers, use the qualified reference. For example, the bundled git container declares:

# builtin git/ext.yml (excerpt)
id: git
contributes:
  panels:
    - id: git
      view: panels/git/view.js

An agent in your own container can reuse it without copying the implementation:

# your container's ext.yml (excerpt; provide the referenced prompt file)
contributes:
  agents:
    - id: designer
      prompt: agents/designer.md
      panels: [git/git, time_machine/time_machine]

The bundled git/git and time_machine/time_machine panels mount into session.aside. Different containers may have the same bare panel id. Cross-layer overrides replace the whole container with the same directory name, not individual panels from unrelated containers. When implementing a panel, also prefix its tab.id to avoid collisions in the shared tab container.

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 }
workspace:fileSaved Clacky.Workspace.saveFileText() completed successfully { sessionId, path, name }
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

Reusing Host Services

Use the Clacky.* namespace, not window.Sessions or other bare globals. The latter may be undefined even when a service is loaded. Prefer documented methods; a property being exported does not make every internal method an extension contract.

Aside and Composer

  • Clacky.Aside.open() / close(): expand/collapse the current session aside. Opening does not select a particular tab.
  • Clacky.Composer operates on a contenteditable element, not a textarea. For the host input, obtain document.getElementById("user-input") in an active session and check it exists; do not construct or replace the host input yourself.
Method Result / effect
text(el) Plain text, excluding reference chips.
setText(el, value) / clear(el) Replace/clear contents, including existing chips. Neither submits a message nor dispatches an input event. Ask before replacing the user's draft.
focus(el) / focusEnd(el) Focus the input / move the caret to its end.
hasContent(el) Whether text or reference chips exist.
chips(el) Array of references: file/directory {type, path, name}, session {type, session_id, name}.
insertChip(el, chip) Insert a reference and dispatch input. File/directory input: {type, path, name}; session input: {type: "session", sessionId, name} (note the different key).
removeChip(chipEl) Remove one chip DOM element; not a path or a reference object.
setPlaceholder(el, value) / init(el) Set placeholder text / initialize an extension-owned composer. Do not reinitialize the host input.

Dialogs and Feedback

Method Return / behavior
Clacky.Modal.confirm(message) Promise<boolean>.
confirmOnce(storageKey, message, skipLabel) Promise<boolean>; remembers a confirmed “skip next time” choice in localStorage. Use an extension-prefixed key.
confirmWithCheckbox(message, checkboxLabel) Promise<{ok, checked}>; cancellation returns both false.
prompt(message, defaultValue = "") A Promise resolving to a string or null; trimmed input, null on cancel or empty input.
rename(currentName = "", labelI18nKey = "sessions.modal.name") A Promise resolving to a trimmed string, or null on cancel or an unchanged name. Empty input marks an error and keeps the dialog open; the Promise remains pending.
toast(message, typeOrOptions = "info", maybeOptions = {}) No return value. Types: info/success/warning/error. Options include duration (ms) and action: {label, onClick}.

Except the fully qualified first row, dialog methods above are also on Clacky.Modal. For example, Clacky.Modal.toast("Saved", {type: "success", action: {label: "Open", onClick: openSavedFile}}); invoke it after a successful user action, not unconditionally when the panel mounts.

Routing, Translation, Auth State, and WebSocket

Service Use / boundary
Clacky.Router.navigate(view, params = {}) Request navigation, e.g. navigate("session", {id: sessionId}). Fire-and-forget, not an awaitable completion signal. There is no Router.go(). For extension workspaces prefer Clacky.ext.ui.openWorkspace(id).
Clacky.I18n.t(key, vars) Translate an existing host key with optional interpolation.
Clacky.Auth.passed Read the frontend's auth state (a property, not a function); it is not proof that a later request will be authorized.
Clacky.WS.send(object) Send a host-protocol message, or queue it while disconnected. No acknowledgment is returned. Use only verified message types/payloads; sending a task can incur costs or alter a conversation. Prefer documented REST/actions for the feature and subscriptions for observation; do not replace the host's subscribed session.

Workspace, Skills, and Data Sources

  • Clacky.Workspace.state carries the active sessionId / workingDir. fetchEntries(relPath) returns directory entries; there is no Workspace.list().
  • Workspace file methods take an entry with a workspace-relative path (for example {path: "src/app.js", name: "app.js"}), as returned by fetchEntries. Do not pass an absolute path: the store prefixes the active working directory.
  • fetchFileText(entry), fetchFileBlob(entry), displayPath(entry), and revealFile(entry) operate within that active workspace. saveFileText(entry, content) saves text and emits workspace:fileSaved with {sessionId, path, name} on success; it returns no file object. Do not change the shared workspace's session to implement an extension-local selection. For explicit paths, see Host APIs.
  • await Clacky.Skills.load() refreshes the catalog; Clacky.Skills.all reads a copy of it. There is no Skills.list().
  • Observe session changes with Clacky.ext.subscribe("session:agent-changed", handler), not Clacky.Sessions.on().
  • Clacky.ext.api.resolve(name) returns the registered function, or undefined, not its result. Call the function to read the data. Clacky.ext.fetch does not exist; use same-origin fetch as described in Host APIs.

Live Events vs History Replay

For custom ext.* events persisted by the backend, history replay emits the same event name with {sessionId, ...event, replayed: true}. Live events do not set that marker. Backend emission and persistence are covered in Hooks.

Use replay to reconstruct state, but skip toasts, external sends, and paid operations when payload.replayed is true. History is paginated and loaded on demand, not delivered as one complete event stream at mount time. Make state restoration idempotent and do not assume one delivery or chronological replay across pagination.

Engineering Boundaries

Reuse host btn-* / form-* classes, Clacky.Modal, and var(--color-*); prefix extension-specific classes and keep changes inside the mount. Prefer events over polling, cache repeated reads, and release listeners/requests when their owner is torn down. A hidden tab's cached body is not automatically torn down: gate expensive work separately. Billing/usage queries do not themselves invoke a paid model; model tasks and media generation/transcription can incur costs and should require explicit user intent.

Security Boundaries

  • Extension files are served with strict path validation — no directory traversal to read other files under ~/.clacky/ (such as config.yml).
  • Subscriptions are for observing host events. Explicit user actions can call documented mutation APIs; subscriptions and path validation are not a security sandbox.
  • ?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.