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/registry subpath.

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:

jsonc
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.

tsx
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:

FieldPurpose
slugSingle source of truth for the nav route and the route path. May carry :param segments (e.g. system/concepts/:slug).
idStable 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.
labelDisplayed nav label.
iconOptional nav icon (ComponentType<{ className? }> — any lucide/heroicons/SVG kit).
categorySemantic 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).
accessRoute access tier — 'member' (default), 'org-admin', or 'platform-admin'.
loadDynamic import of the page module: () => import('./pages/X').
navHiddenRegister the route with no nav item (e.g. a :param detail route reached only by deeplink).
weightLegacy 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

  1. Build-time discovery. build/generate-biome-loaders.mjs globs biomes/<tier>/<id>/xema-biome.json, filters to xema.target === 'web', and emits a static loader map at src/lib/biomes/generated/web-biome-loaders.ts. Adding a biome directory is sufficient — no host edits.
  2. Runtime bootstrap. bootstrapBiomes() (src/lib/biomes/bootstrap.ts) fetches GET /platform/biomes/web for the enabled set, dynamically import()s each enabled biome, and registers it via registerFrontendBiome(factory, bridge) from @xemahq/ui-kernel. A biome enabled in the catalog but missing from this build's loader map is surfaced as unavailable_in_build and skipped — never a hard failure. (A second, REMOTE_FEDERATED path can load a biome over Module Federation at runtime; both feed the same register path.)
  3. Registration. The singleton biomeRegistry holds 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.


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 / weight fields are deprecated — they predate host-owned categories and remain only for un-migrated biomes. New biomes declare category.


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 keySlot nameWhere it renders
OrgHeaderActionsorg-header-actionsOrg shell header action cluster
ProjectOverviewCardsproject-overview-cardsProject home overview grid
ProjectOverviewSecondaryproject-overview-secondarySecondary project-home column
BiomesOverviewCardsbiomes-overview-cardsBiomes admin overview
DashboardEmptyStatedashboard-empty-stateDashboard empty-state CTA row
RunDetailActionsrun-detail-actionsRun detail action cluster
RunDetailSidePanelrun-detail-side-panelRun detail right panel
SessionActionssession-actionsAgent-session action cluster
SessionSidePanelsession-side-panelAgent-session side panel
SessionRightHeadersession-right-headerLeft of the session's built-in header controls
SettingsTabssettings-tabsSettings tab strip

A panel is { slot, id, render: () => ReactNode, weight? }:

tsx
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:

tsx
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 memberWhat it provides
navigationpush / replace, and the router-agnostic hooks useLocation, useRouteParams, useSearchParams.
authgetActorToken / getOrgId / getProjectId / getUserId, plus host-owned token freshness (ensureFreshToken) and onUnauthorized 401 recovery — wire an Orval client's getAuthToken against these.
queryClientThe shared @tanstack/react-query QueryClient.
toastsuccess / error / info, each with an optional detail line.
pageMetausePageMeta(...) — the topbar/page-chrome surface (see above).
systemThe 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.
capabilitiesServer-authoritative capability port (GET /bff/me/capabilities). Gate UI on the effective capability set via <CapabilityProvider> + useCapability() — not cosmetic role booleans.
requestContextThe 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.

See Apps — embedding an app.


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.


  • Manifest reference — the xema-biome.json fields, including target and scope
  • 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 →