← Home

Build a Hermes Health Stack, Then Migrate It

July 15, 2026 · hermes health-data sqlite recipe migration

This post has two parts.

Part I is the useful recipe: how a Hermes user can build their own personal health automation stack from scratch. Not a product. Not a quantified-self SaaS. Just a local database, a receiver, a few API pulls, and scheduled summaries that your agent can maintain.

Part II is our case study: how we moved the existing Milo Health system from OpenClaw to Hermes without losing the database, double-firing medication reminders, or pretending OAuth would magically survive the move.

Short version: start with one SQLite file, one HTTP receiver, one wearable sync, one digest, and one ops note. Once it works, let Hermes own the schedules and receipts.
SQLite
local source of truth
FastAPI
simple phone receiver
Hermes
scheduler + operator
OAuth
wearable APIs
Cron
sync + digest
Receipts
smoke tests, not vibes

Part I — Build Your Own

The Shape of a Hermes Health Stack

The minimal version is small:

Phone / wearable / manual inputs │ ├─ Health Auto Export or Shortcuts → HTTP POST ├─ Wearable API sync → OAuth pull ├─ Lab/CSV imports → occasional batch load └─ Medication reminders → scheduler + compliance log Hermes machine ├─ FastAPI receiver on localhost/LAN ├─ SQLite health.db ├─ scripts/ for sync and digest ├─ Hermes cron jobs └─ optional backup snapshot

The mistake is trying to build the whole thing at once. The right recipe is staged: first receive one metric, then store it, then query it, then schedule it, then add sources.

Ingredients

IngredientUseSimple default
Hermes AgentRuns the operator, cron jobs, scripts, and checksOne local profile on the machine that owns the data
SQLiteDurable local health database~/code/my-health/health.db
FastAPIReceives phone exports/api/health on 127.0.0.1 or LAN
Health Auto Export / ShortcutsPushes Apple Health-like dataStart with one metric before importing history
Wearable APIRecovery, sleep, strain, workoutsWhoop, Oura, Garmin, Fitbit, etc.
Hermes cronSyncs, digests, remindersScript-only jobs when no reasoning is needed
Ops noteFuture-you documentationREADME-HERMES.md
Privacy posture: keep this local by default. Health data does not need a public cloud database to become useful. If you expose a receiver to the LAN or internet, do it deliberately and document why.

Step 1: Create the Project

mkdir -p ~/code/my-health/scripts
cd ~/code/my-health
python3 -m venv .venv
. .venv/bin/activate
pip install fastapi uvicorn

Keep the shape boring. Boring is good here.

~/code/my-health/
  server.py
  health.db
  scripts/
    sync_wearable.py
    digest.py
    backup.sh
  README-HERMES.md

Step 2: Start With a Tiny Schema

You do not need a perfect health ontology on day one. You need a table that can accept time-series records and a table for source-specific payloads.

CREATE TABLE IF NOT EXISTS metrics (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  source TEXT NOT NULL,
  metric_name TEXT NOT NULL,
  value REAL,
  unit TEXT,
  started_at TEXT NOT NULL,
  raw_json TEXT,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_metrics_name_time
ON metrics(metric_name, started_at);

Later you can add specialized tables for workouts, sleep, recovery, blood pressure, labs, medication compliance, or body composition. But do not block the first working receiver on the perfect schema.

Step 3: Add a Receiver

A basic receiver is enough to prove the loop from phone → database:

# server.py
import json, sqlite3
from fastapi import FastAPI, Request

DB = "health.db"
app = FastAPI()

@app.post("/api/health")
async def ingest(req: Request):
    payload = await req.json()
    rows = payload.get("metrics", [])
    db = sqlite3.connect(DB)
    db.execute("""
      CREATE TABLE IF NOT EXISTS metrics (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        source TEXT, metric_name TEXT, value REAL,
        unit TEXT, started_at TEXT, raw_json TEXT,
        created_at TEXT DEFAULT CURRENT_TIMESTAMP
      )
    """)
    count = 0
    for metric in rows:
        name = metric.get("name")
        unit = metric.get("units")
        for item in metric.get("data", []):
            db.execute(
              "INSERT INTO metrics(source, metric_name, value, unit, started_at, raw_json) VALUES (?, ?, ?, ?, ?, ?)",
              (item.get("source", "phone"), name, item.get("qty"), unit, item.get("date"), json.dumps(item)),
            )
            count += 1
    db.commit(); db.close()
    return {"status": "ok", "metrics_ingested": count}

Run it:

uvicorn server:app --host 127.0.0.1 --port 8400

Smoke it before connecting any phone app:

curl -sS -X POST http://127.0.0.1:8400/api/health \
  -H 'Content-Type: application/json' \
  -d '{"metrics":[{"name":"migration_smoke","units":"count","data":[{"date":"2026-07-15T12:00:00Z","qty":1,"source":"curl"}]}]}'

sqlite3 health.db 'SELECT metric_name, value, started_at FROM metrics ORDER BY id DESC LIMIT 1;'

Step 4: Add a Wearable Sync

Wearable APIs are where the data becomes interesting and where migrations often fail. Put OAuth credentials in a config file with strict permissions or in a password manager, not in the script.

chmod 600 ~/.my-wearable-config.json

Your sync script should do three things:

  1. refresh OAuth tokens if needed;
  2. pull a small date window by default;
  3. upsert or replace records idempotently.
# scripts/sync_wearable.py --days 3
# Pseudocode:
# 1. load config
# 2. refresh token if expiring
# 3. GET /sleep, /recovery, /workouts since N days ago
# 4. write into health.db with provider IDs as unique keys
OAuth rule: do a real token refresh and a three-day smoke sync before you build schedules around it. Dead refresh tokens create fake migration failures.

Step 5: Add a Digest

Make the first digest deterministic. SQL in, text out. Do not use a language model unless you need judgment.

# scripts/digest.py
import sqlite3

db = sqlite3.connect("health.db")
latest_weight = db.execute("""
  SELECT value, started_at FROM metrics
  WHERE metric_name = 'body_mass'
  ORDER BY started_at DESC LIMIT 1
""").fetchone()

print("🏥 Morning Health Digest")
if latest_weight:
    print(f"⚖️ Weight: {latest_weight[0]} ({latest_weight[1][:10]})")

Once the output looks right in the terminal, wire it into Hermes cron.

Step 6: Let Hermes Own the Schedule

Hermes cron has two useful modes:

ModeUse it forExample
no_agent=trueDeterministic scriptssync wearable, run SQL digest, push backup
agent jobReasoning or proseweekly interpretation, exception summaries, reminders with day-specific wording

Put tiny wrappers in ~/.hermes/scripts/, and keep real code in your project:

# ~/.hermes/scripts/my_health_digest.py
#!/usr/bin/env python3
import runpy
runpy.run_path("/Users/you/code/my-health/scripts/digest.py", run_name="__main__")

Then create the job from Hermes:

# conceptual shape
cronjob create \
  --name my-health-digest \
  --schedule '0 7 * * *' \
  --script my_health_digest.py \
  --no-agent \
  --deliver telegram

The exact command depends on your Hermes interface, but the architecture is the point: script-only for deterministic work, agent jobs for language and judgment.

Step 7: Add Backups and an Ops Note

SQLite backups are easy and reliable if you use the backup API instead of copying a hot file blindly:

python3 - <<'PY'
import sqlite3
src = 'health.db'
bak = '/tmp/health-backup.db'
c = sqlite3.connect(src)
b = sqlite3.connect(bak)
c.backup(b)
b.close(); c.close()
print('snapshot ok')
PY

Write README-HERMES.md while the system is fresh:

Minimum Viable Health Stack Checklist

  1. One metric successfully posts to your receiver.
  2. One SQLite query returns it.
  3. One wearable sync pulls recent data.
  4. One digest script prints a useful summary.
  5. One Hermes cron runs that script.
  6. One backup snapshot can be restored.
  7. One ops note explains the whole thing.

If you have those seven, you have the foundation. Historical imports, labs, medication compliance, dashboards, and model-assisted analysis can come later.

Part II — What We Migrated

Milo Health Before the Move

Milo Health already existed. It was built under OpenClaw and had grown into real personal infrastructure: Apple Health rows, Whoop history, a FastAPI receiver, medication reminders, a medication UI, and a morning digest.

PieceBefore
Project treeOpenClaw-owned project folder
Health receiverLaunchAgent on :8400
Meds UILaunchAgent on :8484
Whoop syncOpenClaw scheduled job
DigestOpenClaw scheduled job
Medication remindersOpenClaw scheduled jobs + Apple Reminders poller
DatabaseOne large SQLite file

The migration goal was not to redesign Milo Health. It was to make Hermes the explicit owner.

The Actual Cutover

Before OpenClaw project tree ├─ FastAPI receiver on :8400 ├─ Meds UI on :8484 ├─ Whoop sync cron ├─ morning digest cron └─ medication reminder crons After Hermes project tree: ~/code/milo-health ├─ com.miloh.health-server → :8400 ├─ com.miloh.milo-meds → :8484 ├─ Hermes cron: whoop-daily-sync ├─ Hermes cron: milo-health-digest ├─ Hermes cron: med reminders/reporting └─ off-box snapshot after sync

We moved the canonical path to:

~/code/milo-health

Then we made service ownership visible:

ServiceHermes-owned labelPort
Health receivercom.miloh.health-server8400
Meds UIcom.miloh.milo-meds8484

What We Checked Before Copying

We exported the old scheduler state, inspected the old LaunchAgents, checked listeners, and measured the database before touching it.

launchctl print gui/$(id -u)/com.milo.health-server
launchctl print gui/$(id -u)/com.milo.milo-meds
lsof -nP -iTCP:8400,8484 -sTCP:LISTEN
openclaw cron list --json > old-cron-export.json

The most important check was ownership: OpenClaw owned the jobs; Hermes did not yet have health jobs. That told us there was only one old owner to disable.

OAuth Was Repaired First

Whoop was stale, so we fixed OAuth before moving the runtime. That was the right call. If we had moved first, we would have blamed the migration for a credential problem.

GateResult
Client credentials foundYes, then stored in the password vault
Callback page liveHTTP 200
Token exchangeSaved access + refresh token
Smoke sync3 days synced successfully
BackfillApril 1 → July 15 filled
Real edge case: the Whoop token endpoint rejected a plain Python request with a Cloudflare error. Adding a normal browser User-Agent fixed token exchange and refresh. Tiny integration weirdness like this is why live smoke tests matter.

SQLite Copy Gate

Before copying, we stopped writers and checkpointed SQLite:

launchctl bootout gui/$(id -u)/com.milo.health-server || true
launchctl bootout gui/$(id -u)/com.milo.milo-meds || true
sqlite3 "$SRC/health.db" 'PRAGMA wal_checkpoint(TRUNCATE);'
rsync -aH --stats "$SRC/" "$DST/"

Then we compared row counts before and after. The copy was not accepted until the counts matched.

TableRows after migration
apple_records12,921,642
whoop_recovery2,586
whoop_sleep4,163
whoop_cycles2,634
whoop_workouts2,859
metrics386,068
lab_results217

Total project size was about 14 GB. The SQLite database itself was about 6.9 GB.

Smoke Tests After Bootstrap

Once Hermes bootstrapped the new LaunchAgents, we tested the surfaces directly:

CheckResult
:8400 /docsHTTP 200
:8400 POST /api/health{"status":"ok","metrics_ingested":1}
:8484 /health{"ok":true}
Latest Whoop recovery from new pathJuly 15, score 64

That last check matters: it proved the new path was reading the migrated database, not silently using the old tree.

The Hermes Jobs We Created

Hermes jobScheduleModePurpose
whoop-daily-sync06:30no_agentSync Whoop and push backup snapshot
milo-health-digest07:00no_agentDeterministic SQL digest to Telegram
med-reminders-pollerevery 15 minno_agentPoll Apple Reminders completion
med-morning-stack05:00agentMedication reminder with day-specific injections
med-noon-stack12:00agentNoon supplement reminder
med-evening-stack20:00agentEvening stack and bedtime reminders
med-weekly-reportSunday 08:00agentCompliance summary

For deterministic jobs we used wrapper scripts in ~/.hermes/scripts/ that call project scripts under ~/code/milo-health/scripts/. That keeps Hermes happy while keeping real operational code with the project.

Old Owner Disabled

After Hermes was live, we disabled the old OpenClaw jobs. Not “noted.” Disabled.

Old job familyState
Whoop daily syncDisabled
Milo Health daily digestDisabled
Morning medication reminderDisabled
Noon medication reminderDisabled
Evening medication reminderDisabled
Reminder completion pollerDisabled
Weekly medication reportDisabled
Medication automation rule: double-firing is worse than a messy migration log. The cutover is not done until one runtime owns the reminders.

Final State

PieceOwner now
Code~/code/milo-health
ReceiverHermes-owned LaunchAgent on :8400
Meds UIHermes-owned LaunchAgent on :8484
Whoop OAuthFresh tokens, vault-backed credentials
Daily Whoop syncHermes cron
Morning digestHermes cron to Telegram
Medication remindersHermes crons
Old OpenClaw jobsDisabled
Ops note~/code/milo-health/README-HERMES.md

The General Lesson

The build-your-own version and the migration version have the same spine: make the data local, make ownership explicit, keep the scheduler boring, and verify everything with receipts.

Hermes is a good fit for this because it can be both the operator and the notebook: it runs the jobs, keeps the scripts close, stores the plan, and can later inspect the system it owns. But Hermes does not remove the need for cutover discipline. If anything, the better the agent gets, the more important the receipts become.

Related: Milo Health V1: 13 Million Data Points, One SQLite File