Skip to content

Dashboard

rig-agent-runtime includes a built-in web dashboard for monitoring agent activity in real time.

Dashboard

Features

Feature Description
Live messages See incoming Discord messages and agent responses as they happen
Tool call log Track which tools the agent calls, with request/response details
Token usage Monitor input/output tokens and estimated cost per message
Memory stats View conversation count, fact count, and storage usage
MCP Servers Connected/disconnected status and tool count per MCP server
Agent status Connection state, uptime, and last activity timestamp

Two listeners

Since #700 the runtime binds two HTTP listeners, and which one an endpoint lands on is a security decision, not a layout choice.

port serves
Agent HTTP (routed) PUBLIC_PORT, default 3000 POST /api/chat, POST /webhook/{source}, GET /api/health — and nothing else
Dashboard (not routed) DASHBOARD_PORT, default 3001 /, /dashboard, /api/state, /api/usage, /api/health, /api/admin/session-inits, and the /ws WebSocket

The routed listener is default-deny: its allowlist lives in PUBLIC_ROUTES (src/dashboard/server.js) and anything not on it gets a 404 without reaching a handler. Adding an endpoint to the dashboard therefore cannot make it public — it has to be added to that table, on purpose. See Security for why that matters.

Port 3000 keeps the routed role because the Cloudflare tunnel ingress, the cockpit worker and the chart's Services all already point there. Nothing routes to the dashboard port:

kubectl port-forward -n dashecorp sts/rig-e-rig-agent-runtime 3001:3001
Variable Effect
PUBLIC_PORT Override the routed port (chat, webhooks, health). Default 3000.
DASHBOARD_PORT Override the dashboard port. Default 3001. Use 0 for an OS-picked ephemeral port.
DASHBOARD_ENABLED Set to false / 0 / off / no to skip the dashboard listener only. Chat and webhooks keep serving.
CHAT_API_TOKEN Shared secret enabling POST /api/chat. Unset disables that route (503). See HTTP chat endpoint.

If the two ports resolve to the same non-zero value the dashboard is not bound and an error says so. That direction is deliberate: a collision must never end with the dashboard holding the routed port, which is the original exposure restored by a typo.

Co-located agents on a single host

When two agents share a host (e.g. iBuild-E and iClaw-E on the same Mac Mini), they cannot both bind the same port. Each listener fails soft on EADDRINUSE: the loser logs a warning and continues without that listener. Heartbeat and message handling are unaffected.

Give each agent its own pair:

# iBuild-E (LaunchAgent / Helm env)
PUBLIC_PORT=3000
DASHBOARD_PORT=3001

# iClaw-E
PUBLIC_PORT=3002
DASHBOARD_PORT=3003

To suppress the dashboard on one of them — chat and webhooks keep working:

DASHBOARD_ENABLED=false

Local Development

# Chat/webhooks on http://localhost:3000, dashboard on http://localhost:3001
npm start

Production (Cloudflare Tunnel)

The chart's tunnel.enabled: true deploys a cloudflared sidecar with its own token:

tunnel:
  enabled: true
  tokenSecretName: cloudflared-rig-agent-runtime-token
  hostname: my-agent.example.com

Point it at the routed port (PUBLIC_PORT), never at the dashboard port. The routing lives in the tunnel token's own Cloudflare-side configuration, entirely outside this chart and outside OpenTofu, so nothing here can stop a hostname being mapped to the dashboard — which is exactly how rig-e.dashecorp.com ended up publishing /api/state to the internet (dashecorp/infra#447). If a hostname must reach the dashboard, put Cloudflare Access in front of it; the origin has no gate of its own.

Ad-hoc access via port-forward

# the dashboard, which nothing routes to
kubectl port-forward -n rig-agent-runtime deploy/my-agent 3001:3001

WebSocket Protocol

The dashboard uses WebSocket for real-time updates. The server pushes events to connected clients.

sequenceDiagram
    participant B as Browser
    participant D as Dashboard Server
    participant A as Agent Loop

    B->>D: WebSocket connect
    D->>B: agent_status event
    A->>D: message_received
    D->>B: message_received event
    A->>D: tool_call
    D->>B: tool_call event
    A->>D: response_sent
    D->>B: response_sent event
    A->>D: usage_update
    D->>B: usage_update event

Event Types

Event Payload Description
agent_status {name, uptime, connected, lastActivity} Sent on connect
message_received {channel, user, content, timestamp} Incoming Discord message
tool_call {tool, method, path, status, duration} Tool HTTP call completed
response_sent {channel, content, tokens} Agent replied
usage_update {inputTokens, outputTokens, cost} Cumulative usage stats

Security

The dashboard's pages and read endpoints (/, /api/state, /api/usage, /api/admin/session-inits) have no built-in authentication and are not expected to grow any. What protects them is that nothing routes to the port they live on. In production:

  • Do not expose the dashboard port via a Service, Ingress or tunnel ingress
  • Reach it with kubectl port-forward
  • If it must be routed, put Cloudflare Access in front of the hostname — the origin has no gate of its own

POST /api/chat is gated differently, by CHAT_API_TOKEN: unset, it returns 503 and cannot be called at all; set, every request must present a matching X-Chat-Token. It gets a token rather than network controls because it is cost-incurring — each call drives an LLM — so being unroutable would not be a sufficient answer for it. The route is intended to be reached from rig-cockpit-worker, which remains the operator-facing edge.

Why the listeners are split

Both surfaces used to share one listener, and the dashboard half was written against a "cluster-internal" threat model that a whole-host tunnel mapping quietly invalidated. The reasoning, the two lessons that generalise, and why the routed listener is default-deny are in One listener cannot serve two threat models.

Chart caveat (#701): dashboard.port and dashboard.enabled in charts/rig-agent-runtime are not rendered as env vars, so setting them in values changes the Service and the containerPort while the app keeps its own defaults. Until that is fixed, set the variables above directly.