12, Dashboard

Dashboard

A read-only Robinhood-style dashboard that mirrors the desk-state.json snapshot the PM writes after every run, it visualizes state, it cannot place orders.

The dashboard in `ui/` is a mirror, not a controller. It is a professional, read-only view (Vite + React) that visualizes a desk-state.json snapshot the PM session writes after each desk run. It cannot place orders, approval and execution happen only in the Claude Code session, per `CLAUDE.md`.

Run it

bash
cd ui
npm install
npm run dev          # opens http://localhost:5180

A demo public/desk-state.example.json ships with the repo so the UI looks alive immediately, before you have ever run a live desk.

How data flows

PM writes, the UI polls
Claude Code (PM) ── runs the desk, writes ──▶ ui/public/desk-state.json ──▶ UI polls every ~5s
                                              (gitignored: real account state)

The app fetches desk-state.json first and falls back to `desk-state.example.json` when it is absent, so a fresh clone renders the demo, while your real run renders live. desk-state.json is gitignored (it holds real balances and positions) and is never committed; only the sanitized example is. Each poll is cache-busted, so writes show up within about five seconds without a reload.

The desk-state.json schema

The field names map 1:1 to the sub-agents' output blocks in `.claude/agents/` and the caps in `strategies/README.md`, so the PM can fill it directly from a desk run.

ui/public/desk-state.json
{
  "generatedAt": "ISO-8601",            // timestamp shown in the header
  "account": {
    "name": "Robinhood Agentic", "connected": true,
    "equity": 0, "cash": 0, "buyingPower": 0,
    "dayPnl": 0, "dayPnlPct": 0, "openPositions": 0, "ordersToday": 0
  },
  "riskCaps": {                          // mirrors strategies/README.md
    "perTradePct": 15, "maxConcentrationPct": 25, "maxOpenPositions": 6,
    "maxDailyOrders": 4, "stopLossPct": 8, "dailyLossHaltPct": 5, "cashBufferPct": 10
  },
  "positions": [
    { "symbol": "AAPL", "qty": 10, "avgCost": 0, "last": 0,
      "value": 0, "pnl": 0, "pnlPct": 0, "weightPct": 0, "stop": 0 }
  ],
  "candidates": [                        // one per analyzed ticker
    { "symbol": "MSFT", "strategy": "mean-reversion",
      "fundamental": { "score": -2, "confidence": "low|med|high", "valuation": "", "growth": "", "note": "" },
      "technical":   { "signal": 2, "confidence": "", "trend": "", "support": 0, "resistance": 0, "entry": 0, "stop": 0, "note": "" },
      "macro":       { "sentiment": "negative|mixed|positive", "backdrop": "", "injection": "none", "note": "" },
      "risk":        { "decision": "APPROVE|APPROVE-WITH-CHANGES|VETO", "sizingOk": true, "note": "" } }
  ],
  "proposedTrade": {                     // null if none pending
    "symbol": "MSFT", "side": "buy|sell", "qty": 0, "orderType": "limit|market",
    "limitPrice": 0, "estCost": 0, "weightAfterPct": 0, "stop": 0, "stopPct": 0,
    "strategy": "", "riskDecision": "", "status": "PENDING_APPROVAL", "rationale": "" },
  "recentOrders": [
    { "time": "ISO", "symbol": "", "side": "buy|sell", "qty": 0, "price": 0, "type": "", "status": "filled|pending|cancelled" }
  ],
  "injectionAlerts": [                   // from the macro-news analyst; [] if none
    { "source": "url", "quote": "verbatim suspicious text", "handledBy": "macro-news-analyst", "action": "ignored" }
  ],
  "backtests": [ /* OPTIONAL, omit and the panel hides itself */ ],
  "decisionLog": [ /* OPTIONAL, omit and the panel hides itself */ ]
}

Optional panels: backtests & decision log

backtests[] and decisionLog[] are optional and additive, older snapshots that omit them still render, and each panel returns nothing when its array is absent or empty.

backtests[]
The compact, dashboard-facing summary of per-strategy backtest reports (the fuller report shape lives under backtest/reports/<strategy>.json). Each entry carries the headline metrics plus a downsampled equitySpark (and optional buyHoldSpark baseline) that feed an inline SVG sparkline. All figures are illustrative, not a live track record.
decisionLog[]
A tail of the append-only JSONL desk log flattened to the { ts, event, summary, symbol, tone } shape the timeline renders. tone (pos / neg / flat / warn) drives the badge and accent color.

The "Run desk" button

The dashboard has an optional Run desk button. Because a browser cannot call your Claude Code session directly, it uses a thin, file-based bridge, and it only ever triggers a read-only desk run that stops at the preview card. It never places an order.

The file-based bridge
[Run desk] ──POST /api/run──▶ Vite dev-server plugin writes desk-request.json (status: pending)
                                                   │
   dashboard polls /api/run-status ◀──────────────┤
   (button shows "Running…")                       ▼
                                   Your Claude session's /loop sees status=pending →
                                   runs the read-only desk (steps 1–7) →
                                   writes ui/public/desk-state.json →
                                   sets desk-request.json status=done
                                                   │
   dashboard sees status=done + new snapshot ──────┘  → button shows "✓ complete"
  • desk-request.json (repo root) is the control file, gitignored, created on first click.
  • The Vite plugin only reads/writes that file. It does not run the LLM and is dev-server only (npm run dev).
  • The actual work runs inside your authenticated Claude session, so it inherits the MCP auth, the strategies/ rules, the Risk Manager veto, and the approval gate.
  • Latency equals your /loop interval; the button is inert without a running watcher loop.
  • Production builds have no dev server, so this is a dev/local tool, a hosted setup would replace the Vite plugin with a small persistent backend.

The desk run itself is documented in The Desk Run; the log that feeds the timeline in Audit Logging.