Runtime bundles and localization
ElowenUiRuntime.api accepts PluginApiRequestInit. For a JSON write, call runtime.api(path, { method: 'PATCH', json: value }); the host serializes the value, sets application/json, preserves other request options and headers, and uses its existing authentication, deadlines and restart recovery. JSON writes without a method default to POST. Omitting json preserves raw RequestInit behavior, including FormData uploads and ordinary GET reads; the types exclude supplying both json and body. Responses remain unknown and the consumer must validate its own domain response. Serialization does not authorize a request or replay an unconfirmed write. The cronjob schedule preview is a real JSON consumer after migration.
The bundled MCP register uses host utils.interpolate(template, values) for row labels, tool counts, reconnect summaries and removal confirmations. Its runtime declaration uses ElowenUiRuntime['utils']['interpolate']; manifest and registration require UI API 55. Each template receives all values in one call; unknown placeholders remain literal and inserted values are not reparsed. The helper performs no I/O. Older hosts reject the bundle through the existing version gate; there is no local interpolation fallback.
Runtime plugin web bundles
Plugin UI is discovered from the authenticated /api/plugins/ui listing. The listing supplies the plugin name, compatibility version, navigation and settings registrations, localized strings, bundle URL, CSS URL, and optional layout and presentation declarations. web.presentation: "overlay" keeps the normal /p/<plugin> navigation address but mounts the page through the same PageOverlay used by Settings and Account. Shell navigation updates that address in place so the current page stays mounted beneath the modal; on a hard load the canonical plugin page stays empty while the shell mounts the same modal.
web/lib/pluginUi.tsx loads an approved bundle at runtime with a same-origin module script. It does not use a static bundler import because the URL is runtime data. The host installs window.ElowenUiRuntime, which provides the host React and JSX runtimes, curated components, hooks, authenticated API access, utilities, and SPA navigation. The bundle registers pages and settings through window.__elowenRegisterPluginUi.
Plugin source bundles are built by scripts/build-plugins-web.mjs. A plugin with web-src/index.tsx, index.ts, index.jsx, or index.js is bundled to its web/index.js and receives generated CSS. Plugins without web-src/ keep their checked-in bundle. The root npm run build runs this step before copying plugin files into the distribution. The shared elowen-plugin-ui-kit/build helper owns browser emit settings through an explicit inline esbuild tsconfig, independent of the core or plugin checkout's compiler configuration. It emits ES2022 ESM with automatic JSX and host React shims; ESM is inherently strict, so it does not inject ambient alwaysStrict directives into CommonJS shims. Both the bundled Terminal UI and registry browser UIs use this helper, and build errors propagate to their drift gates.
Plugin code must use elowen-plugin-ui-kit and must not import web/. The host UI API version is the one PLUGIN_UI_API_VERSION declares in packages/plugin-ui-kit/index.js, mirrored by the host in web/lib/pluginUi.tsx and enforced in lockstep by the web typecheck; docs/PLUGIN_DEV.md states the current number. The host checks the declared version before loading a bundle, catches bundle failures with PluginErrorBoundary, and shows an explicit unavailable or incompatible state. Plugin pages use document or workbench layout as declared by the manifest. A page reached directly receives host framing unless the registration declares ownsPageFrame.
API 30 adds hooks.useInvalidatePluginUi(), which returns a callback refetching the plugin listing the main navigation is built from, so the count a plugin's registerNavBadge probe reports moves right after the plugin's own change (the licensing deck calls it after acknowledging or deleting reports); with the listing unmounted it only marks it stale. API 26 adds hooks.useInvalidateConversationLinks(), which returns a callback invalidating the host's canonical conversation-link queries in both mine and all scopes. It uses the shared QueryClient once; if neither query is mounted, it only marks cached data stale and makes no request. The Cronjob plugin uses it after schedule changes without depending on a private host query key. The same coordinated API cut removes host useCronJobs, useSaveCronJob, useDeleteCronJob, utils.isValidSchedule, and the cron methods on utils.elowenClient; Cronjob owns their replacement queries, mutations and schedule validation. No plugin mounted means no Cronjob browser behavior, and older bundles using the removed members must be updated before this host API is published.
API 27 publishes the telemetry rail grid (RailSection, RailList, RailRow, RailDot) and ShimmerText, described under "Bounded shared UI". RailMeter and RailSectionHead were published too until API 45 withdrew them unused. The bundled MCP, Subagent and Terminal sections and the registry Todo and LSP sections require it.
API 28 publishes the checklist on that grid (TaskList, TaskRow), described under "Bounded shared UI". It only adds. The registry Todo plugin's rail section and card require it; a Todo bundle built for API 27 keeps rendering its own rows.
Plugin registrations can contribute pages, Account sections, User panels, Project panels, and settings sections. The plugin owns its domain state and authenticated plugin API. The host provides shared localization, query infrastructure, autosave contracts, navigation, overlays, and UI primitives.
Sandbox registers through the public PluginUiRegistration and types its Project contribution with PluginProjectRowsHook, PluginProjectRowStatus and PluginProjectRowAction; its browser runtime declares only the members it consumes and retains AssertPublished for local component names. plugins/sandbox/web-src/environmentPresentation.ts owns the seven environment states used by both the register and ProjectEnvironmentSettings: icon name/component, tone, drawer color class and busy flag. This lookup adds no reads or subscriptions and preserves the existing classes, including danger as text-destructive. Before a register mutation, Sandbox requires the current generation from its existing usage map. A confirmation surviving a 401/403 usage refetch reports the existing localized Project-access refusal without persisting an intent or sending a mutation when that generation is absent. The drawer consumes the required runtime projection from the current project overview directly, including an explicit null runtime name or readiness; it does not synthesize an older response. Without a Sandbox contribution, the host adds no environment state or lifecycle actions.
Three runtime members replace local copies a plugin would otherwise carry, so a plugin UI should use them instead of bundling its own:
ElowenUiRuntime.C.ConfirmDialogfor any destructive or discarding confirmation. It renders in place inside the host overlay stack with the host focus return and backdrop rules; a plugin-local confirm modal would diverge from both.utils.formatBytes(bytes, locale?)for a human-readable size with the host's binary units, so byte labels agree across the host and plugins. Pass the app locale for localized digits, such as1,5 GBin Czech and Slovak; omission keeps the compact English rendering. Sandbox resource cards and history share this helper.utils.renderMarkdown(source)for sanitized HTML from Markdown. It is the same parser and sanitizer the host uses for chat and assets; a plugin that injects its result needs no parser or sanitizer of its own.
The registry editor plugin is the reference consumer of all three: its discard and delete confirmations, its binary preview and status bar sizes, and its Markdown preview.
utils.localDateTime(timestamp, locale?, seconds?) (UI API 44) renders a stored SQLite-UTC or ISO timestamp as an absolute local date and time, the same rule the host's own tooltips use; with utils.parseTs and utils.compactElapsed a bundle draws the absolute time plus a compact relative hint without printing a raw UTC string. Sandbox's snapshot list is its consumer.
utils.paginateItems(items, page, pageSize) returns the clamped page and its visible items with the same rule the host registers use. The bundled MCP server register is its consumer. An older host without it leaves the name absent, so a bundle that needs it must require a host that ships it.
Internationalization
Core web copy is stored in the locale dictionaries under web/lib/i18n/dictionaries/. LanguageProvider in web/lib/i18n/context.tsx exposes the active locale and dictionary through useTranslation. The locale is mirrored to a cookie so the server can render the first response in the selected language, and to local storage for client persistence. The daemon also stores the account locale in the locale user setting. The web client synchronizes that setting through /auth/me/locale, while daemon-generated alert and push text reads the stored value directly rather than the browser cookie. Use dictionary keys and interpolate; do not hard-code user-facing strings in feature components.
Plugin strings come from the plugin UI listing. The host merges manifest English with the active locale's web.strings and exposes the result to the bundle. Manifest string keys are checked by contract and language validation tests.
Development, build, and tests
Install root and web dependencies independently:
npm ci
npm ci --prefix web
Run the daemon and web development server in separate terminals:
npm run serve
npm --prefix web run dev
Build the web artifact from the repository root:
npm run build:web
For a direct Next.js build:
npm --prefix web run build
The web package scripts also provide:
npm --prefix web test
npm --prefix web run e2e:smoke
npm --prefix web run e2e
npm --prefix web run typecheck:e2e
The fake daemon's web/tests/e2e/fake-daemon/pluginMetadata.ts owns lazy, process-cached manifest and complete locale discovery from bundled plugins followed by E2E_PLUGIN_DIRS. Call pluginMetadata() to obtain ordered candidates, including duplicate directory names across roots. handlers/pluginRegistry.ts selects the first readable manifest for settings and projects only label, description, user-config label and field translations. realPlugins.ts independently selects the first usable in-directory browser bundle and applies the full browser locale data; an earlier manifest without a built bundle must not hide a later usable bundle. Missing or malformed JSON files are skipped, absent locale directories remain undefined for settings, and browser UI stays opt-in through seed.realPlugins(). Discovery costs one manifest/locale read per candidate per process; asset hashing and loading remain with the browser projection. web/tests/lib/pluginMetadata.test.ts characterizes root precedence, locale projections and the shared cache without starting the E2E servers.
Plugin-settings acceptance uses requiredPluginSectionHref from web/tests/e2e/fixtures/pluginSettingsContract.ts to resolve contributed-section addresses through the host's pluginNav functions and manifest placement. Delayed API fixtures hold all initial reads until the test releases them, then switch to the retry phase explicitly; development remounts do not count as user retries. Error assertions are scoped to the owning form or section because the shell also owns an assertive live region. collectPluginErrors accepts cancellation only for opt-in read methods, paths and exact queries with net::ERR_ABORTED; Sandbox's abort-aware desktop overview and read-only usage batch POST also assert successful replacement snapshots and the batch request's project ids. Other transport failures and every unexpected HTTP error still fail acceptance.
Use focused Vitest tests for changed components and libraries. For routing, authentication, streaming, responsive behavior, or plugin loading, run the relevant Playwright coverage as well. Before handing off a web change, run the focused tests, npm run build:web, and any affected contract or language checks. For a broader repository check, npm run check covers lint, dead-code, dependency, daemon typecheck, and language validation; it does not replace the web build or web test suite.
Owning code
web/lib/pluginUi.tsx: loads approved bundles, installswindow.ElowenUiRuntimeand publishes the components, hooks and utilities.web/lib/pluginChatUi.tsx: renders the chat plugin slots, including the chat dock host.web/lib/i18n/context.tsxandweb/lib/i18n/dictionaries/: core locale context and dictionaries (cs,sk,en).scripts/build-plugins-web.mjsandpackages/plugin-ui-kit/build.js: the browser bundle build forplugins/*/web-src/.packages/plugin-ui-kit/index.d.ts: the public UI API contract and version.web/tests/lib/pluginUi.test.tsandweb/tests/lib/pluginMetadata.test.ts: contract tests for the runtime surface and plugin metadata.web/tests/e2e/fake-daemon/pluginMetadata.ts: manifest and locale discovery for end-to-end tests.
Changelog plugin presentation
The bundled What's new page receives only applicable content from Changelog's
per-account API. Plugin-scoped sections carry installed-plugin identity from
host.stores().pluginsRead.list(), the same discovery catalog as Settings.
PluginItem uses the host's sanitized Markdown, the browser DOM parser to
extract the section title, and the existing shadcn-backed muted Badge beside
it. UI API 54 also publishes the existing components.AdaptiveBrandMark,
with its AdaptiveBrandMarkProps in the kit. Changelog's pill passes
src="/api/plugins/sandbox/icon", size={12} and monochrome; the shared
mask paints in the pill's current text color on both light and OLED surfaces.
Absent an icon URL, the pill shows only the name. The default colored-image
mode remains available, and an omitted or empty alt makes the mark decorative.
The component adds no requests beyond loading its image or mask asset and no
subscriptions. Changelog declares requiresApiVersion: 54 so an older host
refuses the bundle before mounting. Untagged content has no pill; an unavailable
plugin item never reaches this renderer. Details load only when an entry opens.