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

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:
| 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:
Local Development¶
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.portanddashboard.enabledincharts/rig-agent-runtimeare not rendered as env vars, so setting them in values changes the Service and thecontainerPortwhile the app keeps its own defaults. Until that is fixed, set the variables above directly.