Skip to main content
Glama
README.md
# homestack

Pulls data from personal devices into one SQLite file, then exposes it two ways: an MCP server so Claude can answer questions about it, and an HTTP API for a dashboard.

The point of the split is that neither adapter owns any logic. Both call the same functions in `core/query.py`, so they can't drift apart, and a UI never has to speak MCP.

Oura is the first source. Rachio, Ring, and Eight Sleep are meant to slot in as new files under `core/sources/` with no schema change.

## How it fits together

```
core/sources/*.py   pull from a vendor API, map to rows
core/store.py       SQLite: observations, events, raw, sync_state
core/query.py       the read functions — all real logic lives here
  ├── mcp_server.py   MCP adapter (7 tools)
  └── api.py          HTTP adapter (8 routes)
sync.py             one-shot pull, for cron or by hand
run.py              container entrypoint: api + mcp + a sync loop
```

`observations` is deliberately narrow — `(source, metric, ts, value, unit)` — so a question that spans devices, like sleep score against bedroom temperature, is one query no matter how many sources exist. `raw` keeps every untouched API document, so a mapping can be changed and re-derived without re-fetching.

Nothing writes to a device. The Oura scopes are read-only, and the only tool that reaches outward is `sync_now`, which pulls.

## Setup

Register an application at https://cloud.ouraring.com/oauth/applications with redirect URI `http://localhost:8765/callback` — Oura rejects http for anything but the literal hostname `localhost`. Check every scope except Email.

```bash
python -m venv .venv && .venv/Scripts/activate    # Linux/macOS: source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env                              # fill in client id + secret
python auth_oura.py                               # one-time browser consent
python sync.py --since 2020-01-01                 # full backfill
```

`auth_oura.py` writes `data/tokens.json`. Copy it to the server along with `.env` and the sync refreshes on its own from then on — the redirect URI is never used again.

## Running it

Locally, for development:

```bash
uvicorn api:app --port 8770
python mcp_server.py
```

On Unraid, as one container plus a Tailscale sidecar:

```bash
docker compose up -d --build
```

Nothing is published to the LAN. The sidecar puts it on the tailnet at `https://homestack.<tailnet>.ts.net`, so your phone reaches it from anywhere without opening a port. On first boot the node needs one interactive login — `docker logs homestack-ts` prints the URL — unless you put a `TS_AUTHKEY` in `.env`.

Register the MCP server with Claude Code:

```bash
claude mcp add homestack --transport http http://192.168.88.39:8771/mcp
```

## Two things that will bite you

**Oura refresh tokens are single-use.** Every refresh returns a new one and kills the old. `save_tokens()` writes atomically and keeps `tokens.json.bak` for exactly this reason. If both are lost, re-run `auth_oura.py`.

**Heart rate needs 30-day request chunks** and produces a sample roughly every five minutes, which outnumbers every other metric put together. It's off by default; set `OURA_SYNC_HEARTRATE=true` if you want it.

## Gmail

Deliberately absent. Email is the only source an attacker can write into for free, so if it's ever added the rule is: the pull job reads it with no model in the loop, the store keeps sender, subject, and derived flags but never bodies, and there is no live mailbox tool.