The dashboard for an agent runtime is the thing operators reach for first. Sessions, agents, tools, recent events. Without it, you’re SSH’d into a host and tailing journalctl.

openclaw-rs ships the dashboard inside the gateway binary. One port, one process, one URL.

The two-crate setup

crates/
  openclaw-ui/          # Vue 3 + Vite, builds to dist/
    src/
    package.json
    vite.config.ts
  openclaw-gateway/     # axum, embeds the ui crate's dist/
    src/
    Cargo.toml

openclaw-ui is a normal Vite SPA. openclaw-gateway declares it as a build dependency in a build script and embeds the resulting dist/ via rust-embed.

The build script

// crates/openclaw-gateway/build.rs
use std::process::Command;

fn main() {
    println!("cargo:rerun-if-changed=../openclaw-ui/src");
    println!("cargo:rerun-if-changed=../openclaw-ui/package.json");

    let status = Command::new("bun")
        .arg("run").arg("build")
        .current_dir("../openclaw-ui")
        .status()
        .expect("failed to build openclaw-ui");
    assert!(status.success(), "openclaw-ui build failed");
}

cargo build triggers the Vite build before compiling the gateway. If openclaw-ui files change, cargo knows to rebuild.

The embed

use rust_embed::RustEmbed;

#[derive(RustEmbed)]
#[folder = "../openclaw-ui/dist/"]
struct Dashboard;

async fn dashboard_handler(uri: Uri) -> impl IntoResponse {
    let path = uri.path().trim_start_matches('/');
    let path = if path.is_empty() { "index.html" } else { path };

    match Dashboard::get(path) {
        Some(content) => {
            let mime = mime_guess::from_path(path).first_or_octet_stream();
            ([(CONTENT_TYPE, mime.as_ref())], content.data).into_response()
        }
        None => fallback_html().into_response(), // SPA fallback for client routing
    }
}

The SPA fallback (fallback_html()) returns the embedded index.html for any path the router doesn’t recognise, so client-side routing works.

What the dashboard does

The current scaffold covers:

  • Sessions — list, drill into the event log, replay a session.
  • Agents — registered agents, status, last activity.
  • Tools — what’s registered, last invocation, success rate.
  • Channels — Telegram connection status.
  • Health — gateway, sled, credential store, sandbox backend.

It talks to the gateway over the same JSON-RPC /rpc endpoint your other clients use. There’s no admin-only API surface.

Why this is better than a separate UI service

Three concrete wins:

  1. One thing to deploy. No second container, no second port, no second TLS cert.
  2. Versions can’t drift. The dashboard build is locked to the gateway build by cargo. You can’t ship a UI that calls a method the gateway doesn’t have.
  3. Bootstrap latency disappears. A fresh install with openclaw onboard lands on a working dashboard in one command.

When you’d unbundle

If you want a dashboard hosted on a CDN — for offline review of session logs you’ve exported, for a corporate-style admin portal — openclaw-ui is a normal Vite project. You can deploy it as static files and point it at any gateway.

The embedded build is just the default that makes “I want to see what’s happening” a zero-config operation.

The bigger pattern

This is the broader pattern in openclaw-rs: defaults that get you to “working” with no extra moving parts, but every component is also useful standalone if you want to break it out.

  • The gateway works without the embedded UI (it’s still a JSON-RPC server).
  • The UI works without the embedded build (Vite dev server pointed at any gateway).
  • The Rust crates work without the gateway (use them in your own service).
  • The Node bindings work without anything else (Node + napi-rs binary).

One binary by default. As many pieces as you need otherwise.