diff --git a/src/dashboard/app.py b/src/dashboard/app.py
index 7e1ccba9..d86edb4b 100644
--- a/src/dashboard/app.py
+++ b/src/dashboard/app.py
@@ -43,6 +43,7 @@ from dashboard.routes.memory import router as memory_router
from dashboard.routes.mobile import router as mobile_router
from dashboard.routes.models import api_router as models_api_router
from dashboard.routes.models import router as models_router
+from dashboard.routes.monologue import router as monologue_router
from dashboard.routes.quests import router as quests_router
from dashboard.routes.scorecards import router as scorecards_router
from dashboard.routes.spark import router as spark_router
@@ -618,6 +619,7 @@ app.include_router(models_api_router)
app.include_router(chat_api_router)
app.include_router(chat_api_v1_router)
app.include_router(thinking_router)
+app.include_router(monologue_router)
app.include_router(calm_router)
app.include_router(tasks_router)
app.include_router(work_orders_router)
diff --git a/src/dashboard/routes/monologue.py b/src/dashboard/routes/monologue.py
new file mode 100644
index 00000000..fd4af407
--- /dev/null
+++ b/src/dashboard/routes/monologue.py
@@ -0,0 +1,110 @@
+"""Internal Monologue Visualizer — real-time agent reasoning stream.
+
+GET /monologue — render the monologue visualizer page
+GET /monologue/api — JSON list of recent thoughts (with optional summary)
+WS /monologue/ws — WebSocket stream of live thoughts
+"""
+
+import json
+import logging
+
+from fastapi import APIRouter, Query, Request, WebSocket, WebSocketDisconnect
+from fastapi.responses import HTMLResponse, JSONResponse
+
+from dashboard.templating import templates
+from timmy.thinking import thinking_engine
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/monologue", tags=["monologue"])
+
+
+def _summarise(content: str, max_len: int = 120) -> str:
+ """Return a short summary of a thought — first sentence or truncated."""
+ # Take first sentence
+ for sep in (".", "!", "?"):
+ idx = content.find(sep)
+ if 0 < idx < max_len:
+ return content[: idx + 1]
+ # Fallback: truncate
+ if len(content) <= max_len:
+ return content
+ return content[:max_len].rsplit(" ", 1)[0] + "..."
+
+
+@router.get("", response_class=HTMLResponse)
+async def monologue_page(request: Request):
+ """Render the internal monologue visualizer."""
+ thoughts = thinking_engine.get_recent_thoughts(limit=50)
+ return templates.TemplateResponse(
+ request,
+ "monologue.html",
+ {"thoughts": thoughts},
+ )
+
+
+@router.get("/api", response_class=JSONResponse)
+async def monologue_api(
+ limit: int = Query(default=30, ge=1, le=100),
+ view: str = Query(default="raw", pattern="^(raw|summarized)$"),
+):
+ """Return recent thoughts as JSON, optionally summarised."""
+ thoughts = thinking_engine.get_recent_thoughts(limit=limit)
+ results = []
+ for t in thoughts:
+ entry = {
+ "id": t.id,
+ "content": t.content,
+ "seed_type": t.seed_type,
+ "parent_id": t.parent_id,
+ "created_at": t.created_at,
+ }
+ if view == "summarized":
+ entry["summary"] = _summarise(t.content)
+ results.append(entry)
+ return results
+
+
+@router.websocket("/ws")
+async def monologue_ws(websocket: WebSocket):
+ """Stream thoughts to the client in real time.
+
+ Piggybacks on the existing ws_manager broadcast infrastructure.
+ We register a dedicated connection list here so monologue clients
+ only receive thought events (not all swarm traffic).
+ """
+ await websocket.accept()
+ logger.info("Monologue WebSocket connected")
+
+ # Send recent thoughts as backfill
+ try:
+ recent = thinking_engine.get_recent_thoughts(limit=20)
+ for t in reversed(recent): # oldest first
+ await websocket.send_text(
+ json.dumps(
+ {
+ "event": "thought",
+ "data": {
+ "thought_id": t.id,
+ "content": t.content,
+ "seed_type": t.seed_type,
+ "parent_id": t.parent_id,
+ "created_at": t.created_at,
+ },
+ }
+ )
+ )
+ except Exception as exc:
+ logger.warning("Monologue backfill error: %s", exc)
+
+ # Register with ws_manager so we receive broadcast events
+ from infrastructure.ws_manager.handler import ws_manager
+
+ await ws_manager.connect(websocket, accept=False)
+ try:
+ while True:
+ # Keep alive — client may send pings or view-mode switches
+ await websocket.receive_text()
+ except (WebSocketDisconnect, Exception) as exc:
+ logger.debug("Monologue WebSocket disconnect: %s", exc)
+ ws_manager.disconnect(websocket)
diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html
index 7a1b234d..85cf52b2 100644
--- a/src/dashboard/templates/base.html
+++ b/src/dashboard/templates/base.html
@@ -49,6 +49,7 @@
TASKS
BRIEFING
THINKING
+ MONOLOGUE
MISSION CTRL
SWARM
SCORECARDS
@@ -122,6 +123,7 @@
TASKS
BRIEFING
THINKING
+ MONOLOGUE
MISSION CONTROL
SWARM
SCORECARDS
diff --git a/src/dashboard/templates/monologue.html b/src/dashboard/templates/monologue.html
new file mode 100644
index 00000000..268266fb
--- /dev/null
+++ b/src/dashboard/templates/monologue.html
@@ -0,0 +1,221 @@
+{% extends "base.html" %}
+
+{% block title %}Internal Monologue{% endblock %}
+
+{% block extra_styles %}{% endblock %}
+
+{% block content %}
+
+
+
+
+ {# ── Controls ── #}
+
+
+
+
+
+
+
+ Connecting...
+
+
+
+
+ {# ── Thought stream ── #}
+
+
+
+
+ {% if thoughts %}
+ {% for thought in thoughts %}
+
+
+ {{ thought.created_at[11:19] }}
+ {{ thought.seed_type }}
+
+
+ {{ thought.content | e }}
+
+
+ {% endfor %}
+ {% else %}
+
+ Waiting for first thought... the thinking engine will begin shortly after startup.
+
+ {% endif %}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/static/css/mission-control.css b/static/css/mission-control.css
index fc844301..cef42e19 100644
--- a/static/css/mission-control.css
+++ b/static/css/mission-control.css
@@ -2547,3 +2547,73 @@
.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; }
+
+/* ── Internal Monologue Visualizer ────────────────────────── */
+
+.monologue-container { max-width: 960px; }
+.monologue-title { font-size: 1.6rem; font-weight: 700; color: var(--text-bright); letter-spacing: 0.04em; }
+.monologue-subtitle { font-size: 0.85rem; color: var(--text-dim); margin-top: 2px; }
+
+/* Controls bar */
+.monologue-controls { flex-wrap: wrap; }
+.monologue-btn { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; letter-spacing: 0.08em; border: 1px solid var(--border); color: var(--text-dim); background: transparent; padding: 3px 10px; transition: all 0.2s ease; }
+.monologue-btn:hover { color: var(--text); border-color: var(--purple); }
+.monologue-btn.active { color: var(--purple); border-color: var(--purple); background: rgba(168, 85, 247, 0.08); }
+.monologue-count-badge { background: rgba(168, 85, 247, 0.15); color: var(--purple); font-size: 0.7rem; letter-spacing: 0.06em; }
+
+/* Status indicator */
+.monologue-status { display: flex; align-items: center; gap: 6px; font-size: 0.7rem; color: var(--text-dim); letter-spacing: 0.06em; }
+.monologue-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim); transition: background 0.3s ease; }
+.monologue-dot-connected { background: var(--green); box-shadow: 0 0 6px var(--green); animation: monologue-pulse 2s ease-in-out infinite; }
+.monologue-dot-connecting { background: var(--amber); animation: monologue-pulse 1s ease-in-out infinite; }
+.monologue-dot-disconnected { background: var(--red); }
+
+@keyframes monologue-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+/* Stream container */
+.monologue-stream { max-height: 70vh; overflow-y: auto; padding: 0; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; line-height: 1.6; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
+.monologue-stream::-webkit-scrollbar { width: 6px; }
+.monologue-stream::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+.monologue-stream::-webkit-scrollbar-track { background: transparent; }
+
+/* Individual thought entry */
+.monologue-entry { display: flex; gap: 12px; padding: 8px 14px; border-bottom: 1px solid rgba(59, 26, 92, 0.3); transition: opacity 0.4s ease, transform 0.4s ease, background 0.2s ease; }
+.monologue-entry:hover { background: rgba(168, 85, 247, 0.04); }
+.monologue-entry-new { opacity: 0; transform: translateY(8px); }
+
+/* Gutter (timestamp + seed badge) */
+.monologue-entry-gutter { display: flex; flex-direction: column; align-items: flex-end; min-width: 90px; flex-shrink: 0; gap: 2px; padding-top: 1px; }
+.monologue-timestamp { font-size: 0.65rem; color: var(--text-dim); letter-spacing: 0.04em; }
+.monologue-seed { font-size: 0.55rem; letter-spacing: 0.08em; text-transform: uppercase; padding: 1px 5px; border-radius: 2px; color: var(--text-dim); background: rgba(59, 26, 92, 0.3); }
+
+/* Seed-type colour accents */
+.seed-existential { color: var(--purple); background: rgba(168, 85, 247, 0.12); }
+.seed-swarm { color: var(--green); background: rgba(0, 232, 122, 0.10); }
+.seed-scripture { color: var(--amber); background: rgba(255, 184, 0, 0.10); }
+.seed-creative { color: var(--orange); background: rgba(255, 122, 42, 0.10); }
+.seed-memory { color: #60a5fa; background: rgba(96, 165, 250, 0.10); }
+.seed-freeform { color: var(--text-dim); background: rgba(59, 26, 92, 0.3); }
+.seed-sovereignty { color: var(--red); background: rgba(255, 68, 85, 0.08); }
+.seed-observation { color: var(--green); background: rgba(0, 232, 122, 0.08); }
+.seed-workspace { color: var(--amber); background: rgba(255, 184, 0, 0.08); }
+.seed-prompted { color: var(--text-bright); background: rgba(237, 224, 255, 0.08); }
+
+/* Content area */
+.monologue-entry-content { flex: 1; color: var(--text); word-break: break-word; }
+.monologue-summary { color: var(--text-dim); font-style: italic; }
+
+/* Empty state */
+.monologue-empty { padding: 40px 20px; text-align: center; color: var(--text-dim); font-size: 0.85rem; }
+
+/* Scroll button */
+.monologue-scroll-btn.active { color: var(--green); border-color: var(--green); background: rgba(0, 232, 122, 0.08); }
+
+/* Mobile adjustments */
+@media (max-width: 576px) {
+ .monologue-entry { flex-direction: column; gap: 4px; }
+ .monologue-entry-gutter { flex-direction: row; align-items: center; min-width: auto; gap: 8px; }
+ .monologue-stream { max-height: 60vh; font-size: 0.75rem; }
+}
diff --git a/tests/dashboard/test_monologue.py b/tests/dashboard/test_monologue.py
new file mode 100644
index 00000000..f16a242c
--- /dev/null
+++ b/tests/dashboard/test_monologue.py
@@ -0,0 +1,84 @@
+"""Tests for the internal monologue visualizer routes."""
+
+import pytest
+
+
+@pytest.fixture(name="client")
+def client_fixture():
+ from fastapi.testclient import TestClient
+
+ from dashboard.app import app
+
+ with TestClient(app) as c:
+ yield c
+
+
+class TestMonologuePage:
+ """GET /monologue — renders the visualizer page."""
+
+ def test_page_returns_200(self, client):
+ resp = client.get("/monologue")
+ assert resp.status_code == 200
+ assert "Internal Monologue" in resp.text
+
+ def test_page_contains_stream_container(self, client):
+ resp = client.get("/monologue")
+ assert 'id="monologue-stream"' in resp.text
+
+ def test_page_contains_view_toggle(self, client):
+ resp = client.get("/monologue")
+ assert 'data-view="raw"' in resp.text
+ assert 'data-view="summarized"' in resp.text
+
+
+class TestMonologueAPI:
+ """GET /monologue/api — JSON thought list."""
+
+ def test_api_returns_list(self, client):
+ resp = client.get("/monologue/api")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data, list)
+
+ def test_api_respects_limit(self, client):
+ resp = client.get("/monologue/api?limit=5")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data) <= 5
+
+ def test_api_raw_view_no_summary(self, client):
+ resp = client.get("/monologue/api?view=raw")
+ assert resp.status_code == 200
+ data = resp.json()
+ for entry in data:
+ assert "summary" not in entry
+
+ def test_api_summarized_view_has_summary(self, client):
+ resp = client.get("/monologue/api?view=summarized")
+ assert resp.status_code == 200
+ data = resp.json()
+ for entry in data:
+ assert "summary" in entry
+
+
+class TestSummarise:
+ """Unit tests for the _summarise helper."""
+
+ def test_short_text_unchanged(self):
+ from dashboard.routes.monologue import _summarise
+
+ assert _summarise("Short thought.") == "Short thought."
+
+ def test_first_sentence_extracted(self):
+ from dashboard.routes.monologue import _summarise
+
+ text = "First sentence. Second sentence continues here."
+ assert _summarise(text) == "First sentence."
+
+ def test_long_text_truncated(self):
+ from dashboard.routes.monologue import _summarise
+
+ text = "A " * 200 # No sentence-ending punctuation
+ result = _summarise(text.strip())
+ assert len(result) <= 125 # max_len + ellipsis
+ assert result.endswith("...")