Context API: Phase 0¶
FastAPI service on UDev that serves as the Forge fleet's curated context layer.
Implements the Decorator Crab master plan (see memory/handoffs/decorator-crab-master-plan-2026-04-21.md).
Status: Phase 0 live. Eight Sleep + Garmin wellness signals ingested from HA every 15 min.
Endpoints¶
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | / |
, | Service info |
| GET | /healthz |
, | Row counts + source registry summary |
| GET | /sources |
Bearer | Full source registry |
| POST | /ingest/<source> |
Bearer | Normalized fact ingest |
| GET | /context?about=wellness\|home\|all&window=24h |
Bearer | Curated JSON slice |
Bind¶
127.0.0.1:7358: LAN-only, Tailscale-reachable. Never exposed via Cloudflare Tunnel.
Data model (SQLite at /home/justinwieb/forge/data/context.db)¶
| Table | Purpose |
|---|---|
events_raw |
Append-only raw payloads, 90-day retention (manual cleanup cron TBD) |
facts_wellness |
One row per (ts, source, user_side, metric). Unique key prevents dupes. |
facts_home |
HA state snapshots |
sources |
Per-source last-success / last-error audit |
consents |
Per-source scope + revocation; tripwire row guards rule #1 |
Operations¶
# setup (idempotent)
bash infra/context-api/setup.sh
# start / stop / status
sudo systemctl [start|stop|status] forge-context-api
journalctl -u forge-context-api -f
# trigger a manual HA → context-api poll
infra/context-api/.venv/bin/python infra/context-api/scripts/ha_poller.py
# query from bash
TOKEN=$(grep CONTEXT_API_TOKEN .env | cut -d"'" -f2)
curl -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:7358/context?about=wellness&window=7d" | jq .
What the HA poller maps¶
| HA entity | Context metric | Source |
|---|---|---|
sensor.eight_sleep_<side>_sleep_score |
score |
eight_sleep |
sensor.eight_sleep_<side>_sleep_duration_min |
duration_min |
eight_sleep |
sensor.eight_sleep_<side>_hrv |
hrv |
eight_sleep |
sensor.eight_sleep_<side>_resting_heart_rate |
rhr |
eight_sleep |
sensor.eight_sleep_<side>_respiratory_rate |
resp_rate |
eight_sleep |
sensor.eight_sleep_<side>_bed_temp |
bed_temp |
eight_sleep |
sensor.eight_sleep_<side>_room_temp |
room_temp |
eight_sleep |
sensor.eight_sleep_<side>_presence_start/end |
presence_start/end |
eight_sleep |
sensor.eight_sleep_<side>_bed_presence |
bed_presence |
eight_sleep |
sensor.garmin_steps |
steps |
garmin |
sensor.garmin_distance_km |
distance_km |
garmin |
sensor.garmin_calories_{total,active} |
kcal_{total,active} |
garmin |
sensor.garmin_resting_heart_rate |
rhr |
garmin |
sensor.garmin_body_battery |
body_battery |
garmin |
sensor.garmin_stress_avg |
stress_avg |
garmin |
sensor.garmin_intensity_minutes |
intensity_min |
garmin |
sensor.garmin_sleep_{duration_min,score} |
sleep_{duration_min,score} |
garmin |
sensor.garmin_training_readiness |
training_readiness |
garmin |
Phase 0 exit criteria (not yet met)¶
- Schema created + HA ingest functional
- 5 consecutive days of clean HA + wellness ingestion
- Weather (NWS) normalizer added
- Frigate normalizer added
- Personal ICS normalizer added
- Daily rollup cron writes
memory/daily/YYYY-MM-DD_rollup.md
Rule #1 tripwire¶
db.init() refuses to start the service if any active consent row has
email = '[email protected]'. This hard-fails the service rather than
silently ingesting from personal Gmail.