Web UI

Ship a React UI the host shows for your app, styled with host design tokens.

Agent-readable version: Markdown · llms.txt

A Rome app can ship a React web bundle that the host shows at /apps/<appId>. The frontend SDK @rome-os/app-web-sdk gives an app the runtime helpers it uses to start up, call its own backend, move between views, and style itself correctly inside the shadow DOM the host puts it in.

This page covers the public web-author API: the web entry point, calling the app API, routing and host deep-links, and styling under shadow DOM.

The web entry

The web entry lives at src/web/App.tsx. It must default-export a React component that takes a single { bootstrap } prop. The SDK and the generated entry take care of mounting, adding styles, and wrapping in the shadow DOM — the app only writes the component.

import {
  fetchAppApi,
  getCurrentAppPath,
  navigateToApp,
  navigateRome,
  subscribeToAppPath,
  buildAppUrl,
  startChat,
  type RomeAppBootstrap,
} from "@rome-os/app-web-sdk";
import "./styles.css";

export default function App({ bootstrap }: { bootstrap: RomeAppBootstrap }) {
  // bootstrap.appId / bootstrap.version / bootstrap.routeBase / bootstrap.shell
  return <main>...</main>;
}

RomeAppBootstrap

The host puts a bootstrap object on window.__ROME_APP_BOOTSTRAP__ and passes it to the component. You can also reach it outside React via getBootstrap().

interface RomeAppBootstrap {
  appId: string;
  version: string;
  routeBase: string;   // e.g. "/apps/<appId>"
  routePath: string;   // the initial in-app sub-route
  apiBase: string;     // base for fetchAppApi
  assetBase: string;   // base for bundled assets
  shell: {
    locale: string;
    theme: "light" | "dark";
    themeName: string;
    mode: "embedded" | "full" | "preview";
  };
  caller?: RomeAppCaller;             // host-resolved identity — what useCaller() reads
  globalParams?: RomeAppGlobalParams; // live host params (chat session, interaction flag)
}

getBootstrap() (and the helpers that call it inside) throw "Rome app bootstrap is not available" when there is no bootstrap — that is, outside a real mount, such as in tests or SSR.

Shadow DOM mounting and styles

The host mounts each app inside a shadow root and puts the app's compiled CSS into it. The SDK owns the mount node and saves it on mount via setMountContainer(node) (and clears it with setMountContainer(null) on unmount). That saved node is what getPortalContainer() returns, so floating UI can render back inside the styled shadow root (see Styling and shadow DOM).

Because the app mounts in a shadow root — not an iframe — it shares window with the host. Host deep-link navigation is done with a window CustomEvent rather than postMessage.

Calling the backend

Use fetchAppApi(path, init?) to call this app's own HTTP API. It joins path onto the app's apiBase (/api/apps/<appId>/...), lets you put a query string inside path, cleans up path and query encoding, and returns a standard fetch Response.

// GET /api/apps/<appId>/state
const res = await fetchAppApi("state");
const data = await res.json();

// query strings are supported inside the path
const repos = await fetchAppApi("repos?limit=50");

// pass through any RequestInit
await fetchAppApi("items", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title: "New" }),
});

By default these calls carry the guardian's dashboard session. If the app is shared with anonymous visitors or Rome Cloud guests, see Public Access & Caller Identity for what opens up and how the backend can still tell the owner apart.

Knowing who is looking with useCaller()

useCaller() (or the non-hook getCaller()) returns the host-resolved identity of whoever opened this surface — { kind: "guardian" }, { kind: "visitor", accountId, email }, or { kind: "anonymous" } — and null when unknown (e.g. preview mounts). Use it to show or hide owner-only UI without probing a route:

import { useCaller } from "@rome-os/app-web-sdk";

const isOwner = useCaller()?.kind === "guardian";
// {isOwner && <AdminPanel />}

This is advisory, for UI gating only — any client can run arbitrary code, so every owner-only API route must also check request.caller server-side in your API handler. See Public Access & Caller Identity.

Don't pass an empty path

Never call fetchAppApi("") or fetchAppApi("/"). An empty or /-only path turns into the bare apiBase (/api/apps/<appId>), which is the platform's deployment-status endpoint — not your app API. Always pass a clear subroute, e.g. fetchAppApi("home") or fetchAppApi("state").

Scaling back in preview with isPreview()

isPreview() returns true when the app is mounted by a preview host (the Rome App Store's try-in-browser page) instead of a real Rome instance — found via bootstrap.shell.mode === "preview". A preview mount has no backend: agents, chat, the app API, and storage are not there, so fetchAppApi() and startChat() fail with 503.

Gate any controls that need the backend on it, so the UI scales back cleanly instead of erroring:

import { isPreview } from "@rome-os/app-web-sdk";

<button disabled={isPreview()} onClick={sendToAgent}>
  Send to agent
</button>;

isPreview() returns false before the bootstrap is available (tests/SSR), where treating it as a real host is the safe default.

The app is mounted at /apps/<appId>/*, so any sub-path is a deep-link to a specific view. Build detail/item views as their own routes (/apps/<appId>/<detailId>) so links land straight on one item.

In-app routing

APIPurpose
getCurrentAppPath()Returns the current in-app sub-route, e.g. "settings". Returns "" at the app root.
subscribeToAppPath(cb)Listens for in-app route changes; returns a function to stop listening. Fires on popstate and on navigateToApp.
navigateToApp(path, { replace? })Moves to an in-app sub-route without a reload via pushState (or replaceState with replace: true) and sends a navigation event.
buildAppUrl(path)Builds the full in-app URL for path, for sharing.
buildAssetUrl(path)Builds the URL of a bundled asset under the app's assetBase — use it instead of hand-joining bootstrap.assetBase.
import { getCurrentAppPath, subscribeToAppPath, navigateToApp } from "@rome-os/app-web-sdk";
import { useEffect, useState } from "react";

function useAppRoute() {
  const [path, setPath] = useState(getCurrentAppPath());
  useEffect(() => subscribeToAppPath(setPath), []);
  return path;
}

// navigate to /apps/<appId>/T-1423
navigateToApp("T-1423");

Paths are cleaned up and checked: ./.., backslashes, and parts that can't be decoded are rejected with Invalid app-relative path.

Host routes with navigateRome

navigateRome(payload) deep-links into a host-owned route. The payload is a typed union, so a missing or wrong-typed param is a compile-time error rather than a runtime 404. It moves without a reload and without losing state, done as a window CustomEvent the host shell routes through react-router. It does nothing, cleanly, outside a browser.

PayloadDestination
{ path: "chat", sessionId }An existing chat session.
{ path: "chat/new", draft?, skill? }A fresh, un-sent composer. draft fills in the message ahead of time; skill pins a skill chip ahead of time (a canonical <app-id>:<local-name> catalog skill ID, sent as a field).
{ path: "settings", tab? }Settings, optionally a specific tab.
{ path: "memory", file? }Memory, optionally a specific file.
{ path: "apps" }The Apps dashboard.
{ path: "apps", appId, subPath? }Another app's page (optionally a sub-route).
{ path: "people" } / { path: "projects" } / { path: "routines" } / { path: "desktop" }The corresponding host page.
import { navigateRome } from "@rome-os/app-web-sdk";

// land the guardian on a filled-in composer they review before sending
navigateRome({
  path: "chat/new",
  draft: "Summarize my week",
  skill: "planning:weekly-review",
});

// deep-link into another app's detail view
navigateRome({ path: "apps", appId: "tickets", subPath: "T-1423" });

For chat/new, draft and skill travel as react-router location.state, so they never show up in the URL.

startChat and inline components

startChat(opts) is a higher-level helper that creates a fresh webchat session, posts opts.message as the first user turn (which kicks off the agent), and — unless navigate: false — sends the host to /chat/<sessionId>. It returns { sessionId }.

interface StartChatOptions {
  message: string;
  agentName?: string;   // canonical <app-id>:<name>; omit → default host agent
  projectPath?: string; // anchor the session to a project
  navigate?: boolean;   // false → create without leaving the app
  skillName?: string;   // canonical <app-id>:<name> catalog skill ID
}

const { sessionId } = await startChat({
  message: "Review the open PRs",
  agentName: "my-app:reviewer",
});

It throws if the session can't be created, so wrap the call. The first-turn POST is awaited (so the guardian's own message is saved before the chat page's first history fetch); if that POST fails, it is logged but does not block navigation.

Pick between the two ways to start a chat based on whether the first message should send on its own:

  • startChat — the app sends the first message itself (fire-and-go).
  • navigateRome({ path: "chat/new", ... }) — the guardian lands on a filled-in composer and reviews before sending; it is also the only way to pin a skill chip ahead of time.

Inline components

An app can also render its own interactive components inline inside the webchat transcript. List each component's id in app.yaml (components: [...]) and register a renderer when the bundle loads with defineComponent:

import { defineComponent, type AppComponentContext } from "@rome-os/app-web-sdk";

defineComponent("confirm-dialog", (container, ctx: AppComponentContext) => {
  // mount your UI into `container`; ctx.props are the action's seed props
  const root = createRoot(container);
  root.render(<Confirm host={ctx.host} {...ctx.props} />);
  return () => root.unmount(); // optional cleanup on unmount
});

When an action returns pendingInteraction with render.kind: "inline", the host mounts this bundle in a chat-row shadow root and calls the matching renderer. The renderer gets an AppComponentContext with bootstrap, props, an optional earlier result, and a host bridge:

  • host.submit(output, summary?) — send a result back to the agent that rendered the component; it arrives as the next turn and resumes the agent.
  • host.dismiss() — close with no result; the agent resumes with a dismissed outcome.
  • host.resolvedtrue once resolved, so the renderer can lock to read-only.

Inline components run in the same page context as the host (no postMessage). This is different from a suspendable-action screen (a full-page iframe mount), which uses isInteractionSurface(), resolveInteraction(), and dismissInteraction().

Host context: chat session and global params

Beyond the static bootstrap, the host delivers a small set of live runtime params — RomeAppGlobalParams — out-of-band: seeded into bootstrap.globalParams for same-window mounts, or posted via postMessage (after a ready handshake) for iframe mounts. Today it carries two fields:

interface RomeAppGlobalParams {
  chatSessionId?: string; // the host chat session this surface is bound to
  interaction?: boolean;  // true when mounted as a suspendable-action surface
}
APIPurpose
getGlobalParams() / useGlobalParams()The current params (non-hook / React hook).
getChatSessionId() / useChatSessionId()Just the bound chat session id, or null when unbound or not yet delivered.
subscribeGlobalParams(cb)Listens for param changes; returns a function to stop listening.

chatSessionId is the host's session — e.g. the chat a widget was placed in — and is the right key for per-session app state. It is deliberately not carried on the app's URL: the query string is the app's own namespace (your app can define its own ?session= with its own meaning), so the host never reserves keys in it.

Watch out: treat the values as live. On an iframe mount the session id arrives (and can change) shortly after mount, so never assume it is present on first paint — read it through the hooks and let the UI react.

import { useChatSessionId } from "@rome-os/app-web-sdk";

function Widget() {
  const sessionId = useChatSessionId();
  if (!sessionId) return <Placeholder />; // unbound, or not delivered yet
  return <SessionNotes sessionKey={sessionId} />;
}

The interaction flag is what isInteractionSurface() / useIsInteractionSurface() read; use those helpers rather than checking the flag yourself.

Styling and shadow DOM

Token layer + Tailwind/shadcn

Tailwind v4 and shadcn/ui come pre-installed. The app's styles.css should stay a thin one-line import of the SDK style layer, with your app's own rules below it:

@import "@rome-os/app-web-sdk/styles";

/* your app's own rules below */

The SDK style layer pulls in Tailwind and the host theme tokens. The key design choice: an app owns no token values. The meaning tokens (--background, --foreground, --primary, --radius, …) are inherited custom properties that cross the shadow boundary from the Rome host. The host toggles .dark on an ancestor of the mount, so var(--background) and friends resolve to whatever the shell has set right now and repaint live when the theme changes. A UI built only from these tokens looks right in every theme and mode, with no dark: variants.

The SDK's @theme inline block is the full set of tokens you may paint with — surface tokens (bg-background, bg-surface, bg-card, bg-popover), text tiers (text-foreground, text-muted-foreground, text-subtle-foreground), primary / brand / accent, the status families (destructive / success / warning / info, each with a solid -foreground and soft -bg / -fg / -border parts), borders/ring/input, the radius-* scale, and the font-sans / font-mono / font-serif families. Anything outside this set — raw shades (bg-gray-100), raw overlays (bg-black/40), one-off colors — is a step backward; use bg-overlay for overlays, not bg-black/40.

:host vs :root

Because the app mounts in a shadow root, custom CSS variables must be written under :host, not :root. The host scopes an app's overrides to :host — a direct declaration on the shadow host that beats the inherited host value — so to override the theme, re-declare token values in a :root block (and .dark if needed) in your own styles.css; the host moves them to :host for you. The SDK ships no default token values, precisely because a default would look the same as a deliberate override and would quietly overwrite the host theme.

Global selectors (body, html, *) in styles.css do not reach inside the shadow root — scope everything to :host or a selector below it.

getPortalContainer() for floating UI

Floating UI (dialogs, popovers, tooltips, menus) usually portals to document.body so it can't be cut off — but that puts it outside the shadow root, where the app's styles don't reach, so it renders half-styled. Pass the app's mount node instead:

import { getPortalContainer } from "@rome-os/app-web-sdk";

<Popover.Portal container={getPortalContainer()}>
  <Popover.Content>...</Popover.Content>
</Popover.Portal>;

getPortalContainer() returns the mount node inside the shadow root, or undefined before mount (tests/SSR), where the portal library's document.body default is the right thing.

On dev hosts (localhost, 127.0.0.1, *.localhost), the SDK sets up a dev-only guard: a MutationObserver warns once in the console when a floating layer ([role=dialog], [role=menu], [data-radix-popper-content-wrapper], etc.) is added to document.body — the sign of a portal that forgot container={getPortalContainer()}. It stays quiet in production.

Input/selector quirks

Several browser APIs point at the shadow host, not the element inside the shadow root. Plan for this:

  • document.activeElement points to the shadow host, not the focused element. Use shadowRoot.activeElement instead.

  • document.pointerLockElement always points at the shadow host, so a strict check like document.pointerLockElement === canvas is always false, even when the lock is on. Check against the canvas, its ancestors, and the shadow root's own pointerLockElement:

    function isPointerLocked(canvas: HTMLElement): boolean {
      const el = document.pointerLockElement;
      return (
        el === canvas ||
        (el !== null && el.contains(canvas)) ||
        shadowRoot?.pointerLockElement === canvas
      );
    }

    where shadowRoot is canvas.getRootNode() cast to ShadowRoot. Pointer lock is also denied in some sandboxes — add a mouse-drag fallback (pointerlockerror, a rejected requestPointerLock(), and a ~450 ms timeout) so things still work.

  • Fake new MouseEvent(...) dispatches are untrusted and may be ignored by canvas engines that check event.isTrusted. For headless/integration tests, drive input through the CDP Input.dispatchMouseEvent command so events arrive trusted.

On this page