Skip to content

dashthird-0/anira

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 

Repository files navigation

Anira, my health assistant

Write-up on what it does here. Technical write-up below

The setup

Claude Code running headless on a $6 DigitalOcean droplet (1 vCPU, 1GB RAM, Ubuntu 24.04), reachable from my phone over Telegram. WHOOP data syncs every 2 hours, meals come in from CalWise, my calendar is readable, and a compact health snapshot is loaded in every session, with the full three-year history a file-read away. systemd, a watchdog and an external dead-man's switch keeps the setup always-on.

flowchart LR
    W[WHOOP API] -->|"timer, every 2h"| DB[(whoop.db<br/>SQLite)]
    DB --> DG[digest.py]
    DG -->|rewrites one block| H[HEALTH.md<br/>always loaded]
    H --> C[Claude Code<br/>tmux, systemd]
    CW[CalWise<br/>meal logs] --> C
    CAL[calendar] --> C
    L[labs/ 3-yr history<br/>inbox/ original PDFs] -.->|read on demand| C
    C <-->|Telegram bridge| P[phone]
    NT[nudge timer<br/>6x/day] --> NE[one-shot evaluation<br/>headless, gated by rules]
    H --> NE
    NE -->|"only if warranted"| P
    WD[watchdog<br/>every 10 min] -->|self-heal + alert| C
    WD -->|ping| HC[healthchecks.io]
Loading

The context system

  • CLAUDE.md holds standing instructions and imports HEALTH.md, so every session starts already knowing me.
  • HEALTH.md is the always-loaded snapshot: current protocol, active issues, and a WHOOP block that digest.py rewrites after every sync. Regenerating one block in place keeps the file current without letting it grow.
  • labs/ holds the full 3-year history (blood panels, DEXA, CT, body composition) as one markdown document, read on demand when a question actually needs history.
  • inbox/ holds the original PDFs, 63MB of them, for the rare case where the extraction is in doubt.

New lab report? Drop the PDF on the box, the assistant reads it, updates the history document, and the next conversation already knows. There is no re-explaining step anywhere in the loop.

The sync

A systemd timer runs every 2 hours: pull the last 7 days from the WHOOP API into SQLite, then regenerate the HEALTH.md block. Persistent=true on the timer means a rebooted box catches up on wake instead of silently skipping runs. The 7-day overlap, combined with UPSERTs keyed by WHOOP's own record IDs, makes each sync idempotent; late-arriving revisions (WHOOP re-scores sleep after the fact) just overwrite the row.

WHOOP's API exposes limited data - you get cycle-level summaries (recovery, strain, sleep stages, HRV, resting heart rate), not the continuous heart-rate stream the app shows. Which is quite sad but it's what it is.

The other two feeds

WHOOP covers the training side. Two more inputs close the loop:

  • CalWise. Meals come in from CalWise (a food-logging app I built), so intake questions get answered against what I actually ate
  • Calendar. Read access to my calendar, so that it has visibility into my schedule

The proactive loop

A systemd timer fires 6 times a day. Each firing runs a one-shot headless Claude evaluation in its own session, with a single job: read the snapshot and the fresh data, decide whether anything is worth saying, and either send one Telegram message or exit silently. Most often, the latter.

The split between rules and model judgment is explicit. Deterministic gates run first and cost nothing: did the last sync actually succeed (no nudging off stale data), was a nudge already sent in this window (a state file dedups), and is it inside quiet hours. Only when the gates pass does the model get invoked, and the model owns the judgment: whether today's picture warrants a message, and what it should say.

Keeping 1GB alive

Anthropic's system requirements for Claude Code say 4GB of RAM or more. This one has 1GB. This is a conscious call I've made right now with the risk that OOM might kill the box. Mitigating this with the following:

  1. systemd resilience. Restart=always, RestartSec=10, and StartLimitIntervalSec=0 so it never permanently gives up. MemoryHigh=700M applies reclaim pressure as the process nears the line (a soft ceiling, not a hard cap), and OOMScoreAdjust=-500 biases the kernel's OOM killer toward picking something else. This is still not fool-proof.

  2. A watchdog timer, every 10 minutes. It self-heals before it alerts: kills orphaned bridge processes, clears systemd's failed state, detects dead sessions and expired auth (with a 90-second grace period so it doesn't kill a service that's still starting). If it auto-restarts more than 3 times in an hour, it escalates to me instead of looping. Alerts are rate-limited to one per 6 hours.

  3. A dead-man's switch. Every watchdog run pings a healthchecks.io check (period 10 minutes, grace 5). If the box, the network, or the watchdog itself dies, the silence triggers an external alert.

Learnings

Claude billing

Claude Code on a server wants a long-lived credential, and claude setup-token will happily generate a 1-year one. Don't. On my setup it flipped billing from the Max subscription to per-token API charges and swapped the model to Sonnet without any heads-up. I found out after I was billed the API charges. The fix: stay on interactive OAuth (log in once inside tmux; the credential auto-refreshes).

WHOOP refresh tokens rotate

WHOOP's refresh token is single-use: every refresh kills the old token, so exactly one process should ever own the refresh. I ran the sync from my laptop once while the server's timer was live and locked the box out until I re-authorized manually.

The orphan bridge

This was quite frustrating. The Telegram bridge is a background process the channel plugin tracks, detached from the Claude process tree. When Claude dies, the bridge can survive as an orphan (adopted by PID 1, still holding Telegram's long-poll connection), and every freshly started bridge then gets HTTP 409 Conflict, forever. A restart of the service doesn't reliably clear it. I haven't fully root-caused why this happens; tmux plus a pidfile-managed background process gives it several candidates. So the watchdog hunts for bridges whose parent is PID 1 and kills them before restart. This has fixed it for now.

Security

This setu[p is what Simon Willison calls the lethal trifecta: private data (my medical history), untrusted input (anything I forward it), and a shell with network egress. And the agent runs with permission prompts disabled (it has to; nobody is there to approve), which is quite dangerous.

So how am I managing this? The Telegram bot answers exactly one allowlisted chat id and drops everything else, so strangers can't reach it at all. SSH is key-only, no password fallback. There's a PreToolUse guard hook that hard-blocks the worst commands (recursive deletes of critical paths, curl piped to shell, reads of credential files) even in bypass mode, plus deny rules on the SSH and credential paths. These reduce blast radius against the realistic threat, which is indirect prompt injection through a document or page I ask it to read. This is not fool-proof but the trade-off works for me. Also, context goes to Anthropic's servers whenever Claude reasons over it, and messages transit Telegram's servers.

What it costs

  • Server costs: $6/month
  • A Claude subscription. I already have a Max plan, so the marginal cost is zero. No per-token API bills, by design (see the billing trap above).

Want one?

If you want to build a version of this for yourself, reach out to me and I'll walk you through it.

About

How I built Anira: an always-on personal health assistant on a $6 VPS

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors