# Web UI
URL: /docs/building-apps/capabilities/web-ui
Markdown: /docs/building-apps/capabilities/web-ui.md
Description: Ship a React UI the host shows for your app, styled with host design tokens.

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]

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.

```tsx
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` [#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()`.

```ts
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 [#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](#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 [#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`.

```ts
// 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](/docs/building-apps/public-access) for what
opens up and how the backend can still tell the owner apart.

### Knowing who is looking with `useCaller()` [#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:

```tsx
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](/docs/building-apps/public-access).

### Don't pass an empty path [#dont-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()` [#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:

```tsx
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 [#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 [#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`.                |

```tsx
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` [#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.                                                                                                                                                                |

```ts
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-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 }`.

```ts
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 [#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`:

```ts
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` — `true` 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 [#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:

```ts
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.

```tsx
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 [#styling-and-shadow-dom]

### Token layer + Tailwind/shadcn [#token-layer--tailwindshadcn]

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:

```css
@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: &#x2A;*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` [#host-vs-root]

Because the app mounts in a shadow root, &#x2A;*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 [#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:

```tsx
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 [#inputselector-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`:

  ```ts
  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.