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.
Part I — Build Your Own
The minimal version is small:
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.
| Ingredient | Use | Simple default |
|---|---|---|
| Hermes Agent | Runs the operator, cron jobs, scripts, and checks | One local profile on the machine that owns the data |
| SQLite | Durable local health database | ~/code/my-health/health.db |
| FastAPI | Receives phone exports | /api/health on 127.0.0.1 or LAN |
| Health Auto Export / Shortcuts | Pushes Apple Health-like data | Start with one metric before importing history |
| Wearable API | Recovery, sleep, strain, workouts | Whoop, Oura, Garmin, Fitbit, etc. |
| Hermes cron | Syncs, digests, reminders | Script-only jobs when no reasoning is needed |
| Ops note | Future-you documentation | README-HERMES.md |
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
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.
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;'
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:
# 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
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.
Hermes cron has two useful modes:
| Mode | Use it for | Example |
|---|---|---|
no_agent=true | Deterministic scripts | sync wearable, run SQL digest, push backup |
| agent job | Reasoning or prose | weekly 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.
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:
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 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.
| Piece | Before |
|---|---|
| Project tree | OpenClaw-owned project folder |
| Health receiver | LaunchAgent on :8400 |
| Meds UI | LaunchAgent on :8484 |
| Whoop sync | OpenClaw scheduled job |
| Digest | OpenClaw scheduled job |
| Medication reminders | OpenClaw scheduled jobs + Apple Reminders poller |
| Database | One large SQLite file |
The migration goal was not to redesign Milo Health. It was to make Hermes the explicit owner.
We moved the canonical path to:
~/code/milo-health
Then we made service ownership visible:
| Service | Hermes-owned label | Port |
|---|---|---|
| Health receiver | com.miloh.health-server | 8400 |
| Meds UI | com.miloh.milo-meds | 8484 |
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.
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.
| Gate | Result |
|---|---|
| Client credentials found | Yes, then stored in the password vault |
| Callback page live | HTTP 200 |
| Token exchange | Saved access + refresh token |
| Smoke sync | 3 days synced successfully |
| Backfill | April 1 → July 15 filled |
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.
| Table | Rows after migration |
|---|---|
apple_records | 12,921,642 |
whoop_recovery | 2,586 |
whoop_sleep | 4,163 |
whoop_cycles | 2,634 |
whoop_workouts | 2,859 |
metrics | 386,068 |
lab_results | 217 |
Total project size was about 14 GB. The SQLite database itself was about 6.9 GB.
Once Hermes bootstrapped the new LaunchAgents, we tested the surfaces directly:
| Check | Result |
|---|---|
:8400 /docs | HTTP 200 |
:8400 POST /api/health | {"status":"ok","metrics_ingested":1} |
:8484 /health | {"ok":true} |
| Latest Whoop recovery from new path | July 15, score 64 |
That last check matters: it proved the new path was reading the migrated database, not silently using the old tree.
| Hermes job | Schedule | Mode | Purpose |
|---|---|---|---|
whoop-daily-sync | 06:30 | no_agent | Sync Whoop and push backup snapshot |
milo-health-digest | 07:00 | no_agent | Deterministic SQL digest to Telegram |
med-reminders-poller | every 15 min | no_agent | Poll Apple Reminders completion |
med-morning-stack | 05:00 | agent | Medication reminder with day-specific injections |
med-noon-stack | 12:00 | agent | Noon supplement reminder |
med-evening-stack | 20:00 | agent | Evening stack and bedtime reminders |
med-weekly-report | Sunday 08:00 | agent | Compliance 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.
After Hermes was live, we disabled the old OpenClaw jobs. Not “noted.” Disabled.
| Old job family | State |
|---|---|
| Whoop daily sync | Disabled |
| Milo Health daily digest | Disabled |
| Morning medication reminder | Disabled |
| Noon medication reminder | Disabled |
| Evening medication reminder | Disabled |
| Reminder completion poller | Disabled |
| Weekly medication report | Disabled |
| Piece | Owner now |
|---|---|
| Code | ~/code/milo-health |
| Receiver | Hermes-owned LaunchAgent on :8400 |
| Meds UI | Hermes-owned LaunchAgent on :8484 |
| Whoop OAuth | Fresh tokens, vault-backed credentials |
| Daily Whoop sync | Hermes cron |
| Morning digest | Hermes cron to Telegram |
| Medication reminders | Hermes crons |
| Old OpenClaw jobs | Disabled |
| Ops note | ~/code/milo-health/README-HERMES.md |
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