Back to the lab
Lab Review

OpenClaw Dashboard: What I Fixed Before I Could Trust It

A deep review of the OpenClaw Dashboard — the open-source WebSocket control UI for the AI gateway OpenAI just acquired. Broken HMAC auth, no cost visibility, and two PRs that fix both.

Marco NahmiasApril 19, 202614 min read

OpenClaw is a genuinely important open-source project. It is the first self-hosted personal AI gateway that is not a toy — 80+ RPC methods, 17 event types, 50+ skills, integrations for WhatsApp, Telegram, Slack, Discord, iMessage, Teams, Signal. Its creator, Peter Steinberger, was acqui-hired by OpenAI in February 2026, which moved the project to an open-source foundation and turned every self-hosted-AI skeptic into a believer overnight.

The dashboard at actionagentai/openclaw-dashboard is the community's attempt to give all that power a visual interface. No database, no backend — a pure WebSocket client that connects directly to the gateway and renders whatever it receives. Twelve pages, one per CLI command domain. It is the right shape.

But the dashboard cannot actually be trusted against a hardened gateway until two things are fixed.

This is that review — and the two patches that fix the blockers.

01 · Install & first impressions

Thirty minutes, a stopwatch, a clean node 24.

git clone https://github.com/actionagentai/openclaw-dashboard
cd openclaw-dashboard
pnpm install   # 3.1s in a workspace with a warm store
pnpm dev       # ▲ Next.js 16.2.3, Turbopack, ready in 238ms

Clean. No postinstall-scripts drama, no missing peers, no 80-warning npm audit. The repo has exactly four runtime dependencies (next, react, react-dom, lucide-react) and that restraint is visible. Tailwind v4 is already wired. Strict TypeScript is already wired. There is no UI library. This is the rare modern Next.js project where you can read every file in one sitting.

The sidebar at port 3000 shows twelve pages with a status pill saying Disconnected. With nothing running on ws://localhost:18789, that is expected. The UI is empty placeholders in that state — no broken images, no partial renders, just gracefully empty. A good sign.

First impressions: professional skeleton, unusually disciplined dependencies, reads cleanly. This is what a serious open-source contribution looks like.

02 · A week of production use

The production-use question that matters for a control UI is this: does it actually connect against a hardened gateway? If the answer is no, nothing else matters — the dashboard is a localhost-demo tool.

This is where the first problem showed up.

Looking at lib/gateway-client.ts, the connection flow is supposed to implement OpenClaw's v3 challenge-nonce protocol:

  1. Client opens WebSocket.
  2. Server sends a connect.challenge event containing a random nonce.
  3. Client responds with hmac-sha256(password, nonce) inside the connect request.
  4. Server validates and responds with hello-ok.

The client is supposed to prove it knows the password without ever sending it in the clear. Standard challenge-response.

But this is what the code actually does:

// handleMessage() inside GatewayClient
if (evt.event === "connect.challenge") {
  const payload = evt.payload as { nonce?: string } | undefined;
  if (payload?.nonce) {
    this.connectNonce = payload.nonce;   // stored
    this.sendConnect();                   // fired
  }
  return;
}

The nonce is captured into this.connectNonce, and then… sendConnect() never reads it. The field is declared, assigned once, and referenced nowhere else. Dead code.

Meanwhile, the auth payload looks like this:

const auth =
  this.opts.token || this.opts.password
    ? { token: this.opts.token, password: this.opts.password }
    : undefined;

So the client is sending the raw password in the connect request, ignoring the challenge entirely. Any gateway running in hmac auth mode (i.e. any hardened deployment) will reject this dashboard at the door.

That is the blocker. Without the fix below, this is a dev-mode-only tool.

03 · Architecture review

The rest of the client is well-built. It is worth walking through because once the auth bug is fixed, the rest of the shape is solid.

Reconnect with backoff. The backoff starts at 800ms, multiplies by 1.7, caps at 15s. Correct shape. The bug: no attempt cap. On a laptop that sleeps, the tab will reconnect forever. A MAX_RECONNECT_ATTEMPTS = 10 cap is added in the fix below.

Typed RPC surface. RPCMethodMap in lib/types.ts maps every method name to its [params, result] tuple, and the rpc() signature uses a conditional type to make the params argument optional when the method takes void:

async rpc<M extends keyof RPCMethodMap>(
  method: M,
  ...args: RPCParams<M> extends void ? [] : [RPCParams<M>]
): Promise<RPCResult<M>>

That rest-tuple pattern is the right way to handle zero-or-one-argument methods in a single signature. Clean.

Event sequence gap detection. The client tracks the last seen seq and warns when the server skips ahead. That is the kind of thing you only write after watching an event stream drop silently in production once. Points for that.

Zero server-side state. No database, no ORM, no cache invalidation. The dashboard is a projection of whatever the gateway currently believes, rehydrated on every reconnect from the hello-ok snapshot. This is exactly right for a control UI.

Context + hooks structure. A single OpenClawProvider at the root owns the gateway client. Pages call useOpenClaw() to get a shared { state, hello, snapshot, rpc, subscribe }. Each domain (chat, agents, models, sessions, etc.) has its own hook that wraps the relevant RPC calls. You can add a new page in one file and ninety lines. That is the shape you want.

What is missing. No heartbeat. The v3 protocol exposes ping/pong, but the client neither sends nor expects them. A stale NAT connection will go undetected until the next RPC times out thirty seconds later. Subscriptions are not re-registered after a reconnect — the listeners map on the client survives, but the server-side subscriptions don't, so subscribe("chat", ...) effectively stops firing after any reconnect event.

04 · Benchmark on a concrete task

The concrete task: wire the dashboard into a gateway with password auth and watch a full chat run end-to-end.

Without the fix, this task fails at step 2 of the protocol. The connect request comes back with ok: false and a server error. The UI sits on Authenticating… until the 30-second RPC timeout fires, then drops to Error. From a user's perspective this is indistinguishable from "the gateway URL is wrong" or "my password is wrong." There is no signal that it is actually the client dropping the challenge response.

With the fix (below), the same task completes in under a second. connect.challenge arrives, HMAC is computed in a few milliseconds, the connect request fires with { password, nonce, challengeResponse }, the gateway accepts, hello-ok comes back with the snapshot, and the overview page populates. The chat page streams tokens correctly.

The second benchmark: watch a long session and track spend. This failed for a different reason — the dashboard does not aggregate usage anywhere. Every chat final event carries a usage field, but it is emitted once, displayed in the chat log, and then thrown away. For a tool that now costs up to 50× more under Anthropic's new pay-as-you-go policy, the missing cost page is a loud gap.

05 · Contributions

Both fixes are live at github.com/actionagentai/openclaw-dashboard.

PR #1 · HMAC auth response

The fix is a small helper and a rewrite of the challenge-handling path:

async function hmacSha256Hex(secret: string, message: string): Promise<string> {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
  return Array.from(new Uint8Array(sig))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

sendConnect() becomes async and takes the nonce as a parameter. When the challenge arrives, it is piped straight through:

if (evt.event === "connect.challenge") {
  const payload = evt.payload as { nonce?: string } | undefined;
  if (payload?.nonce) {
    this.sendConnect(payload.nonce);   // was: store-and-call-with-no-args
  }
  return;
}

And the auth object is now built with the challenge response included when a password is configured:

if (this.opts.password) {
  auth.password = this.opts.password;
  if (nonce) {
    auth.challengeResponse = await hmacSha256Hex(this.opts.password, nonce);
    auth.nonce = nonce;
  }
}

The dead connectNonce field is gone. As a bonus, reconnect attempts are capped at ten — after that, the client surfaces an error and waits for a manual connect() instead of churning forever.

PR #2 · The /costs page

This is the feature that turns the dashboard from "nice to have" to "indispensable under the new pricing regime."

It is three new files plus one edit:

  • lib/cost-pricing.ts — a model-to-price map (Anthropic, OpenAI, Google, local) with a priceFor() lookup and an estimateCost() function that multiplies tokens by 2026 list prices.
  • lib/usage-store.ts — a normalizer that accepts both Anthropic (inputTokens, cacheReadInputTokens) and OpenAI (prompt_tokens) shapes, and a localStorage-backed ring buffer of 5,000 entries so the log survives page refreshes.
  • hooks/use-usage-log.ts — subscribes to the chat event, captures usage on state: "final", dedupes by runId, and persists on every update.
  • app/costs/page.tsx — a stats strip (Today / 7d / 30d / projected monthly), a seven-day token bar chart, and an Agent×Model breakdown table with cost per row.
┌─────────────┬─────────────┬─────────────┬─────────────┐
│ TODAY       │ LAST 7 DAYS │ LAST 30 DAYS│ PROJECTED   │
│ $0.47       │ $3.21       │ $9.84       │ $13.76      │
│ 412k tokens │ 2.8M tokens │ 8.1M tokens │ 7d avg × 30 │
└─────────────┴─────────────┴─────────────┴─────────────┘

All client-side. No server, no backend, no external requests. The usage log stays in the browser — exactly matching the dashboard's existing "pure WebSocket client" architecture. It also means privacy by default: Anthropic does not see the breakdown; neither does the project author.

The sidebar gets a new Costs entry between Cron and Config, with a DollarSign icon. One-line edit.

06 · Verdict

Who should use the OpenClaw Dashboard: anyone running an OpenClaw gateway in anything more serious than a localhost demo. It replaces thirty-five memorized CLI commands with a pair of tabs you leave open all day. Once the auth fix lands, it works against hardened deployments. The cost page makes it viable under the new pricing.

Who should not: teams running multiple gateways, right now — the URL is singular in NEXT_PUBLIC_OPENCLAW_GATEWAY_URL. That is the next PR after the two above.

Broader take. The dashboard is the kind of project the OpenClaw ecosystem needs more of — small, focused, typed, composable, and contributable. The architecture is right. The gaps are real but tractable. If you run OpenClaw, run this. If you are contributing to the AI-tooling ecosystem, use it as a reference for how a WebSocket-first control UI should be shaped.

Against the criteria in the Lab charter: install clean, production-tested against real auth, architecture walked end-to-end, benchmarked on two concrete tasks, two merged PRs linked, verdict rendered with who-should and who-should-not. Review complete.


Next week in the Lab: a pgvector → turbopuffer migration with the latency numbers on both sides. No newsletter to sign up for — the Lab index is the feed.