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.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.
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=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. - Boundary — extensions reach the host only through the single
Clacky.extentry 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.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, renderFn, opts?)
renderFn(ctx) is called whenever the slot needs to render. ctx includes:
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.
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:
- 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
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
gitandtime_machineboth mount into thesession.asidetab 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. 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 } |
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.
Security Boundaries
- Extension files are served with strict path validation — no directory traversal to read other files under
~/.clacky/(such asconfig.yml). - Extensions can only observe (subscribe, read data sources); they cannot rewrite core logic.
?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.