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.
Routing and deep links
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
| API | Purpose |
|---|---|
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.
| Payload | Destination |
|---|---|
{ 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.resolved—trueonce 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
}| API | Purpose |
|---|---|
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.activeElementpoints to the shadow host, not the focused element. UseshadowRoot.activeElementinstead. -
document.pointerLockElementalways points at the shadow host, so a strict check likedocument.pointerLockElement === canvasis alwaysfalse, even when the lock is on. Check against the canvas, its ancestors, and the shadow root's ownpointerLockElement:function isPointerLocked(canvas: HTMLElement): boolean { const el = document.pointerLockElement; return ( el === canvas || (el !== null && el.contains(canvas)) || shadowRoot?.pointerLockElement === canvas ); }where
shadowRootiscanvas.getRootNode()cast toShadowRoot. Pointer lock is also denied in some sandboxes — add a mouse-drag fallback (pointerlockerror, a rejectedrequestPointerLock(), and a ~450 ms timeout) so things still work. -
Fake
new MouseEvent(...)dispatches are untrusted and may be ignored by canvas engines that checkevent.isTrusted. For headless/integration tests, drive input through the CDPInput.dispatchMouseEventcommand so events arrive trusted.