diff --git a/src/dashboard/app.py b/src/dashboard/app.py
index 49c291e..5701ea2 100644
--- a/src/dashboard/app.py
+++ b/src/dashboard/app.py
@@ -46,6 +46,7 @@ from dashboard.routes.tasks import router as tasks_router
from dashboard.routes.telegram import router as telegram_router
from dashboard.routes.thinking import router as thinking_router
from dashboard.routes.tools import router as tools_router
+from dashboard.routes.tower import router as tower_router
from dashboard.routes.voice import router as voice_router
from dashboard.routes.work_orders import router as work_orders_router
from dashboard.routes.world import router as world_router
@@ -583,6 +584,7 @@ app.include_router(system_router)
app.include_router(experiments_router)
app.include_router(db_explorer_router)
app.include_router(world_router)
+app.include_router(tower_router)
@app.websocket("/ws")
diff --git a/src/dashboard/routes/tower.py b/src/dashboard/routes/tower.py
new file mode 100644
index 0000000..f27e629
--- /dev/null
+++ b/src/dashboard/routes/tower.py
@@ -0,0 +1,108 @@
+"""Tower dashboard — real-time Spark visualization via WebSocket.
+
+GET /tower — HTML Tower dashboard (Thinking / Predicting / Advising)
+WS /tower/ws — WebSocket stream of Spark engine state updates
+"""
+
+import asyncio
+import json
+import logging
+
+from fastapi import APIRouter, Request, WebSocket
+from fastapi.responses import HTMLResponse
+
+from dashboard.templating import templates
+from spark.engine import spark_engine
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/tower", tags=["tower"])
+
+_PUSH_INTERVAL = 5 # seconds between state broadcasts
+
+
+def _spark_snapshot() -> dict:
+ """Build a JSON-serialisable snapshot of Spark state."""
+ status = spark_engine.status()
+
+ timeline = spark_engine.get_timeline(limit=10)
+ events = []
+ for ev in timeline:
+ entry = {
+ "event_type": ev.event_type,
+ "description": ev.description,
+ "importance": ev.importance,
+ "created_at": ev.created_at,
+ }
+ if ev.agent_id:
+ entry["agent_id"] = ev.agent_id[:8]
+ if ev.task_id:
+ entry["task_id"] = ev.task_id[:8]
+ try:
+ entry["data"] = json.loads(ev.data)
+ except (json.JSONDecodeError, TypeError):
+ entry["data"] = {}
+ events.append(entry)
+
+ predictions = spark_engine.get_predictions(limit=5)
+ preds = []
+ for p in predictions:
+ pred = {
+ "task_id": p.task_id[:8] if p.task_id else "?",
+ "accuracy": p.accuracy,
+ "evaluated": p.evaluated_at is not None,
+ "created_at": p.created_at,
+ }
+ try:
+ pred["predicted"] = json.loads(p.predicted_value)
+ except (json.JSONDecodeError, TypeError):
+ pred["predicted"] = {}
+ preds.append(pred)
+
+ advisories = spark_engine.get_advisories()
+ advs = [
+ {
+ "category": a.category,
+ "priority": a.priority,
+ "title": a.title,
+ "detail": a.detail,
+ "suggested_action": a.suggested_action,
+ }
+ for a in advisories
+ ]
+
+ return {
+ "type": "spark_state",
+ "status": status,
+ "events": events,
+ "predictions": preds,
+ "advisories": advs,
+ }
+
+
+@router.get("", response_class=HTMLResponse)
+async def tower_ui(request: Request):
+ """Render the Tower dashboard page."""
+ snapshot = _spark_snapshot()
+ return templates.TemplateResponse(
+ request,
+ "tower.html",
+ {"snapshot": snapshot},
+ )
+
+
+@router.websocket("/ws")
+async def tower_ws(websocket: WebSocket) -> None:
+ """Stream Spark state snapshots to the Tower dashboard."""
+ await websocket.accept()
+ logger.info("Tower WS connected")
+
+ try:
+ # Send initial snapshot
+ await websocket.send_text(json.dumps(_spark_snapshot()))
+
+ while True:
+ await asyncio.sleep(_PUSH_INTERVAL)
+ await websocket.send_text(json.dumps(_spark_snapshot()))
+ except Exception:
+ logger.debug("Tower WS disconnected")
diff --git a/src/dashboard/templates/tower.html b/src/dashboard/templates/tower.html
new file mode 100644
index 0000000..2a117cb
--- /dev/null
+++ b/src/dashboard/templates/tower.html
@@ -0,0 +1,180 @@
+{% extends "base.html" %}
+
+{% block title %}Timmy Time — Tower{% endblock %}
+
+{% block extra_styles %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
Waiting for Spark data…
+
+
+
+
+
+
+
+
+
+
Waiting for Spark data…
+
+
+
+
+
+
+
EVENTS0
+
MEMORIES0
+
PREDICTIONS0
+
ACCURACY—
+
+
+
+
+
+
+
+
+
+
+
Waiting for Spark data…
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/static/css/mission-control.css b/static/css/mission-control.css
index 395b987..fc84430 100644
--- a/static/css/mission-control.css
+++ b/static/css/mission-control.css
@@ -2493,3 +2493,57 @@
.db-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.db-cell:hover { white-space: normal; word-break: break-all; }
.db-truncated { font-size: 0.7rem; color: var(--amber); padding: 0.3rem 0; }
+
+/* ── Tower ────────────────────────────────────────────────────────────── */
+.tower-container { max-width: 1400px; margin: 0 auto; }
+.tower-header { margin-bottom: 1rem; }
+.tower-title { font-size: 1.6rem; font-weight: 700; color: var(--green); letter-spacing: 0.15em; }
+.tower-subtitle { font-size: 0.85rem; color: var(--text-dim); }
+
+.tower-conn-badge { font-size: 0.7rem; font-weight: 600; padding: 2px 8px; border-radius: 3px; letter-spacing: 0.08em; }
+.tower-conn-live { color: var(--green); border: 1px solid var(--green); }
+.tower-conn-offline { color: var(--red); border: 1px solid var(--red); }
+.tower-conn-connecting { color: var(--amber); border: 1px solid var(--amber); }
+
+.tower-phase-card { min-height: 300px; }
+.tower-phase-thinking { border-left: 3px solid var(--purple); }
+.tower-phase-predicting { border-left: 3px solid var(--orange); }
+.tower-phase-advising { border-left: 3px solid var(--green); }
+.tower-scroll { max-height: 50vh; overflow-y: auto; }
+.tower-empty { text-align: center; color: var(--text-dim); padding: 16px; font-size: 0.85rem; }
+
+.tower-stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; text-align: center; }
+.tower-stat-label { display: block; font-size: 0.65rem; color: var(--text-dim); letter-spacing: 0.1em; }
+.tower-stat-value { display: block; font-size: 1.1rem; font-weight: 700; color: var(--text-bright); }
+
+.tower-event { padding: 8px; margin-bottom: 6px; border-left: 3px solid var(--border); border-radius: 3px; background: var(--bg-card); }
+.tower-etype-task_posted { border-left-color: var(--purple); }
+.tower-etype-bid_submitted { border-left-color: var(--orange); }
+.tower-etype-task_completed { border-left-color: var(--green); }
+.tower-etype-task_failed { border-left-color: var(--red); }
+.tower-etype-agent_joined { border-left-color: var(--purple); }
+.tower-etype-tool_executed { border-left-color: var(--amber); }
+.tower-ev-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
+.tower-ev-badge { font-size: 0.65rem; font-weight: 600; color: var(--text-bright); letter-spacing: 0.08em; }
+.tower-ev-dots { font-size: 0.6rem; color: var(--amber); }
+.tower-ev-desc { font-size: 0.8rem; color: var(--text); }
+.tower-ev-time { font-size: 0.65rem; color: var(--text-dim); margin-top: 2px; }
+
+.tower-pred { padding: 8px; margin-bottom: 6px; border-radius: 3px; background: var(--bg-card); border-left: 3px solid var(--orange); }
+.tower-pred-done { border-left-color: var(--green); }
+.tower-pred-pending { border-left-color: var(--amber); }
+.tower-pred-head { display: flex; justify-content: space-between; align-items: center; }
+.tower-pred-task { font-size: 0.75rem; font-weight: 600; color: var(--text-bright); font-family: monospace; }
+.tower-pred-acc { font-size: 0.75rem; font-weight: 700; }
+.tower-pred-detail { font-size: 0.75rem; color: var(--text-dim); margin-top: 4px; }
+
+.tower-advisory { padding: 8px; margin-bottom: 6px; border-radius: 3px; background: var(--bg-card); border-left: 3px solid var(--border); }
+.tower-adv-high { border-left-color: var(--red); }
+.tower-adv-medium { border-left-color: var(--orange); }
+.tower-adv-low { border-left-color: var(--green); }
+.tower-adv-head { display: flex; justify-content: space-between; font-size: 0.65rem; margin-bottom: 4px; }
+.tower-adv-cat { font-weight: 600; color: var(--text-dim); letter-spacing: 0.08em; }
+.tower-adv-prio { font-weight: 700; color: var(--amber); }
+.tower-adv-title { font-size: 0.85rem; font-weight: 600; color: var(--text-bright); }
+.tower-adv-detail { font-size: 0.8rem; color: var(--text); margin-top: 2px; }
+.tower-adv-action { font-size: 0.75rem; color: var(--green); margin-top: 4px; font-style: italic; }