NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Browser bundle and runtime
Developer reference

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, and jsxRuntime instances.
  • Curated components, including shared layout, dialog, icon, project, and autosave components. API 19 publishes SectionDeck and DeckNavigation with their typed DeckNavGroup, DeckNavItem, and DeckNavMatch records. API 22 publishes Textarea. API 24 publishes CardGrid and CardGridItem, 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 publishes SelectionActionBar and CardSelectCheckbox, 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, RailDot and ShimmerText (see docs/WEB.md, "Bounded shared UI"). API 28 publishes the checklist TaskList and its row TaskRow (see docs/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, including useAutoSaveStatus, usePluginConfigDraft, and useToast. useToast returns the same object for old bundles using either { toast } or feedback.toast(...). API 29 adds promise and dismissKey (typed in the kit as PluginToast); 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's usePluginConfigDraft already 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 for buildUsageSummary, so a plugin cannot render English-only figures. API 31 publishes the shared workflowLabel and workflowCounts utilities. API 34 publishes utils.uncollectedChildResult(run) and utils.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 publishes utils.cardTasks(cards) and utils.cardTasksAddressable(rows) from the host's existing web/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 receives TaskRailData: already-projected, addressable rows from the host, passed directly to TaskList rather 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 utils a plugin surface should not carry its own copy of. utils.formatUsd(value, locale, decimals = 2) is the ONE localized USD rendering: a null amount 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 at 4 decimals and passes that explicitly; 2 is 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 same elowen:auth-transition protocol 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 as forbidden, having rolled the transition back first. The optional landing is 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, a javascript: string, anything containing a backslash, and anything that normalises to a leading // are all refused before the switch starts, with landing 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 own Intl.NumberFormat call, 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.