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.ymlplus 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:
- Crashes — an exception in one extension takes the whole page down (white screen), so the user can't even reach the official features.
- 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=trueto 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.extand the documentedClacky.*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)—containeris the first argument, notctx.containeris a host-owned DOM element you can append into directly. This is the single most common mistake: writing(ctx) => ...shifts every argument by one, soctxends 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.tabsandsettings.bodywork as a pair: give the tab button'sdata-tabthe same ID as the panel'sdata-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.asideis 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 requiresopts.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.contextshows the current session'sagentProfileandsessionId.
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 (nulloutside a session view).ctx.sessionId— the current session ID (nulloutside a session view).ctx.setBadge(n)— only on tab slots (session.aside): update this tab's badge counter; passnull/0to clear.
runtime— only meaningful when you providecreate(see below); otherwisenull.
Return value:
- a DOM node or an HTML string → the host appends it;
undefined(you mutatedcontainerin place) ornull(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 becomesruntime.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 callingrenderagain.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:
- The host updates
Clacky.ext.contextto the new session's{ agentProfile, sessionId }and emitssession:agent-changed. - 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. TheClacky.extbus is for extensions; alongside emitting on its internal bus, the store mirrors the same event ontoClacky.extso 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("acrosslib/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.Composeroperates on a contenteditable element, not a textarea. For the host input, obtaindocument.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.statecarries the activesessionId/workingDir.fetchEntries(relPath)returns directory entries; there is noWorkspace.list().- Workspace file methods take an
entrywith a workspace-relativepath(for example{path: "src/app.js", name: "app.js"}), as returned byfetchEntries. Do not pass an absolute path: the store prefixes the active working directory. fetchFileText(entry),fetchFileBlob(entry),displayPath(entry), andrevealFile(entry)operate within that active workspace.saveFileText(entry, content)saves text and emitsworkspace:fileSavedwith{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.allreads a copy of it. There is noSkills.list().- Observe session changes with
Clacky.ext.subscribe("session:agent-changed", handler), notClacky.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.fetchdoes not exist; use same-originfetchas 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 asconfig.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=truereturns 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.