Browser bundle and runtime
Browser bundle contract
The current browser runtime contract is API 55, the value of PLUGIN_UI_API_VERSION in packages/plugin-ui-kit/index.d.ts and web/lib/pluginUi.tsx. It publishes literal interpolation, host-owned JSON request serialization and typed environment-operation following; unused Modal intent and useQueries are removed. API 33 removed the unconsumed LiveTail component and the inert generic mcpServers config editor. No current registry renderer relies on either; 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).
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 55.
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.
Register UI components like this:
window.__elowenRegisterPluginUi?.("my-plugin", {
requiresApiVersion: 54,
pages: { "": RootPage },
account: { connection: ConnectionPanel },
user: { details: UserPanel },
project: { status: ProjectPanel },
settings: { settings: SettingsPanel }
});
The registration object accepts pages, account, accountChip,
user, project, projectRows, 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. 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.
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 while the host owns pagination, grouping, search, and
authorization. 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 core registers 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, while the Subagent drill-in always opens a delegated read-only transcript even if a renderer
supplies 1. 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.