SDK — UI I Contribute
A target: "web" biome ships its frontend as an ordinary npm-style package. Its
entry module (src/index.tsx) default-exports a FrontendBiomeFactory —
(bridge: HostBridge) => FrontendBiome. At boot the host imports that factory,
calls it with the host's HostBridge, and registers the returned biome so its
pages, nav items, slot panels, and session contributions light up.
You do not declare UI in xema-biome.json. The manifest only declares that
the biome is a web biome (xema.target: "web") plus its id, scope, and display
metadata; every frontend contribution comes from the default-exported factory.
- Host shell:
xema-host-web— a Next.js App Router app. - Kernel contract:
@xemahq/ui-kernel(published on npm). Host-rendering helpers (<BiomeSlot>,HostExtensionSlots, session primitives) live under the@xemahq/ui-kernel/registrysubpath.
The recommended way to author a biome is the defineWebBiome helper — you rarely
build the FrontendBiome object by hand.
The manifest
The web biome's xema-biome.json is metadata only — it never contains a
frontend.slots / frontend.routes / frontend.nav block. An example:
1{
2 "name": "@acme/spaces-web",
3 "version": "0.1.0",
4 "xema": {
5 "id": "spaces-web",
6 "displayName": "Spaces (Web)",
7 "display": { "icon": "layers", "category": "Workspace", "summary": "…" },
8 "scope": "system",
9 "target": "web",
10 "systemSurface": true,
11 "storeListed": false
12 }
13}xema.target: "web" is what the host's build-time scanner keys on;
xema.id must match the id your factory returns. (xema.display.category is a
store/admin grouping label — distinct from the per-page menu category below.)
Authoring with defineWebBiome
defineWebBiome({ id, displayName, pages, … }) collapses a biome into one
declarative page list. Each page's slug single-sources both the nav route
and the route path, so they can never drift. The helper emits one route per page
(wrapping the lazily-loaded module in a <Suspense> boundary) and one nav item
(unless navHidden), and passes init / dispose / panels / session /
outputRenderers straight through to the FrontendBiome.
1import { defineWebBiome } from '@xemahq/ui-kernel';
2import { Layers } from 'lucide-react';
3
4export default defineWebBiome({
5 id: 'spaces-web',
6 displayName: 'Spaces',
7 pages: [
8 {
9 slug: 'system/spaces',
10 label: 'Spaces',
11 icon: Layers,
12 category: 'knowledge', // primary | build | operate | knowledge | admin | account
13 load: () => import('./pages/SpacesPage'),
14 },
15 ],
16});Each WebBiomePage accepts:
| Field | Purpose |
|---|---|
slug | Single source of truth for the nav route and the route path. May carry :param segments (e.g. system/concepts/:slug). |
id | Stable nav-item id for active-state + analytics. Defaults to slug; set it only to keep a short id (e.g. grants) distinct from a longer route slug. |
label | Displayed nav label. |
icon | Optional nav icon (ComponentType<{ className? }> — any lucide/heroicons/SVG kit). |
category | Semantic menu category the surface declares its intent to live in — see below. |
scope | 'org' (default; mounts at the org root) or 'project' (mounts under /projects/:projectId). |
access | Route access tier — 'member' (default), 'org-admin', or 'platform-admin'. |
load | Dynamic import of the page module: () => import('./pages/X'). |
navHidden | Register the route with no nav item (e.g. a :param detail route reached only by deeplink). |
weight | Legacy intra-category tiebreak. Prefer host-owned category ordering. |
The factory shape keeps biome code free of any host-shell primitive (router,
auth client, toast) — which is what lets the same package load into today's
Next.js host and a different host tomorrow. Biomes reach the host only
through the kernel contract types and the HostBridge.
How the host discovers and loads a web biome
- Build-time discovery.
build/generate-biome-loaders.mjsglobsbiomes/<tier>/<id>/xema-biome.json, filters toxema.target === 'web', and emits a static loader map atsrc/lib/biomes/generated/web-biome-loaders.ts. Adding a biome directory is sufficient — no host edits. - Runtime bootstrap.
bootstrapBiomes()(src/lib/biomes/bootstrap.ts) fetchesGET /platform/biomes/webfor the enabled set, dynamicallyimport()s each enabled biome, and registers it viaregisterFrontendBiome(factory, bridge)from@xemahq/ui-kernel. A biome enabled in the catalog but missing from this build's loader map is surfaced asunavailable_in_buildand skipped — never a hard failure. (A second,REMOTE_FEDERATEDpath can load a biome over Module Federation at runtime; both feed the same register path.) - Registration. The singleton
biomeRegistryholds every registered biome; host surfaces subscribe to its revision counter so they re-render the moment a biome lands after first paint.
Routes and route access
The host mounts two App-Router catch-all resolver segments that forward the
captured slug to BiomeRouteResolver:
- Org scope —
src/app/(authed)/[...biomeSlug]/page.tsx - Project scope —
src/app/(authed)/projects/[projectId]/[...biomeSlug]/page.tsx
BiomeRouteResolver (src/lib/biomes/biome-host-next/) walks the registry for
contributions whose projectScoped matches the segment's scope and matches the
remainder against each RouteContribution.path via matchRoute — a tiny,
deterministic segment-by-segment matcher where :name segments bind params. The
first match wins; its element() renders inside a params provider so the page
reads its :params (and, for project scope, projectId) through
bridge.navigation.useRouteParams() — never a router primitive. No match once
bootstrap has settled → the Next notFound() boundary.
RouteAccess is a closed set enforced declaratively at the resolver (least →
most privileged): member < org-admin < platform-admin. Nav-hiding is
not a security boundary — a deeplink reaches the resolver directly, so an
insufficient subject gets a clean "access denied" surface. Backend capability
checks remain the ultimate authority.
Nav categories — you declare WHAT, the host owns WHERE
A page's category is the semantic menu group the biome declares its intent
to live in. The closed set is:
primary | build | operate | knowledge | admin | account
The host owns the menu information-architecture: src/lib/nav-registry.ts
defines the NavCategory taxonomy and maps each category to a concrete rail
group, heading, and order (NAV_GROUPS). Authors declare what a surface is,
not where it renders — so the platform can re-organise the global menu without
editing every biome. When a page omits category, the host falls back to its own
id-keyed taxonomy, then to the legacy section. An unresolvable category is
dropped and logged (fail-loud), never silently mis-placed.
The per-biome
section/weightfields are deprecated — they predate host-owned categories and remain only for un-migrated biomes. New biomes declarecategory.
Slots and panels
Beyond full pages, a biome injects fragments into named host insertion points by
contributing panels[]. The host renders them with <BiomeSlot name="…">,
sorted by weight; when no biome contributes, the slot renders its fallback (or
nothing). Slot names are a typed catalog exported as HostExtensionSlots from
@xemahq/ui-kernel/registry; a duplicate slot registration fails fast.
Shipped slot names (src/lib/shared/ui-kernel/registry/lib/extension-points.ts):
HostExtensionSlots key | Slot name | Where it renders |
|---|---|---|
OrgHeaderActions | org-header-actions | Org shell header action cluster |
ProjectOverviewCards | project-overview-cards | Project home overview grid |
ProjectOverviewSecondary | project-overview-secondary | Secondary project-home column |
BiomesOverviewCards | biomes-overview-cards | Biomes admin overview |
DashboardEmptyState | dashboard-empty-state | Dashboard empty-state CTA row |
RunDetailActions | run-detail-actions | Run detail action cluster |
RunDetailSidePanel | run-detail-side-panel | Run detail right panel |
SessionActions | session-actions | Agent-session action cluster |
SessionSidePanel | session-side-panel | Agent-session side panel |
SessionRightHeader | session-right-header | Left of the session's built-in header controls |
SettingsTabs | settings-tabs | Settings tab strip |
A panel is { slot, id, render: () => ReactNode, weight? }:
1import { HostExtensionSlots } from '@xemahq/ui-kernel/registry';
2
3panels: [
4 {
5 slot: HostExtensionSlots.ProjectOverviewSecondary,
6 id: 'acme-support/overview-card',
7 render: () => <SupportOverviewCard />,
8 weight: 500,
9 },
10],Page header actions
A page drives the host topbar — title, description, help-actions, back
affordance, and a right-aligned action cluster — through
bridge.pageMeta.usePageMeta(...), not a slot. The host pushes the meta on
mount and pops it on unmount:
1function SupportDashboard(): JSX.Element {
2 const bridge = useHostBridge();
3 bridge.pageMeta.usePageMeta({
4 title: 'Support',
5 description: 'Triage inbound customer issues.',
6 actions: ['Filter by severity', 'Assign to a teammate'],
7 topbarActions: <NewTicketButton />,
8 });
9 return /* … */;
10}The HostBridge
Every biome reaches host services through ONE host-agnostic object — the
HostBridge the factory receives, also available inside components via
useHostBridge() (@xemahq/ui-kernel). Biome UI consumes the platform through
the bridge plus generated Orval clients — never ad-hoc fetch, direct router
manipulation, or its own auth client.
| Bridge member | What it provides |
|---|---|
navigation | push / replace, and the router-agnostic hooks useLocation, useRouteParams, useSearchParams. |
auth | getActorToken / getOrgId / getProjectId / getUserId, plus host-owned token freshness (ensureFreshToken) and onUnauthorized 401 recovery — wire an Orval client's getAuthToken against these. |
queryClient | The shared @tanstack/react-query QueryClient. |
toast | success / error / info, each with an optional detail line. |
pageMeta | usePageMeta(...) — the topbar/page-chrome surface (see above). |
system | The SystemBus (@xemahq/ui-kernel) — capability invocation, cross-biome intents, the command palette, xema:// deeplinks, and windowing. Pure orchestration: it NEVER authorizes; the backend capability-router is the sole authority on auth / tenancy / policy / audit. |
capabilities | Server-authoritative capability port (GET /bff/me/capabilities). Gate UI on the effective capability set via <CapabilityProvider> + useCapability() — not cosmetic role booleans. |
requestContext | The current correlationId for request tracing. |
realtime (optional) | CloudEvents-over-SSE source; reach it through the kernel useCloudEvent / useRealtimeStatus / useEventScope hooks. Absent hosts omit it and those hooks fail fast. |
errors (optional) | Canonical user-facing error decoder; falls back to the kernel default getUserFacingErrorMessage. |
Session-shell contributions and output renderers
A biome extends the agent-session shell declaratively via the session
contributions object (session profiles, slash commands, secondary-drawer tabs,
header chips, mutation bars, activity/tool-call renderers, attachment classes)
and can bind a biome-defined artifact OutputKind to a renderer via
outputRenderers[] (overlaid on the built-in set; built-in kinds cannot be
overridden, new kinds light up when the biome loads). The reference biome
template-third-party-web (shipped with xema-host-web) exercises every
contribution kind — fork it to bootstrap a real biome.
Embedded / external-subject routes
For a session rendered inside an external website (the
App + Audience layer), the host serves the embed routes
(embedded)/embedded/session/[token] and (embedded)/embedded/shell outside
the authenticated app shell — no Xema identity assumed, host chrome (rail,
topbar, branding) stripped. The host hook useExternalSubject() detects the
/embedded/* prefix and exposes the delegated session's verified claims
(capabilities, environment, canCall(ref)), so an embedded surface
constrains itself to exactly the caller's capability set. Treat
isEmbedded && claims === undefined as "verifying" and hold sensitive controls
until claims resolve.
Host capabilities and how fast a change takes effect
A web biome's requests to server APIs are held to the host capabilities the biome declares. The platform keeps each biome's declared set up to date from the packages installed for the org or person, and a service refuses a request for a capability the set does not contain.
The set is refreshed on a bounded window: an uninstall, a narrowing of the
declared capabilities, or a revocation takes effect within 35 seconds (a
5-second refresh plus a 30-second freshness bound). The check fails closed: if
a service cannot confirm that its copy of the set is current, it answers
503 with the code WEB_BIOME_HOST_CAPABILITIES_NOT_SYNCHRONIZED and admits
nothing from the biome until it can. Retry shortly.
The window is not the way to cut off a caller immediately. To stop a caller now, revoke the token: token revocation remains the immediate path and does not wait for the next refresh.
Related pages
- Manifest reference — the
xema-biome.jsonfields, includingtargetandscope - Apps — how external-subject embedding works
- Shell — the terminal shell and its WS protocol
- Capabilities — how the SystemBus +
useCapability()gating is enforced
Previous: ← Publishing Next: Events I subscribe →