# Build and develop

> Build the Control UI, run the dev server, and point it at a remote Gateway

- 网址：https://funcoding.ai/agents/openclaw/web/control-ui/development/
- 来源：OpenClaw 官方文档原文（英文），MIT 许可，同步于 2026-10-11
- 官方原文：https://docs.openclaw.ai/zh-CN/web/control-ui/development

---
Contributor notes for building the Control UI and running it against a Gateway you choose.

Every command on this page runs from a checkout of the `openclaw/openclaw`
repository with `pnpm install` already done. A packaged CLI install has no
`pnpm` scripts.

## Build and develop the UI

The Gateway serves static files from `dist/control-ui`:

```bash
pnpm ui:build
```

The build's performance report keeps the initial-entry JavaScript budget separate
from the complete chat and new-session boot totals. Those route totals include
the entry assets and the route's measured immediate dynamic imports, counting
each asset once even when both preload lists reference it. Moving an existing
boot import into the first request wave therefore remains visible in the byte
accounting. The initial-entry ceilings and baseline are unchanged; route byte totals
are reported without introducing a higher limit. `--base-dist` on
`scripts/check-control-ui-performance.mts` compares route bytes and requests when
both builds contain route preload templates. Older builds report that comparison
as unavailable, so use a cold-load network capture to compare their complete boot
cost.

Route boot JavaScript is limited to 35 requests per route, with three requests of headroom above the measured maximum, to catch facade regressions caused by top-level await disabling chunk optimization.

After a Gateway update, already-open tabs reload the current build when they encounter a missing old asset. Reloading is automatic when no unsaved-work guard blocks it; otherwise, save or discard the protected work and use the **Reload** banner. The Gateway and service worker keep only the current build's assets.

Bundled public assets (themes, fonts, icons, and artwork) use `?v=<build-id>` URLs with a one-year immutable HTTP cache. The ID includes a digest of the public files, so rebuilding changed files at the same commit also changes their URLs. The Gateway snapshots this identity at startup; restart it after rebuilding an in-place installation. Unversioned requests, stale IDs, documents, `sw.js`, and custom `gateway.controlUi.root` installs keep `Cache-Control: no-cache`. The service worker caches matching current-build public URLs for offline reuse.

The Gateway shares prepared bundled asset bytes across browsers, including Brotli and gzip variants. Cold file admission and reads run in a worker so simultaneous page loads do not block chat delivery. Custom roots continue to read current files on each request.

Non-index static assets use `Last-Modified` for conditional `GET` and `HEAD` requests. `If-None-Match` takes precedence over `If-Modified-Since`: `*` matches an existing asset, while other values receive the normal `200` response because static assets do not emit ETags. Date-only revalidation still returns `304` for unchanged assets. If no available content encoding is acceptable, the Gateway returns `406` before evaluating either condition.

All three HTTP-date formats are interpreted as UTC. Invalid or repeated `If-Modified-Since` fields are ignored, so they cannot suppress the current asset bytes. A leap-second validator remains earlier than the following second.

Static asset URLs support percent-encoded filenames. Contained symlinks retain the requested asset's MIME type, and a symlinked `index.html` receives the same base-path and document preparation as other entry routes.

Optional absolute base (fixed asset URLs):

```bash
OPENCLAW_CONTROL_UI_BASE_PATH=/openclaw/ pnpm ui:build
```

Local development (separate dev server):

```bash
pnpm ui:dev
```

Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).

To use the real Gateway's capabilities, media, and native plugin UI while
developing, select its URL when starting Vite:

```bash
OPENCLAW_UI_DEV_GATEWAY_URL=http://127.0.0.1:18789 pnpm ui:dev --host 127.0.0.1 --port 5173
```

Configured development binds to loopback by default. Open `http://127.0.0.1:5173` and use the Gateway's normal authentication and
device pairing. The target accepts `http://`, `https://`, `ws://`, or `wss://`,
including a configured Gateway base path. Keep credentials out of this setting.
Add the exact browser origin to `gateway.controlUi.allowedOrigins` when needed;
the development proxy preserves the browser's Origin and does not provide
authentication credentials.

Vite serves the UI and its hot reload connection, and proxies Gateway requests.
The UI keeps credentials and saved settings scoped to the actual Gateway. An
owner pairing handoff can be delivered on the dev page while retaining its
original Gateway destination and fragment. To use another Gateway, restart
Vite with the new target; an already-open page cannot forward requests through
the replacement target. Reload the page after restarting Vite.

UI edits use Vite's normal hot reload or page reload. Start the Gateway's source
watch separately when developing its implementation. Stopping Vite stops its
proxy, without stopping the Gateway or deleting either application's state.
Without `OPENCLAW_UI_DEV_GATEWAY_URL`, `pnpm ui:dev` retains its existing
standalone connection behavior. This setting does not affect `pnpm ui:build`.

For a standalone preview with synthetic data, use:

```bash
pnpm dev:ui:mock -- --port 19321
```

Open the printed URL in a fresh Chromium profile or isolated browser context,
without existing service workers or operator credentials. Chat, presence, and
profile data are synthetic. Add `--fixture attachments` for media examples; the
printed board fixture URL is also available.

The mock preview selects its own origin for Gateway resources, including
avatars, before application startup. It supplies synthetic WebSocket responses
and confines native resource requests to the serving origin and local data/blob
fixtures, including frames, while preserving same-origin Vite HMR and terminal
WebAssembly. Unimplemented HTTP API routes return a local JSON 404; external
fetches are rejected with a standalone-mock diagnostic. New workers, Talk WebRTC,
popups, and external link/navigation actions are disabled in the mock app.
External iframe URL assignments are rejected before Chromium can speculatively
connect. Add a local fixture when a demo needs another response. Each invocation
owns a separate Vite cache and removes it on graceful shutdown, so concurrent
previews and attachment fixtures do not invalidate one another.

This is a trusted-fixture development boundary, not a sandbox for hostile HTML,
browser extensions, or an already-controlling service worker. Browser-level
navigation outside the app is outside its control. Production connection settings
and `pnpm ui:dev` behavior are unchanged; use that command when you intentionally
need a real Gateway or external integration.

## Chat input ownership

`ChatOutboxGatewayOwner` owns queued-input admission, updates, removal, and the
matching pane projections. Single-row changes and reordering share one durable
compare-and-set operation; queue callers do not publish separate storage and
display updates. Command completion uses the composer recovery owner to retain
or release draft attachments. Delivery waits for the full settings-update chain,
then continues admission synchronously so another picker update cannot enter
between settlement and transport.

## Chat render scheduling

Streaming deltas and session-roster notifications must not trigger unrelated
renders. The shell processes session deletion and document-title updates directly,
without rendering for roster publications. The chat stream owns its frame queue;
chat-page session subscriptions coalesce presentation updates through
`SubscriptionsController`. State synchronization stays immediate, while the Lit
commit runs inside the scheduled frame so child property bindings do not escape
into a later microtask. Disconnecting or replacing a subscription retires its
queued frame. Hidden documents retain immediate invalidation because animation
frames may be suspended.

The `chat-stream-runtime-budgets.e2e.test.ts` suite protects streaming with
structural update counts; chat-page unit tests cover intervening roster publications.

Shell callbacks retain their identity across renders so a background session update
does not redraw navigation twice. The outbox subscription still invalidates draft
and attention badges when their underlying facts change. Session-link decoration
preserves unchanged attributes instead of rewriting them on each roster update.

Transcript enhancement inspects inserted or changed Markdown blocks; settled code
blocks and tables do not need another scan when neighboring prose streams. Resize
observers own geometry changes. The command palette likewise retains its measured
input layout while navigating results, and remeasures edits, width changes, and
reconnected fields. Status clocks pause in hidden tabs and render only when their
displayed value or properties change.

The first connected, presented transcript commit renders two neighboring rows beyond each
viewport edge. The next animation frame restores the six-row scrolling buffer;
pending navigation, focus, or anchor reconciliation uses that full buffer immediately,
including neighboring controls such as folded-work headers. Focused and anchored
rows remain retained independently. Link-preview discovery
waits for browser idle time, then follows transcript mutations instead of rescanning
unchanged content on every pane update. Hiding or retiring the pane cancels pending
discovery.

Streaming Markdown retains normalized input, split progress, and rendered prefixes
in one bounded cache. Completed independent blocks render once; replacements,
locale or display-option changes, and document-wide Markdown dependencies invalidate
that reuse. Lists, reference definitions, containers, raw HTML, and colliding file labels
retain their whole-block or whole-prefix semantics and the existing parse limits.

Composer edits publish transcript resize notifications only when the viewport
height or corrected scroll offset changes. Draft growth, shrinkage, and end
anchoring still synchronize immediately. The position rail observes column width
and conversation-region height instead of measuring the gutter on every streamed
render; virtualizer and sidebar geometry changes retain their explicit sync path.
Rail labels are shared across mounted markers, so offscreen history does not add
translation work on each stream update.

Sidebar narration releases its session interests while hidden. Acquires and releases
share bounded exponential retry backoff with full jitter and server delay hints.
Failed releases retain their original handles, including late hidden acquisitions;
returning interests cancel queued releases and reacquire before retiring those handles.
The shared connection coordinator renews observers after uncertain unsubscribe timeouts
and preserves other viewers' leases and approval delivery. DOM detachment pauses timers
without dropping retained handles; connection closure retires them and cancels retries.

## Talk live smoke test

Maintainers can exercise the browser Talk paths end to end from the repository
root. Replace each placeholder with a real key:

```bash
OPENAI_API_KEY=<openai-key> GEMINI_API_KEY=<gemini-key> \
  node --import tsx scripts/dev/realtime-talk-live-smoke.ts
```

The run verifies the OpenAI backend WebSocket bridge, a synthesized PCM24
speech-to-response audio roundtrip, OpenAI browser WebRTC SDP exchange, Google
Live constrained-token browser setup with a JPEG frame and `describe_view`
function roundtrip, and the Gateway relay browser adapter with fake microphone
media. Pass `--openai-audio-cycles 3` for a short repeated OpenAI connect,
talkback, and close soak. The command prints provider status only and does not
log secrets.

## Debugging/testing: dev server + remote Gateway

The Control UI is static files; the WebSocket target is configurable and can differ from the HTTP origin. This is handy when you want the Vite dev server locally but the Gateway runs elsewhere.

**Start the UI dev server**

```bash
pnpm ui:dev
```

**Connect the remote Gateway**

Follow the [remote Gateway URL handoff](https://funcoding.ai/agents/openclaw/web/urls/#remote-gateway-handoff)
reference for the encoded Gateway URL and optional one-time credentials.

<details>
<summary>Origin security notes</summary>

- Public non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins` explicitly (full origins). Private same-origin LAN/Tailnet loads from loopback, RFC1918/link-local, `.local`, `.ts.net`, or Tailscale CGNAT hosts are accepted without enabling Host-header fallback.
- Gateway startup may seed local origins such as `http://localhost:<port>` and `http://127.0.0.1:<port>` from the effective runtime bind and port, but remote browser origins still need explicit entries.
- Do not use `gateway.controlUi.allowedOrigins: ["*"]` except for tightly controlled local testing; it means allow any browser origin, not "match whatever host I am using."
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode, but it is a dangerous security mode.

</details>

```json5
{
  gateway: {
    controlUi: {
      allowedOrigins: ["http://localhost:5173"],
    },
  },
}
```

Remote access setup details: [Remote access](https://funcoding.ai/agents/openclaw/gateway/remote/).
