Browser bundle and runtime
UI API 66 adds RailRow.note, the row's second line under the name: the lead and the figure column stay
on the first line, the note runs one line deep beneath the name, and the caller insets it so it sits under
the name rather than under whatever the label carries before it. A bundle calling it declares
requiresApiVersion: 66 in its manifest and browser registration.
UI API 65 publishes components.RailMenu, components.MarkStrip and components.HoverCard plus
utils.formatSpeed and utils.apiErrorMessage. RailMenu is the rail's popover — a panel of rail rows
opened by the caller's own trigger, so the collapsed rail's instrument keeps its shape and its accessible
name while the panel holds rows that carry their own buttons. MarkStrip is a row of small marks that each
name themselves on hover. HoverCard opens after 600 ms of pointer rest, bounds and scrolls its body and
never moves focus. utils.formatSpeed is the ONE rendering of a measured generation rate (tokens per
second) and utils.apiErrorMessage the host's error shaping. The bundled Subagent rail is the consumer of
all three components and of both helpers.
UI API 64 publishes components.ConnectionStatus, the one presentation of a connection's separate
transport and model retry lanes: it counts down only a fresh locally owned schedule and says unknown or
stale observations as such. The same API requires desktop ticket expiresAt and an AbortSignal in both
ticket callbacks.
UI API 63 publishes components.TerminalSurface, the shared xterm screen, together with the dock
additions that let a source draw its own window: PluginDockOperation.icon and .caption,
PluginOperationDockSource.view and hooks.useOperationDock().open(id, plugin, { show: true }).
Mount the surface for one session with its sessionKey, the plugin route that mints a one-shot ticket
and the plugin's WebSocket path; it owns the terminal, the socket, the resume offsets, the copy/paste
keys and the appearance settings. A window that ENDS a session calls
components.TerminalSurface.release(sessionKey) (its own X, or an acknowledgement of an exited row);
one that merely minimizes does not, which is what keeps the shell alive while the reader looks
elsewhere. The full prop contract, the states, the frame protocol and the caps are in
the terminal surface, and the dock's view
rules in the operation dock. A bundle calling any
of them requires UI API 63 in both its manifest and browser registration. Dock and Project card-action
icons use the host's closed, case-sensitive lucide names, for example Terminal, not terminal;
unknown names render the puzzle fallback. The Terminal plugin omits the optional dock caption,
keeping the project in the bubble tooltip and accessible name through the operation title. Its own
bare window retains Modal.title for accessibility without a visible caption. A token-bordered, rounded frame surrounds the padded terminal surface; two shared ghost icon buttons with white minus and X glyphs float 8px inside its top-right corner on every width, without adding clearance to the even 16px terminal padding. They have no circular outline or resting background; shared hover, keyboard focus and hit targets remain intact. While the surface connects or reconnects, core renders a centered spinner and, once retry progress exists, the shared connection status with its retry action; plugins do not import or copy that block.
UI API 62 publishes utils.projectName(project) alongside components.ProjectIcon. Use this pair
for every Project name and icon, including dropdown options and selected summaries. Computer Projects
use the paired device's computer.label and the laptop glyph; host and managed Projects retain their
slug and stored icon or folder glyph. PluginUiProject.computer describes the existing /projects
projection. Pass the complete Project to the icon, never a reconstructed slug-only row. Both helpers
are synchronous; only an existing stored icon triggers the shared cached image read. The Editor's
administrator picker is a consumer. Sites resolves its Project ids through the existing shared
hooks.useProjects() catalog before rendering this pair; a Project outside that account's catalog
has no identity row, including on a shared Site. The host query cache shares the single catalog read. Text-only clients use core's packages/plugin-shared/projectChoice.mjs.
A bundle calling projectName requires UI API 62 in both its manifest and browser registration.
Older hosts refuse mounting it through the existing version gate.
Browser bundle contract
UI API 61 exposes components.ResourceHistoryModal with ResourceHistoryModalProps. Use it for a resource
ring's stored history: pass the initial metric, description, close callback and a useHistory hook returning
validated data, loading/error state, retention bounds and retry. An optional scope slot holds your Project
picker. The host owns the range picker, coloured inline metric checkbox pills, empty/error states and chart;
keep authorization, account-scoped query keys and polling inside your hook. Sandbox is the plugin consumer.
Closing the frame unmounts its reader. A host without API 61 refuses a bundle requiring this component.
For a single-choice mode or filter, use components.Segmented with options, value, onChange
and aria-label. Each option supports a decorative icon or iconContent, count and disabled.
The host renders a bordered control well and a raised active pill using the current skin's opaque
sidebar-accent/sidebar-accent-foreground pair, matching the active navigation row without hover.
Only enabled inactive options receive a faint accent hover wash. Labels remain whole and the track
scrolls horizontally by default, with native
touch swipe, hidden scrollbar, selected-option reveal and keyboard radio semantics. Do not wrap or
shrink its options in plugin CSS. variant="line" is reserved for distinct record-section navigation;
collection-view filters use the default presentation. Existing runtime Segmented consumers inherit
the change without rebuilding or a new API version, including Stats scope and grouping controls.
UI API 57 publishes exact ChoiceFieldProps, ProjectFilterPillsProps, SelectionSummaryProps and SummaryChipProps from the kit. Managed selections have one summary card; the unused summary/chip variant="line" is removed. Registry browser tests load the selected candidate's actual choice, project-filter and summary components rather than copying retired presentation branches.
UI API 57 also publishes the existing components.DataTableSelectCell with its discriminated header/row props. Pass header for the current-page select-all, or both rowId and label for a row. Its enclosing DataTable owns the current-page ids, selection and change callback; without that selection context the cell renders nothing. The host's Memory register is the consumer; a card register ticks cards with CardSelectCheckbox and clears them through SelectionActionBar (API 25), as MCP's does. Consumers requiring the new component must declare API 57 in their manifest and registration. Selection performs only local set updates until the caller invokes a bulk action.
Every single-choice field uses the anchored Select surface, regardless of catalog length;
ChoiceField.picker and ProjectFilterPills.variant no longer exist. Editor renders
<C.ProjectFilterPills value={projectId ?? 'all'} onChange={setProject} includeAll={false} />.
The host prepares the project catalog through its existing query and omits the filter when fewer
than two choices exist. Callers own selected values and persistence; no additional requests or
storage are introduced. Update consumers in the same release as the host because a maximum API
version check cannot protect a bundle that still passes a removed property.
Catalog groups
components.MarkdownAssetEditor groups the current page with the existing ownership.scopes predicates, labels and icons. Skills supplies personal, instance, bundled and plugin scopes; personal entries start open, the others closed. Without scopes, custom agent definitions start open and built-ins closed. Do not call instance-wide definitions personal. Scope filters need not be exhaustive: unmatched entries retain their source group. Empty groups are omitted, and search or explicit scope/source filters reveal matches. The component preserves its pager, forms, bulk actions and caller-owned permissions.
For MCP's default-ordered register the equivalent composition is <C.SettingsGroup title={s.scopePersonal} icon={UserCog} collapsible defaultOpen>{cards}</C.SettingsGroup>; its instance group uses defaultOpen={false}. Do not pass storageKey: list expansion lasts only for the mount. The composition performs no requests or persistence and does nothing without a mounted catalog. This changes presentation only and adds no runtime members.
UI API 58 publishes components.SettingsValue, SettingsState, SettingsList, SettingsItem and
SliderWithReading beside SettingsGroup, SettingsRow and StatusDot: the content primitives of every
window and of the Project drawer, identical to Settings. A web.project, web.user or web.settings panel returns SettingsGroups
(never its own page wrapper), each holding SettingsRows with a description and a right-aligned control, or a
SettingsList of SettingsItems for records; a read-only value is SettingsValue and an empty or failed
read is SettingsState. State is a StatusDot plus a word; colour means state only. PropertyGroup,
PropertyRow and DetailBlock are no longer published. They need no requests and set requiresApiVersion: 58.
Details: seam catalog, UI API 58. Inside the Project and User drawers the host draws these
same components in its compact drawer presentation (a group's title above one hairline-divided box, labels in
one column with the value after them, list items and states as lines of that box); a panel passes the same
props and needs no change or check of its own.
UI API 59 publishes hooks.useProjectCommits(id, { limit, anchor, enabled }): the Project's commit log read
in ANCHORED pages of the host's own query cache. The result is react-query's infinite query — data.pages
of { commits, hasMore, anchor }, hasNextPage, isFetchingNextPage, fetchNextPage() and refetch() —
so a panel can unfold a history in place and ask for the next page as the end of the list comes into view.
anchor is the commit the WHOLE walk starts from and the caller names it (the head a
hooks.useProjectGit snapshot reported), because every page, including a first page re-read after a
failure, is asked for from that same commit: that is what stops a reload from shifting the window onto a
newer history and showing one commit twice. null lets the daemon anchor at the current HEAD, for a
caller with no snapshot. The read carries no new request shape or polling; one call is one page. A bundle
using it declares requiresApiVersion: 59 in its manifest and its registration, since an older host has no
such hook to call.
UI API 56 publishes utils.uploadProjectFile(file, { projectId, directory }, { signal, onProgress }).
For example, Editor passes its selected relative Project directory and renders durable byte progress
with the existing host Progress component. The helper returns a stored { name, path, relative }
or { failed: true, reason?, cancelled? }. It opens /api/brain/uploads/sessions, streams Blob parts,
recovers from dropped connections using the server's offset and commits the verified size.
Part retries use the host's authenticated finite request policy; only validated durable byte
progress renews an episode, and open/commit are never retried. See upload ownership.
Abort the signal to cancel and discard staging. There are no file type or total size limits; occupied
names gain suffixes. Accounts must hold current Project write access, rechecked at part and commit;
directories cannot leave the Project. Open sessions expire after idle time and are lost on restart.
Without a call it performs no work; memory is bounded by the part and guest chunk, with no inference cost.
The implementation stays in web/lib/projectUploads.ts inside the Next bundle root. Chat calls it
directly with { sessionId }, retaining its conversation-owned attachment layout, and plugins call
the host runtime rather than importing web/ or bundling a copy. UI-kit imports are type-only.
The current browser runtime contract is API 66, the value of PLUGIN_UI_API_VERSION in packages/plugin-ui-kit/index.d.ts and web/lib/pluginUi.tsx. The runtime publishes no LiveTail component and no generic mcpServers config editor; the terminal and MCP plugins own their live management surfaces. The host exposes the runtime as
window.ElowenUiRuntime. It also defines window.__elowenRegisterPluginUi(plugin, registration) (web/lib/pluginUi.tsx), which the bundle calls to register its components.
A bundle declares requiresApiVersion in the registration object. It loads
when that requirement is no newer than the host runtime. API 26 deliberately removes the cron-specific browser hooks and schedule
parser: Cronjob owns its own requests and validates schedules through its
server preview. The maximum-version check does not shield an older bundle
from removals on a newer host; update installed bundles in the same release.
The runtime provides:
apiVersion.- The host's
react,reactDom, andjsxRuntimeinstances. - Curated
components, including shared layout, dialog, icon, project, and autosave components. API 19 publishesSectionDeckandDeckNavigationwith their typedDeckNavGroup,DeckNavItem, andDeckNavMatchrecords. API 22 publishesTextarea. API 24 publishesCardGridandCardGridItem, the host's card register:layout="list"is one full-width card per row, for entries read as a line (a name and the sentence saying what it is for), with the name as the one open control. A plugin's short register draws the same cards as the built-in ones instead of a table approximating them. API 25 publishesSelectionActionBarandCardSelectCheckbox, the floating bulk-action bar and the per-card select checkbox extracted from the memory register, so a plugin's card register ticks and clears the same selection: the count line, the action buttons and the X clear button in one bar, with activation that never opens the entry underneath. API 27 publishes the chat rail grid:RailSection,RailList,RailRow,RailDotandShimmerText(seedocs/WEB.md, "Bounded shared UI"). API 28 publishes the checklistTaskListand its rowTaskRow(seedocs/WEB.md), so a plugin's task list draws the host's rows, menu and strings. Plugin pages get the host's closing instance-name wordmark automatically: the shell renders it after every page, so a bundle renders none of its own. - Curated
hooks, includinguseAutoSaveStatus,usePluginConfigDraft, anduseToast.useToastreturns the same object for old bundles using either{ toast }orfeedback.toast(...). API 29 addspromiseanddismissKey(typed in the kit asPluginToast); bundles narrow them as optional on older hosts and feature-detect them. Promise feedback propagates the operation result and rejection. Supply curated localized text, not exception strings. Omit success feedback where the UI already confirms the action. The host'susePluginConfigDraftalready raises the trailing "changes saved" confirmation, so consumers of that draft must not announce the same save a second time. - Curated pure
utils. API 21 requires a viewer locale forbuildUsageSummary, so a plugin cannot render English-only figures. API 31 publishes the sharedworkflowLabelandworkflowCountsutilities. API 34 publishesutils.uncollectedChildResult(run)andutils.railProcessVisible(process). The bundled Subagent and Terminal rails require API 34 and filter their supplied rows through these helpers before rendering either density. Running children and pending result deliveries stay visible; only running detached processes qualify. Terminal foreground handles remain in the snapshot for their tool calls but do not own rail space. Missing rows produce no section; without either plugin its rail slot is absent. Each predicate is constant-time, filtering is linear, and neither helper performs I/O or changes the snapshot. Hosts below API 34 refuse requiring bundles before mount. API 32 publishesutils.cardTasks(cards)andutils.cardTasksAddressable(rows)from the host's existingweb/lib/railTasks.ts. Todo calls the first with[card], then the all-or-nothing id guard before rendering interactive rows. It preserves structured labels, owner, unresolved blockers and measured times, without parsing glued text or fetching a task route. No Todo card yields an empty array; missing task ids fail the guard. Work is linear in the supplied cards and Todo rows and creates no network request. Todo's rail slot receivesTaskRailData: already-projected, addressable rows from the host, passed directly toTaskListrather than reconstructed as session tasks. The host normalizes incoming plugin cards at the emit boundary; the slot payload is internal host data, not a second untrusted wire boundary. No registered Todo surface means the normal host fallback remains in charge. - API 23 publishes two
utilsa plugin surface should not carry its own copy of.utils.formatUsd(value, locale, decimals = 2)is the ONE localized USD rendering: anullamount means nothing reported a price and renders an em dash, never$0.00, so an unpriced bucket is never presented as free. The host renders every usage figure at4decimals and passes that explicitly;2is the default for a bundle's own amount.utils.impersonateUser(userId, landing?)hands the reader into another account's own session through the host's identity transition, the sameelowen:auth-transitionprotocol every account-scoped surface on the page listens for, including the host's in-flight guard, which refuses a second transition while one is running. It resolves once the new identity is the one in the cookie, and rejects with the reason core gave, such asforbidden, having rolled the transition back first. The optionallandingis where the shell opens as the new account, such as"/account?cat=cli"; without one it goes to/dash. It must be a path in THIS app, and the host decides that, not the caller: an absolute URL, a protocol-relative one, a relative one, ajavascript:string, anything containing a backslash, and anything that normalises to a leading//are all refused before the switch starts, withlanding must be a path in this app. Pass the path a reader should arrive on, not a URL you assembled from host state. Do not re-declare the transition event, its storage key or the request around it in a bundle; do not offer a stop-impersonation control from a plugin, since the host shell owns leaving an impersonated session. And do not print a price with your ownIntl.NumberFormatcall, which is how an unpriced amount becomes$0.00. - Authenticated same-origin
api(path, init). - SPA
navigate(href).
A plugin page whose manifest sets presentation: "overlay" opens as a page overlay, and its
components receive surface: "deck". An overlay plugin with peer sections composes the same frame as Settings and
Account from the published components. The host bounds the modal body;
SectionDeck fills it and owns the one vertical content scroller. Do not wrap
the deck in another scrolling or shrink-preventing container.
const { SectionDeck, DeckNavigation } = window.ElowenUiRuntime!.components;
function ChatbotDeck() {
const [active, setActive] = React.useState("bots");
const groups = [{
id: "chatbots",
items: [
{ id: "bots", label: "Bots", icon: Bot, current: active === "bots", onActivate: () => setActive("bots") },
{ id: "statistics", label: "Statistics", icon: ChartNoAxesColumn, current: active === "statistics", onActivate: () => setActive("statistics") }
]
}];
return (
<SectionDeck
testId="chatbot-deck"
contentLabel={groups[0].items.find((item) => item.current)!.label}
navigation={(layout, className) => (
<DeckNavigation
label="Chatbot sections"
groups={groups}
layout={layout}
testId="chatbot-navigation"
emptyLabel="No sections"
className={className}
/>
)}
>
{active === "bots" ? <Bots /> : <Statistics />}
</SectionDeck>
);
}
A bundle using these API 19 components sets both manifest
web.requiresApiVersion: 19 and registration requiresApiVersion: 19. New
bundles should target the current contract, API 66.
Build browser sources with the elowen-plugin-ui-kit package and the plugin
web build command. The bundle must use the host React instance and must not
ship another React, query client, or import from the host web/ application.
If the prebuilt host CSS does not contain a utility the plugin needs, ship
css in the manifest. The host serves bundle and stylesheet URLs with
content hashes.
Design tokens live in web/app/styles/tokens.css and are mirrored for
plugins in packages/plugin-ui-kit/theme.css; a contract test enforces the
mirror. A plugin stylesheet is compiled against the mirror, so never rename
or remove a token — add beside it. A renamed token silently paints the stale
fallback instead of the host's live value.
Typography comes from the same tokens and nowhere else. Use the role classes
(text-page-title, text-section-title, text-card-title, text-ui,
text-meta, text-small, text-figure, text-body) or var(--text-<role>)
in a stylesheet; each role carries its size, line height and weight, so a
bundle reads at the size of the Settings window and needs no font-medium.
Do not write Tailwind's text-xs, text-sm, text-base or text-lg, an
arbitrary text-[13px], a raw font-size or a font-family. Use
font-mono only for real code: code blocks, paths, hashes, ids, log output
and command lines; numbers stay sans with tabular-nums. Nothing is smaller
than text-small (12px). The 11px text-caption and 10px text-tiny were
retired in October 2026; a bundle built before then keeps the kit's old
fallback size until it is rebuilt. A plugin that needs a size the roles do
not give asks for a new role in the host's tokens.css rather than
inventing one. web/tests/styles/typeScaleGuard.test.ts holds the bundled
plugins to this.
Text links have one look, the host's: the surrounding text colour, always
underlined, the brand colour on hover. Put the host class inline-link on an
<a> or a <Link>, and inline-links on a container of rendered Markdown so
every anchor in it gets the look. Do not write underline, underline-offset-*,
hover:underline or a text-primary link colour of your own. The classes live in
the host stylesheet, so they need no UI API version; on an older host they do
nothing and the link renders as plain text.
A text ACTION is not a link (owner, 13 Oct 2026): it is C.Button with
variant="ghost" — no underline, quiet at rest, the accent wash on hover — and
size="sm" where it sits inside a line or a row. Retry buttons, disclosure
toggles, Show all, Choose icon and Map repository are the controls the host
and the registry plugins draw this way. Never put .inline-link on a <button>.
Register UI components like this:
window.__elowenRegisterPluginUi?.("my-plugin", {
requiresApiVersion: 66,
pages: { "": RootPage },
account: { connection: ConnectionPanel },
user: { details: UserPanel },
project: { status: ProjectPanel },
settings: { settings: SettingsPanel }
});
The registration object accepts operationDock, pages, account, accountChip,
user, userStatus, project, projectRows, projectEditIcon, dashboardMetrics,
chatPickers, chatCards, chatRailSections, chatDock, historyBranches,
settings, and ownsPageFrame. Page keys are
slash-joined route patterns; "" is the root page. Page components receive
plugin name, captured params, remaining path segments, surface, and optional
save-state reporting. An account section is placed in the Account menu by
its manifest entry's order (see the manifest guide); the registration only
supplies its component. User and Project panels receive the selected host DTO and
a panel id. The one chatDock component receives the conversation's Project,
its session, the live narration and the pending-input notice.
operationDock is a PluginOperationDockSource that lists the plugin's long-running operations in the
background operations dock. It implements list, get, acknowledge and decodeEvent for the event kind
named by eventKind. The host owns account filtering, query lifetime and the event stream, and the plugin
validates its own wire rows.
These contribution slots are real runtime seams, not plugin-name switches.
projectRows receives the bounded Project projection. dashboardMetrics
receives the dashboard's documented metric props. chatPickers are local
browser choosers keyed by command and do not create a model turn.
chatCards render opaque BrainCard ids before the read-only generic
StaticCard, drawing the card the daemon pushed for the session on screen
rather than rebuilding it from a route. chatRailSections render expanded and compact variants while
the host owns rail geometry and SSE; in all three, sessionId is the
conversation the plugin's routes may act on and is null while a delegated
child or a read-only transcript is shown; an expanded section draws its head and
rows with the published rail grid (API 27) rather than its own markup. historyBranches render a branch below
a conversation row inside a named branch region, while the host owns pagination, grouping,
search, and authorization. Contributions return standalone semantic list/disclosure content,
not table-row or table-cell fragments. The parent/items/expanded/toggle/open props remain
unchanged; the generic fallback uses the same list-compatible mount. Cronjob's matching
renderer is Cronjob 0.6.49 and requires core 0.30.7; update core and Cronjob together before using the new list host. There is no table/list compatibility flag or legacy renderer. Their open(target) callback uses session:<percent-encoded-id>:<0|1>; Cronjob
encodes its run's session id with encodeURIComponent and sets 1 only for a continuable run.
Subagent uses session:<percent-encoded-id> without a flag. Both views of the unified core list decode these
targets once through parseBrainSessionTarget in web/lib/pluginChatUi.tsx, with no I/O;
malformed or non-session targets do not navigate. Normal history branches preserve the continuation
flag. A target without a flag, as Subagent sends, opens a read-only delegated transcript. Daemon
authorization remains authoritative. An absent renderer keeps the existing
generic navigation. chatDock is mounted once per chat surface for the conversation
on screen, independent of the loaded turns. A missing or failed optional slot is omitted or uses its stated
generic fallback; it is never represented by a successful empty component.
The optional cron.conversationLinks(input) control method contributes job metadata
for the core-authorized conversation ids. Core rechecks job visibility and transcript
ownership in src/api/routes/brainConversationLinks.ts; it derives the navigation
prefix from the current registry's actual cron owner, using
/p/<encoded-owner>?job=<encoded-job-id>. Plugin-supplied URLs are ignored.
The registry Cronjob is the current consumer, but the package name is not fixed in
core. Absent control, absent method or missing account grant answers unavailable;
a failed read answers error, and an empty successful read remains available.
The core sub-agent branch keeps its own independent status. This is one display-only
read per listing and creates no job, schedule or transcript mutation.
A settings component is mounted in the settings deck and can also be served as
a standalone plugin page. Add its id to ownsPageFrame only when the bundle
renders the complete page frame and owns its own save indicator.