diff --git a/src/attune/ops/routes/dashboard.py b/src/attune/ops/routes/dashboard.py
index 925db390..4ae3fce9 100644
--- a/src/attune/ops/routes/dashboard.py
+++ b/src/attune/ops/routes/dashboard.py
@@ -8,7 +8,7 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
-from attune.ops import data
+from attune.ops import anthropic_cost, data
logger = logging.getLogger(__name__)
@@ -60,6 +60,20 @@ async def home(request: Request) -> HTMLResponse:
runner = getattr(request.app.state, "runner", None)
recent_runs = [r.to_dict() for r in runner.recent(limit=5)] if runner else []
attune_ai = next((v for v in versions if v.package == "attune-ai"), None)
+ # Anthropic account-spend tiles (Phase 2 of anthropic-cost-integration).
+ # Defensive try/except — a fetch crash must not block the dashboard
+ # render, so we degrade silently (no tile cluster) on unexpected
+ # errors. Categorized failures (no_key, auth_failed, rate_limited,
+ # network) come back through cost_error and the template renders
+ # the right CTA / notice / fallback per-kind.
+ refresh = request.query_params.get("refresh") == "1"
+ try:
+ cost_summary, cost_error = anthropic_cost.fetch_summary(refresh=refresh)
+ except Exception: # noqa: BLE001
+ # INTENTIONAL: defensive degradation; surface in DEBUG logs but
+ # never block the home page render on a billing-fetch surprise.
+ logger.debug("anthropic_cost.fetch_summary raised", exc_info=True)
+ cost_summary, cost_error = None, None
return _render(
request,
"home.html",
@@ -71,6 +85,8 @@ async def home(request: Request) -> HTMLResponse:
sparkline=sparkline,
recent_runs=recent_runs,
attune_ai=attune_ai,
+ cost_summary=cost_summary,
+ cost_error=cost_error,
)
diff --git a/src/attune/ops/static/css/main.css b/src/attune/ops/static/css/main.css
index 2109cdd9..10e49e3d 100644
--- a/src/attune/ops/static/css/main.css
+++ b/src/attune/ops/static/css/main.css
@@ -268,6 +268,29 @@ a.kpi:hover {
color: var(--fg-subtle);
}
+/* Account-spend cluster (Phase 2 anthropic-cost-integration).
+ Visually distinguishes Anthropic-billing tiles from workflow-
+ telemetry tiles above so users can tell at a glance which numbers
+ come from which data source. Thin left-edge accent on each tile
+ in the cluster + tighter top margin so the two clusters read as
+ related-but-distinct rather than as a single grid. */
+.kpi-cluster-account {
+ margin-top: -8px;
+}
+.kpi-cluster-account .kpi {
+ border-left: 3px solid var(--accent);
+}
+.kpi-cluster-account .kpi-value-small {
+ font-size: 18px;
+ font-weight: 500;
+}
+.kpi-cluster-account .kpi-cta {
+ border-left-color: var(--fg-muted);
+}
+.kpi-cluster-account .kpi-notice {
+ border-left-color: var(--warn, #c87a00);
+}
+
/* ---------- Panels ---------- */
.panel {
diff --git a/src/attune/ops/templates/home.html b/src/attune/ops/templates/home.html
index 35cd00d9..3319c0db 100644
--- a/src/attune/ops/templates/home.html
+++ b/src/attune/ops/templates/home.html
@@ -40,6 +40,49 @@
attune ops
+{# Account-spend cluster (Phase 2 of anthropic-cost-integration).
+ Visually distinguished from the workflow-telemetry cluster above
+ via the .kpi-cluster-account class — these numbers come from the
+ Anthropic admin API, not local usage.jsonl. Gated on cost_summary
+ availability; CTA when no admin key; friendly notice on auth fail;
+ silent fall-through on transient errors so the dashboard stays
+ functional. #}
+{% if cost_summary is not none %}
+
+{% elif cost_error is not none and cost_error.kind == "no_key" %}
+
+{% elif cost_error is not none and cost_error.kind == "auth_failed" %}
+
+{% endif %}
+
Recent runs
{% if recent_runs %}
diff --git a/tests/unit/ops/test_anthropic_cost_home_integration.py b/tests/unit/ops/test_anthropic_cost_home_integration.py
new file mode 100644
index 00000000..92186e1a
--- /dev/null
+++ b/tests/unit/ops/test_anthropic_cost_home_integration.py
@@ -0,0 +1,172 @@
+"""Phase 2 of anthropic-cost-integration — home page wiring.
+
+The spec (docs/specs/anthropic-cost-integration/tasks.md, task 2.4)
+specifies four scenarios this file covers:
+
+1. No admin key → CTA tile, no traceback, dashboard otherwise functional
+2. Valid CostSummary → all three account KPI tiles populated
+3. Auth-failed error → friendly notice, dashboard otherwise functional
+4. ?refresh=1 query param → fetch called with refresh=True
+
+The fetch_summary call is mocked at its import site in dashboard.py
+(not at its definition site) so the dispatch in the home route picks
+up the mock. See CLAUDE.md lesson "Real project files on disk override
+test mocks" for the rationale.
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime, timezone
+from unittest.mock import patch
+
+import pytest
+
+pytest.importorskip("fastapi")
+pytest.importorskip("jinja2")
+
+from fastapi.testclient import TestClient # noqa: E402
+
+from attune.ops.anthropic_cost import CostFetchError, CostSummary # noqa: E402
+from attune.ops.config import build_config # noqa: E402
+from attune.ops.server import create_app # noqa: E402
+
+
+def _client(tmp_path, monkeypatch) -> TestClient:
+ monkeypatch.setenv("ATTUNE_HOME", str(tmp_path / "attune-home"))
+ config = build_config(
+ project_root=tmp_path,
+ trusted_hosts=("testserver", "test"),
+ )
+ app = create_app(config)
+ return TestClient(app)
+
+
+def _valid_summary() -> CostSummary:
+ return CostSummary(
+ today_usd=4.72,
+ seven_day_usd=18.31,
+ month_to_date_usd=42.50,
+ thirty_day_usd=42.50,
+ by_day=[(date(2026, 5, 19), 4.72)],
+ by_model=[("claude-sonnet-4-5", 4.72)],
+ by_cost_type=[("input_tokens", 1.10), ("output_tokens", 3.62)],
+ fetched_at=datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc),
+ source="live",
+ )
+
+
+def test_home_renders_cta_when_no_admin_key(tmp_path, monkeypatch):
+ """Home page shows the no-key CTA, no traceback, dashboard renders."""
+ err = CostFetchError(kind="no_key", message="no admin key configured")
+ with patch(
+ "attune.ops.anthropic_cost.fetch_summary",
+ return_value=(None, err),
+ ):
+ with _client(tmp_path, monkeypatch) as client:
+ resp = client.get("/")
+
+ assert resp.status_code == 200
+ # CTA copy and class are present.
+ assert "Set up cost reporting" in resp.text
+ assert "Connect billing" in resp.text
+ assert "kpi-cluster-account" in resp.text
+ # Telemetry tiles still render — dashboard is otherwise functional.
+ assert "Today's events" in resp.text
+ assert "7-day spend" in resp.text
+
+
+def test_home_renders_three_tiles_with_valid_summary(tmp_path, monkeypatch):
+ """All three account-spend KPI tiles populated with formatted USD."""
+ summary = _valid_summary()
+ with patch(
+ "attune.ops.anthropic_cost.fetch_summary",
+ return_value=(summary, None),
+ ):
+ with _client(tmp_path, monkeypatch) as client:
+ resp = client.get("/")
+
+ assert resp.status_code == 200
+ # Three tile labels.
+ assert "Today (account)" in resp.text
+ assert "7d (account)" in resp.text
+ assert "MTD (account)" in resp.text
+ # Formatted dollar values.
+ assert "$4.72" in resp.text
+ assert "$18.31" in resp.text
+ assert "$42.50" in resp.text
+ # Source indicator visible on the today tile.
+ assert "live" in resp.text
+
+
+def test_home_renders_notice_on_auth_failed(tmp_path, monkeypatch):
+ """auth_failed surfaces a friendly notice; dashboard stays functional."""
+ err = CostFetchError(kind="auth_failed", message="key rejected (HTTP 401)")
+ with patch(
+ "attune.ops.anthropic_cost.fetch_summary",
+ return_value=(None, err),
+ ):
+ with _client(tmp_path, monkeypatch) as client:
+ resp = client.get("/")
+
+ assert resp.status_code == 200
+ assert "Key rejected" in resp.text
+ assert "kpi-notice" in resp.text
+ # Friendly notice — no raw HTTP status or stack trace surfaced.
+ assert "HTTP 401" not in resp.text
+ # Workflow and version tiles still render.
+ assert "Workflows" in resp.text
+
+
+def test_home_passes_refresh_true_when_query_param_set(tmp_path, monkeypatch):
+ """?refresh=1 → fetch_summary called with refresh=True."""
+ summary = _valid_summary()
+ with patch(
+ "attune.ops.anthropic_cost.fetch_summary",
+ return_value=(summary, None),
+ ) as mock_fetch:
+ with _client(tmp_path, monkeypatch) as client:
+ resp = client.get("/?refresh=1")
+
+ assert resp.status_code == 200
+ mock_fetch.assert_called_once()
+ # Keyword arg, per the helper's keyword-only signature.
+ assert mock_fetch.call_args.kwargs.get("refresh") is True
+
+
+def test_home_default_call_passes_refresh_false(tmp_path, monkeypatch):
+ """Without ?refresh=1, fetch_summary is called with refresh=False."""
+ summary = _valid_summary()
+ with patch(
+ "attune.ops.anthropic_cost.fetch_summary",
+ return_value=(summary, None),
+ ) as mock_fetch:
+ with _client(tmp_path, monkeypatch) as client:
+ resp = client.get("/")
+
+ assert resp.status_code == 200
+ mock_fetch.assert_called_once()
+ assert mock_fetch.call_args.kwargs.get("refresh") is False
+
+
+def test_home_degrades_silently_on_fetch_exception(tmp_path, monkeypatch):
+ """Unexpected fetch exception → no tile cluster, dashboard renders.
+
+ Defensive try/except in the home route should swallow surprises and
+ let the rest of the page render. The cost-cluster simply doesn't
+ appear; no traceback bleeds into the HTML.
+ """
+ with patch(
+ "attune.ops.anthropic_cost.fetch_summary",
+ side_effect=RuntimeError("anthropic edge melted"),
+ ):
+ with _client(tmp_path, monkeypatch) as client:
+ resp = client.get("/")
+
+ assert resp.status_code == 200
+ # No tile cluster rendered.
+ assert "kpi-cluster-account" not in resp.text
+ # No traceback / exception text leaked.
+ assert "anthropic edge melted" not in resp.text
+ assert "Traceback" not in resp.text
+ # Dashboard otherwise functional.
+ assert "attune ops" in resp.text # hero title