diff --git a/AGENTS.md b/AGENTS.md
index 5f279eb..d64fee4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,6 +26,7 @@ Framework docs live at the repo root. The Coulomb MVP implementation lives in
| Lint / format | none configured — match surrounding style |
| Build | none |
| Run: economics dashboard | `cd projects/coulomb-pricing && python3 -m observatory --period YYYY-MM` |
+| Run: observatory UI | `cd projects/coulomb-pricing && python3 -m observatory.server` → http://127.0.0.1:8765/ |
| Workplan / hub sync | `cd ~/state-hub && make fix-consistency REPO=adaptive-pricing REPO_PATH=~/adaptive-pricing` |
| Registry sanity | `grep -q '^version:' registry/indexes/capabilities.yaml && echo OK` |
diff --git a/projects/coulomb-pricing/README.md b/projects/coulomb-pricing/README.md
index e40a109..d3a0185 100644
--- a/projects/coulomb-pricing/README.md
+++ b/projects/coulomb-pricing/README.md
@@ -35,7 +35,12 @@ cd projects/coulomb-pricing
python3 -m pytest -q
python3 -m observatory --period 2026-06
python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md
+python3 -m observatory.server
```
+Open **http://127.0.0.1:8765/** for the Economic Observatory UI (served from
+`ui/`, data via `/api/dashboard`). Design reference:
+https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511
+
Manual ledgers will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and
OpenRouter (Sprint 4) importers.
\ No newline at end of file
diff --git a/projects/coulomb-pricing/observatory/api.py b/projects/coulomb-pricing/observatory/api.py
new file mode 100644
index 0000000..008956f
--- /dev/null
+++ b/projects/coulomb-pricing/observatory/api.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+import json
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+from .economics import build_liquidity_summary, build_snapshot
+from .load import (
+ default_data_dir,
+ latest_period,
+ load_budget,
+ load_expense_records,
+ load_membership,
+ load_monthly_ledger,
+ load_payment_records,
+ load_pricing_models,
+ load_product,
+)
+
+
+def _serialize(value: Any) -> Any:
+ if isinstance(value, Decimal):
+ return str(value)
+ if hasattr(value, "__dataclass_fields__"):
+ return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__}
+ if isinstance(value, list):
+ return [_serialize(item) for item in value]
+ if isinstance(value, dict):
+ return {key: _serialize(item) for key, item in value.items()}
+ return value
+
+
+def _load_json_catalog(data_dir: Path, name: str) -> dict:
+ path = data_dir / "infrastructure" / name
+ if not path.exists():
+ return {}
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def build_dashboard_payload(data_dir: Path | None = None, period: str | None = None) -> dict:
+ root = data_dir or default_data_dir()
+ product = load_product(root)
+ budget = load_budget(root)
+ models = load_pricing_models(root)
+ members = load_membership(root)
+ payments = load_payment_records(root)
+ expenses = load_expense_records(root)
+ ledger = load_monthly_ledger(root)
+ target_period = period or latest_period(ledger)
+
+ snapshot = build_snapshot(target_period, product, models, members, payments, ledger)
+ liquidity = build_liquidity_summary(budget, payments, ledger, target_period)
+ payment_by_period = {record.period: record for record in payments}
+
+ history = []
+ for month in sorted(ledger, key=lambda row: row.period):
+ if month.period > target_period:
+ continue
+ payment = payment_by_period.get(month.period)
+ net_payment = payment.net_amount if payment else Decimal("0")
+ history.append(
+ {
+ "period": month.period,
+ "active_members": month.active_members,
+ "gross_revenue": month.gross_revenue,
+ "infrastructure_cost": month.infrastructure_cost,
+ "payment_processing_cost": month.payment_processing_cost,
+ "total_platform_cost": month.total_platform_cost,
+ "net_payment": net_payment,
+ "net_liquidity": net_payment - month.infrastructure_cost,
+ }
+ )
+
+ return _serialize(
+ {
+ "design_reference": "https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511",
+ "period": target_period,
+ "product": product,
+ "budget": budget,
+ "snapshot": snapshot,
+ "liquidity": liquidity,
+ "history": history,
+ "pricing_models": models,
+ "members": members,
+ "payments": payments,
+ "expense_record_count": len(expenses),
+ "infrastructure": {
+ "domains": _load_json_catalog(root, "domains.json"),
+ "virtual_servers": _load_json_catalog(root, "virtual_servers.json"),
+ "stripe": _load_json_catalog(root, "stripe.json"),
+ },
+ }
+ )
+
+
+def payload_json(data_dir: Path | None = None, period: str | None = None) -> str:
+ return json.dumps(build_dashboard_payload(data_dir, period), indent=2)
\ No newline at end of file
diff --git a/projects/coulomb-pricing/observatory/server.py b/projects/coulomb-pricing/observatory/server.py
new file mode 100644
index 0000000..fa6774b
--- /dev/null
+++ b/projects/coulomb-pricing/observatory/server.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+import argparse
+import json
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import parse_qs, urlparse
+
+from .api import build_dashboard_payload
+
+ROOT = Path(__file__).resolve().parent.parent
+UI_DIR = ROOT / "ui"
+
+
+class ObservatoryHandler(BaseHTTPRequestHandler):
+ data_dir: Path = ROOT / "data"
+
+ def _send(self, status: int, body: bytes, content_type: str) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self) -> None:
+ parsed = urlparse(self.path)
+ if parsed.path == "/api/dashboard":
+ query = parse_qs(parsed.query)
+ period = query.get("period", [None])[0]
+ payload = build_dashboard_payload(self.data_dir, period)
+ self._send(200, json.dumps(payload).encode("utf-8"), "application/json")
+ return
+
+ if parsed.path == "/":
+ return self._serve_file(UI_DIR / "index.html", "text/html; charset=utf-8")
+
+ if parsed.path.startswith("/ui/"):
+ relative = parsed.path.removeprefix("/ui/")
+ target = UI_DIR / relative
+ if target.exists() and target.is_file():
+ content_type = "text/css" if target.suffix == ".css" else "application/javascript"
+ return self._serve_file(target, f"{content_type}; charset=utf-8")
+
+ self._send(404, b"Not found", "text/plain")
+
+ def _serve_file(self, path: Path, content_type: str) -> None:
+ self._send(200, path.read_bytes(), content_type)
+
+ def log_message(self, format: str, *args) -> None:
+ return
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Coulomb Economic Observatory UI server")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8765)
+ parser.add_argument("--data-dir", type=Path, default=ROOT / "data")
+ args = parser.parse_args(argv)
+
+ ObservatoryHandler.data_dir = args.data_dir
+ server = ThreadingHTTPServer((args.host, args.port), ObservatoryHandler)
+ print(f"Economic Observatory UI: http://{args.host}:{args.port}/")
+ print(f"API: http://{args.host}:{args.port}/api/dashboard")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\nStopped.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
\ No newline at end of file
diff --git a/projects/coulomb-pricing/tests/test_api.py b/projects/coulomb-pricing/tests/test_api.py
new file mode 100644
index 0000000..212d53c
--- /dev/null
+++ b/projects/coulomb-pricing/tests/test_api.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+import json
+from decimal import Decimal
+from pathlib import Path
+
+from observatory.api import build_dashboard_payload, payload_json
+
+DATA_DIR = Path(__file__).resolve().parent.parent / "data"
+
+
+def test_dashboard_payload_contains_live_ledger_totals() -> None:
+ payload = build_dashboard_payload(DATA_DIR, "2026-06")
+
+ assert payload["period"] == "2026-06"
+ assert payload["liquidity"]["remaining_budget"] == "659.12"
+ assert payload["liquidity"]["cumulative_infrastructure_cost"] == "409.28"
+ assert payload["snapshot"]["monthly_infrastructure_cost"] == "29.73"
+ assert len(payload["history"]) == 18
+ assert payload["expense_record_count"] == 58
+
+
+def test_payload_json_is_valid() -> None:
+ parsed = json.loads(payload_json(DATA_DIR, "2026-06"))
+ assert Decimal(parsed["payments"][0]["fees_amount"]) == Decimal("0.44")
\ No newline at end of file
diff --git a/projects/coulomb-pricing/ui/app.js b/projects/coulomb-pricing/ui/app.js
new file mode 100644
index 0000000..a917d5c
--- /dev/null
+++ b/projects/coulomb-pricing/ui/app.js
@@ -0,0 +1,178 @@
+const euro = (value) => `€${Number(value).toFixed(2)}`;
+
+async function loadDashboard(period) {
+ const query = period ? `?period=${encodeURIComponent(period)}` : "";
+ const response = await fetch(`/api/dashboard${query}`);
+ if (!response.ok) throw new Error("Failed to load dashboard");
+ return response.json();
+}
+
+function setBadge(el, status) {
+ el.textContent = status;
+ el.classList.toggle("ok", status === "generating" || status === "neutral");
+}
+
+function renderHero(data) {
+ const { snapshot, liquidity } = data;
+ const cards = [
+ {
+ label: "Remaining budget",
+ value: euro(liquidity.remaining_budget),
+ sub: `${liquidity.months_tracked} months tracked`,
+ },
+ {
+ label: "Period net liquidity",
+ value: euro(snapshot.period_net_liquidity),
+ sub: snapshot.liquidity_status,
+ },
+ {
+ label: "Active members",
+ value: snapshot.active_members,
+ sub: data.members[0]?.username ? `@${data.members[0].username}` : "—",
+ },
+ {
+ label: "Member payment (gross)",
+ value: euro(snapshot.monthly_revenue),
+ sub: `Net ${euro(data.payments.at(-1)?.net_amount ?? 0)} after Stripe`,
+ },
+ {
+ label: "Infrastructure / month",
+ value: euro(snapshot.monthly_infrastructure_cost),
+ sub: `Processing ${euro(snapshot.monthly_payment_processing_cost)}`,
+ },
+ {
+ label: "Cumulative net liquidity",
+ value: euro(liquidity.cumulative_net_liquidity),
+ sub: liquidity.liquidity_status,
+ },
+ ];
+
+ document.getElementById("hero-grid").innerHTML = cards
+ .map(
+ (card) => `
+
${error}`;
+ });
\ No newline at end of file
diff --git a/projects/coulomb-pricing/ui/index.html b/projects/coulomb-pricing/ui/index.html
new file mode 100644
index 0000000..e05ba7e
--- /dev/null
+++ b/projects/coulomb-pricing/ui/index.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+ Adaptive Pricing · Coulomb Social MVP
+Operator liquidity pool
+Member net payments minus infrastructure
+Domains, hosting, and Stripe reference
+Computed from expense and payment record tables
+| Period | +Members | +Gross | +Infrastructure | +Processing | +Net liquidity | +
|---|
| ID | +Name | +Type | +Status | +
|---|