Building a Personal Life Dashboard with FastAPI, MCP, and HTMX

A personal life dashboard built with FastAPI, HTMX, and Model Context Protocol, aggregating data from seven sources into one self-hosted app that both a browser and Claude can use.

Building a Personal Life Dashboard with FastAPI, MCP, and HTMX

I wanted a single place to see my day: what I read, what I listened to, how I slept, what my infrastructure is doing, and what I need to work on. No SaaS dashboard with a monthly fee and a privacy policy longer than its feature list. Something I control, running on my own cluster, that Claude can read and write to directly.

The result is Life Hub: a FastAPI application with an HTMX frontend and a Model Context Protocol server. It collects data from seven sources, stores everything in SQLite, and exposes it through both a browser UI and Claude’s tool-calling interface.

Why Build This

My data was scattered. Reading history in Miniflux. Music in Navidrome. Audiobooks in Audiobookshelf. Infrastructure status in Uptime Kuma. Blog posts in Ghost. Code activity on GitHub. Weather from Open-Meteo. Habits and journal entries in... nowhere, because I hadn’t found a tracker I actually liked.

I didn’t want to connect all these services together with Zapier or n8n. I wanted a simple aggregation layer that pulls data on a schedule, stores it locally, and presents it on one page. And I wanted Claude to have access to it, not through screen scraping or API wrappers, but through native MCP tools.

Architecture

The stack is deliberately boring:

  • FastAPI: async Python web framework
  • SQLAlchemy 2.0+ with aiosqlite: async ORM over SQLite
  • HTMX + Alpine.js + Tailwind CSS: server-rendered UI, no build step
  • MCP 1.26+: Model Context Protocol server (Streamable HTTP transport, stateless)
  • httpx: async HTTP client for collectors

The app has three interfaces:

  1. Browser UI: HTMX forms post to /htmx/* endpoints, which return HTML fragments
  2. REST API: Full CRUD at /api/v1/* for programmatic access
  3. MCP Server: 14 tools + 3 resources at /mcp for Claude integration

Data flows in from two directions: manual input through the browser or MCP tools (habits, mood, sleep, journal, goals) and automated collection from external services every 15 minutes via a CronJob.

Database

SQLite in WAL mode. Ten tables, no migrations infrastructure beyond what Alembic provides. Five tables are for manual tracking (habits, mood, sleep, journal, goals) and five are for auto-synced data (activity events, health metrics, infra snapshots, finance snapshots, sync state).

The ActivityEvent table is the spine of the whole system. Every collector writes here, and every user action (toggle a habit, log mood, update a goal) creates an entry. The timeline page just queries this table in reverse chronological order. One table, one query, complete history.

class ActivityEvent(Base):
    __tablename__ = "activity_events"

    id: Mapped[int] = mapped_column(primary_key=True)
    source: Mapped[str] = mapped_column(nullable=False)      # miniflux, navidrome, user, etc.
    category: Mapped[str] = mapped_column(nullable=False)     # reading, music, tracking, etc.
    event_type: Mapped[str] = mapped_column(nullable=False)   # article_read, habit_logged, etc.
    title: Mapped[str] = mapped_column(nullable=False)
    event_metadata: Mapped[str | None] = mapped_column("metadata", nullable=True)  # JSON blob
    timestamp: Mapped[str] = mapped_column(nullable=False)

I chose event_metadata as the attribute name because metadata is reserved by SQLAlchemy’s declarative API. The column is still called metadata in the actual database. SQLAlchemy maps between them.

The Collectors

Seven collectors run every 15 minutes via a Kubernetes CronJob. Each one inherits from a BaseCollector class, implements a collect() method, and posts results to the ingest API.

CollectorSourceWhat It Captures
MinifluxRSS readerArticles read (title, feed, URL, reading time)
NavidromeMusic serverRecently played albums (artist, genre, year)
AudiobookshelfAudiobook serverListening sessions (duration, progress, series)
Uptime KumaStatus monitorService status (up/down, response time)
GhostBlog CMSPublished posts (title, tags, reading time)
GitHubCode hostingEvents (pushes, PRs, issues)
Open-MeteoWeather APICurrent conditions (temp, humidity, wind)

The ingest API deduplicates on (source, timestamp, title) for activity events and (monitor_name, timestamp) for infra snapshots. Collectors can run as often as they want without creating duplicate records. This matters because the CronJob has restartPolicy: OnFailure. If a run partially fails and retries, previously ingested records are skipped.

Each collector runs independently. If Navidrome is down, the other six collectors still complete. Errors are logged but don’t propagate.

The MCP Server

This is the part I’m most excited about. Life Hub exposes a Model Context Protocol server that Claude can connect to directly. No webhooks, no API wrappers. Claude calls tools and gets structured data back.

Write Tools

log_habit(date, name, completed, notes)     # Toggle a habit
log_mood(date, score, tags, notes)           # Rate the day 1-10
log_sleep(date, hours, quality, notes)       # Record sleep
log_journal(date, content, tags)             # Write a journal entry
update_goal(goal_id, progress, status)       # Update goal progress

Read Tools

get_today_summary()                          # Full daily overview
get_habit_streak(name, days)                 # Current and longest streak
get_finance_summary(period)                  # Account balances, totals
get_reading_stats(days)                      # Articles read, sources
get_music_stats(days)                        # Albums played, genres
get_health_metrics(metric, days)             # Avg/min/max for a metric
get_infra_status()                           # All monitors, up/down count
get_github_activity(days)                    # Commits, PRs, issues
get_goals()                                  # All goals by status

Resources

life://today                                 # Today's summary as markdown
life://goals                                 # Goals formatted as markdown
life://journal/{date}                        # Journal entry for a date

In practice, I can ask Claude “how did I sleep this week?” and it calls get_today_summary() or queries sleep data directly. I can say “log that I meditated today” and it calls log_habit(). The MCP server does real database queries, not mock data, not canned responses.

Authentication uses the same API key as the REST API, validated with hmac.compare_digest() for timing safety. The MCP server runs as an ASGI sub-application mounted at /mcp with its own auth middleware.

The Frontend

HTMX was the right choice here. The dashboard is server-rendered HTML with interactive elements that post form data and swap HTML fragments. No React, no npm, no webpack. The entire frontend is three files: base.html (layout + nav), page templates (today, timeline, goals, journal), and app.js (40 lines that inject the API key into HTMX requests).

The Today page has three sections:

  • Left column: Habits checklist (clickable toggles), mood picker (1-10 buttons), sleep form
  • Right column: Weather card, active goals with progress bars, recent activity feed

Every interaction (checking a habit, picking a mood score, logging sleep) is an HTMX POST that returns an HTML fragment. The fragment replaces the relevant section of the page. No full page reloads, no client-side state management, no loading spinners.

<!-- Habit checkbox: posts to /htmx/habits, replaces the habits list -->
<input type="checkbox" 
  hx-post="/htmx/habits" 
  hx-vals='{"date": "2026-02-28", "name": "Meditate"}'
  hx-target="#habits-list" 
  hx-swap="outerHTML">

Mood and sleep use an upsert pattern: one entry per day. If you log mood twice on the same day, it updates the existing record rather than creating a duplicate. The form pre-populates with today’s existing values so you can see what you already logged.

Deployment

Life Hub runs as a single-replica Deployment on my Kubernetes cluster. The image builds via GitHub Actions and pushes to ghcr.io/snaviaux/life-hub.

The Kubernetes setup includes:

  • Deployment: FastAPI pod (96Mi request / 256Mi limit), health probes on /health
  • Collector CronJob: Runs every 15 minutes, same image, calls python collector_cli.py
  • Backup CronJob: Nightly at 2:45 AM, copies SQLite + WAL to NFS on Unraid
  • Ingress: Traefik with Authentik ForwardAuth + security headers
  • Network Policies: Default-deny with explicit allows per workload type

The collector CronJob gets the same environment variables as the main deployment: all the API keys and service URLs. It posts collected data to the Life Hub API via the internal Kubernetes DNS name (life-hub.life-hub.svc.cluster.local), not the external URL. Network policies scope its egress to exactly the services it needs to reach.

Authentication at the browser level is handled by Authentik SSO (ForwardAuth on the Traefik ingress). The API key is injected server-side via a meta tag for HTMX to pick up. This means the browser UI has two auth layers: Authentik verifies the user, then the API key authenticates HTMX requests.

Security

I ran a full security audit after the initial deployment:

  • Timing-safe auth: hmac.compare_digest() on all API key comparisons to prevent timing oracle attacks
  • Auth rate limiting: 10 failed attempts per IP triggers a 5-minute lockout: no brute-force window
  • XSS prevention: html.escape() on all user input rendered in Python HTML generators. Jinja2 templates auto-escape by default.
  • Input validation: Pydantic schemas with max_length, range constraints (ge=0, le=100), status allowlists, and extra="forbid" on ingest schemas
  • OpenAPI docs disabled: docs_url=None in production (no unauthenticated Swagger UI)
  • Non-root container: UID 1000, all capabilities dropped, seccomp enabled

What I Learned

MCP changes how you think about personal tools. Before MCP, building a personal dashboard meant building a UI. Now it also means building an interface for your AI assistant. Claude can log a habit, check my sleep stats, and summarize my week, without me opening a browser. The write tools are surprisingly useful: “log that I ran today” in a conversation is faster than navigating to the dashboard.

HTMX is the right tool for this kind of app. A personal dashboard doesn’t need client-side routing, a virtual DOM, or a state management library. It needs forms that submit data and sections that update. HTMX does this in 40 lines of JavaScript. The entire frontend has no build step. I edit an HTML file and push.

SQLite is underrated for single-user apps. No postgres container, no connection pooling, no backup complexity beyond copying a file. WAL mode gives me concurrent reads during writes. The entire database is a single file I can tar to NFS every night. For a personal dashboard that will never need horizontal scaling, SQLite is the right answer.

Collectors should be idempotent. The deduplication layer in the ingest API was one of the first things I built. Every collector posts records with a natural key (source + timestamp + title), and the API skips duplicates silently. This means collectors can run on any schedule, retry on failure, and overlap without creating noise in the timeline.

The upsert pattern matters for daily tracking. Early versions created a new mood entry every time you clicked a score. By the end of the day, you’d have six mood entries instead of one. Switching to upsert (one entry per date, update if exists) made the data actually useful.

What’s Next

The finance and health pages are still placeholders. The backend supports them (the ingest API accepts finance snapshots and health metrics), but the UI just shows “coming soon.” I need to build real charts and connect an Actual Budget collector (which requires a Node.js sidecar because Actual’s API is JavaScript-only).

I also want to replace the shared API key pattern with session cookies for browser auth. Right now the API key is embedded in a meta tag on every page, which is safe behind Authentik but architecturally messy. Session cookies would let me drop the meta tag and reserve the API key for MCP and programmatic clients only.

The code is a private repo at snaviaux/life-hub on GitHub, and the Kubernetes manifests live in my home-ops GitOps repo. If you’re building something similar, the interesting parts are the MCP integration and the collector/ingest pattern. Both are straightforward to adapt to different data sources.