Skip to main content
Glama
README.md
# Health by Dr Brad

A free health roadmap. Enter your measurements and blood test results, and it
shows which screening tests are due, with evidence-based suggestions and their
citations. Use it at https://drstanfield.com/pages/roadmap, no account needed.
Connect it to [Claude](https://drstanfield.com/blogs/guides/connect-claude) or
[ChatGPT](https://drstanfield.com/blogs/guides/connect-chatgpt) with the connector
URL `https://mcp.drstanfield.com/mcp`. Your record is one file,
`health-roadmap.json`, in your own Dropbox or Google Drive or your browser; our
server never keeps it.

Health-metric tracking with personalized suggestions, delivered as a Shopify
storefront theme extension and as a self-hosted page.

**Local-first.** A user's health data lives in their own cloud (Google Drive,
Dropbox, GitHub, WebDAV) or in localStorage, as a single `health-roadmap.json`
file. The website and the local tools never send that file to our server; the hosted
connector at `mcp.drstanfield.com` holds it in server memory for the length of one
request and stores none of it. "Logged in" means a cloud provider is
connected. The Fly backend is thin: chatbot, lab-import extraction, A/B and
product events, email reminders, Klaviyo capture, and the hosted MCP server.
Supabase holds operational rows only, never health values. Email reminders are
the one place a screening name reaches us: that row holds the enrolled email
address, the screening labels and the dates they are due, and nothing else
(`app/lib/reminder-v2.server.ts`).

## The three agent surfaces

The same file, the same write path, three ways in.

1. **Widget**: the React app in the storefront or on the self-hosted page.
   Reads and writes through `RoadmapStore`.
2. **CLI and stdio MCP**: `tools/get-plan.ts`, `tools/edit-record.ts` and
   `tools/mcp-server.ts`, running on the user's own machine against their own
   file. See [docs/guides/getting-started.md](docs/guides/getting-started.md)
   ("An open-source health record you can connect to any AI", the hub guide),
   [docs/guides/command-line.md](docs/guides/command-line.md) and
   [docs/guides/connect-claude-desktop.md](docs/guides/connect-claude-desktop.md).
3. **Hosted MCP**: `https://mcp.drstanfield.com/mcp`, live since 2026-09-02 on
   the Fly app `health-tool-edu`, over Dropbox and Google Drive. Nine tools as
   of 2026-09-07: the hosted-only `import_documents` (US-35) reads lab files
   from the Dropbox app folder (Drive's `drive.file` scope cannot see dropped
   files), and `file_results` (US-36) takes the rows the assistant read from a
   file dropped into the chat, so the file never reaches our server. On
   Dropbox a read also names folder files not yet in the record (US-37).

To answer one hosted call, the server unseals the cloud credential the
assistant holds, opens the user's folder with it, and holds the record in
server memory for the length of that request. It stores none of it. (One
exception, for an invented account: while an OpenAI or Anthropic app review is pending,
the server holds the reviewer account's Dropbox credential itself; US-32
AC38.) Anyone who
does not want that runs surface 2 instead: `tools/mcp-server.ts` is the same
tool layer over the same file with no server of ours in it at all
([docs/guides/getting-started.md](docs/guides/getting-started.md), the hub
guide, and
[docs/guides/connect-claude-desktop.md](docs/guides/connect-claude-desktop.md)).

Every non-browser writer goes through the same `SyncManager`
(`packages/health-core/src/sync-manager.ts`), which merges on conflict and
verifies after writing. The CLI and stdio MCP run it over `file-adapter.ts`,
which takes a lock file and keeps backups. The hosted MCP runs it over the REST
adapters (`dropbox-rest.ts`, `drive-rest.ts`), where the provider's own
conditional write does the same job.
See [packages/health-core/README.md](packages/health-core/README.md).

Rows are never mutated. A correction appends a new row and marks the old one
`entered-in-error`.

## Two builds, one source

| Build | Command | Where |
| --- | --- | --- |
| Shopify storefront | `npm run build:shopify-prod` | both stores, theme extension |
| Self-host | `npm run build:pages` | GitHub Pages, almost no backend, bring your own keys |

Flags: `VITE_LOCAL_FIRST` for all v2 behavior, `VITE_SHOPIFY_SURFACE` to gate
the features that need our server.

"Almost no backend" is the honest version of the Pages build. The record and
the chat never touch our server there: the record is written straight to the
user's own cloud, and chat runs on the user's own key. Two optional calls do
reach us, and only if the user makes them. Signing in with Google posts the
OAuth code to `/api/google-token`, which forwards it to Google and stores
nothing (`widget-src/standalone/google-config.ts`). Enrolling in email
reminders posts the address and the due dates to `/api/reminders-v2`
(`widget-src/standalone/reminders.ts`). Sentry error reporting also runs on
this build (`widget-src/standalone/app.tsx` calls `initSentry`), scrubbed by
`widget-src/src/lib/sentry.ts` before anything leaves the browser: on
`drbradstanfield.github.io` it is enabled, under the environment name
`standalone`, so the page does talk to sentry.io. The scrub is the same one the
storefront build runs, the shared `scrubEventText` over every free-text field of an
event, so a Pages error is reported under the same rules as any other.

The rest of what the page loads is its own. `widget-src/standalone/index.html`
carries one module script and loads no third-party script at load time; Sentry is
bundled into it, and the hero image ships with the build
(`widget-src/standalone/hero-dr-brad.webp`, 26 KB) rather than being fetched
from `cdn.shopify.com`, so loading the page tells Shopify nothing. Connecting
Google Drive loads Google's sign-in script from `accounts.google.com`. Cloud credentials and the
optional Anthropic key live in `localStorage` for that origin
(`hr_anthropic_key`, `health_roadmap_dropbox_tokens`, `health_roadmap_gdrive`
and `health_roadmap_gdrive_tokens`, `health_roadmap_github`, and
`health_roadmap_selfhost`, which holds a WebDAV username and password), where any script
running on the origin can read them. `/roadmap/` and `/roadmap/dev/` are the
same origin, so the staging preview shares that storage with the live page.

## Getting started

```bash
git clone <repo-url>
cd roadmap
npm install
cp .env.example .env   # fill in what you need
npm run test:all
```

Node 22 or newer. Never run `shopify app dev`: the dev preview overrides
production.

## Tests

```bash
npm test          # symlink guard + health-core + server
npm run test:all  # everything, and what CI runs
npx vitest run <path>
```

## Deploy

CI is the deploy path: `.github/workflows/deploy.yml`, triggered from Actions →
Deploy → Run workflow. All credentials live in the GitHub `production`
environment. Manual and emergency steps, plus the two-app Fly split, are in
[docs/deploy-runbook.md](docs/deploy-runbook.md).

## Verifying this yourself

You do not have to take the code's word for it. Open the site with the DevTools
network tab recording and use the widget.

- Saving a metric produces no request to our server. The record file is written
  only by the adapters in `widget-src/src/storage/` (`dropbox.ts`, `drive.ts`,
  `github.ts`, `webdav.ts`, `local-storage-adapter.ts`), so the write goes to
  your own Dropbox, Google Drive, GitHub, WebDAV server, or localStorage. No
  endpoint on our server accepts the record for storage. The hosted connector is
  the one place a record reaches our server at all, and only if you connect one:
  it reads the file in server memory for the length of one request and stores
  none of it.
- Two requests carry health content, and you trigger both. A lab-document
  upload POSTs the document's page content to `/api/lab-import-v2`, which sends
  it to Anthropic's API for extraction, returns the values, and stores none of
  them. Sending a chat message from the storefront widget POSTs the message,
  that conversation's earlier turns, and the health context needed to answer it
  to `/api/chat`. On that surface the server stores one row per question: the
  first 500 characters of the text you typed, nothing more, which may hold
  health details you wrote into it; the articles it matched; its
  classification; the router's raw output and any router error; the name of
  the surface; and a pseudonymous session and conversation id. The
  earlier turns and the reply are not stored. A daily job blanks the question
  text, the router's raw output and the error text once a row is 30 days old;
  the match record stays: the articles, the classification, the ids. A guest
  session row holds a hashed address, not an IP.
- The chat bubble on blog pages and the chatbot embed send less than the
  widget's chat, and keep more. Both read the widget's copy of your saved
  record out of `localStorage` and send it as chat context on every message
  (`loadGuestInputs()` in `widget-src/src/lib/storage.ts`, called from
  `widget-src/src/site-chat.tsx` and `widget-src/src/chatbot-embed.tsx`). That
  context is your saved profile (sex, height, birth year and month, unit
  system); the latest saved value of each measurement, with its date; the
  medications on record, with drug names and doses; and every screening answer
  on record: methods, results, dates and follow-ups, your smoking history and
  pack-years, your PSA value, your answers on discussing prostate and
  endometrial screening, and your answer about abnormal bleeding. Nothing you
  typed without saving is in it. The widget's own chat sends all of that, plus
  up to 24 dated values per measurement and whatever is typed on the page but
  not yet saved. Neither bundle sets `VITE_LOCAL_FIRST`, so the server takes
  them for a stored surface: it writes your question and the reply into
  `chat_messages`, and the reply can quote the values you were sent with. Those
  cached values are still sent as context; Brad chose on 2026-09-10 to keep that, because the answer is
  worse without them. The transcripts now join the same 30-day purge
  (`app/lib/chat-purge-cron.server.ts`): once a row is 30 days old the job
  blanks the message text and the conversation title, which is the first words
  of your question. What stays is the shape of the thread, not the words: row
  ids, the role, the timestamps, the model, the token counts, and the fallback
  flags. A blanked turn reads back as `[removed after 30 days]`. The Discord
  bot's transcripts are blanked on the same clock, decided 2026-09-10: a
  question typed at the bot is as private as one typed into the bubble. The
  YouTube transcripts stay, because those turns are public comments on a video
  under their own retention.
- Two devices editing apart converge without a conversation. If both record the same
  measurement for the same day while apart, the newer entry becomes that day's value;
  the other stays in your history marked entered-in-error. Nothing is deleted. The
  record file is the trust root: anything that can write it can already delete it
  outright, and a file carrying a later erase wins on every device. Recovery is your
  provider's version history.
- Telemetry is a closed allow-list of event names in
  `packages/health-core/src/product-events.ts`. An event is a name plus an
  anonymous visitor UUID, and the server rejects any name off the list.
- Nothing else we call carries a value. `/api/google-token` forwards an OAuth
  code or refresh token to Google and stores nothing. `/api/reminders-v2` sends
  your reminder schedule, which is a label such as "Colonoscopy" and a due
  date. On the storefront you do not have to enrol: connecting a cloud provider
  enrols you, because that connection IS the consent (`autoEnrolReminders` in
  `widget-src/standalone/reminders.ts`, the default-on decision of 2026-08-11).
  Your cloud account's email address, the labels and the dates reach us on that
  connect. The plan-ready email that follows says reminders are coming and
  carries the one-click off switch, as does every reminder after it. On the
  GitHub Pages build nothing enrols you: `autoEnrolReminders` returns
  immediately off the Shopify surface, and only the toggle, which asks you for
  an address, can enrol you. Either way what we hold is that address, the
  schedule and the token that turns reminders off: no health values, no
  account, nothing else. The optional plan email sends the address you type and
  that same schedule.
- Feedback you type into the widget's feedback box is stored and emailed. The
  route takes your email address and up to 2000 characters of free text, adds
  your Shopify customer id when you are logged in, writes the row to
  `feedback_submissions` and sends the same thing to Brad through Resend
  (`app/routes/api.feedback.ts`, `recordFeedbackSubmission` in
  `app/lib/product-events.server.ts`). Nothing scrubs that text and nothing
  deletes the row, so health details you write there stay written.
- Errors go to Sentry, scrubbed in the browser before they leave it
  (`widget-src/src/lib/sentry.ts`): console breadcrumb text is replaced, fetch
  breadcrumb URLs are cut back to their origin, and record-operation errors are
  reduced to a fixed message and a closed set of tags. `sendDefaultPii` is
  never set. Read the text scrub for what it is
  (`packages/health-core/src/sentry-scrub.ts`): it removes email addresses,
  numbers carrying a unit, and numbers written within 20 characters of about
  thirty metric and lab words, and server-side it drops any extra field longer
  than 200 characters. It does not recognise a diagnosis or a name written in
  prose, and it has no phone-number rule (that guard sits in the connector's
  `report_feedback` tool). The control that carries the weight is upstream: the
  code throws errors with fixed messages instead of echoing the text it was
  reading. The lab-extraction parse path was changed on 2026-09-10 to do the
  same, because a JSON or schema error quotes the document back.
- Health files cannot ride into the server image. Every health-data pattern in
  `.gitignore` is also excluded in `.dockerignore`, and a test asserts the two
  stay in step (`tests/dockerignore.test.ts`), so adding an ignore rule without
  the matching exclusion fails the suite.
- The plan footer links to
  [the privacy notice](https://drstanfield.com/pages/connector-privacy) on both
  builds (`widget-src/src/components/ResultsPanel.tsx`), so what we do with
  your data is one click from your plan, not buried in a consent screen.

## Where to read next

- [CLAUDE.md](CLAUDE.md): the working contract for this repo.
- [docs/architecture-v2.md](docs/architecture-v2.md): the visual system
  map, and the best entry point.
- [docs/reference.md](docs/reference.md): file inventory, data model,
  endpoints, gotcha archive.
- [docs/user-stories.md](docs/user-stories.md): every behavior change starts
  as a story here.
- [docs/agent-access.md](docs/agent-access.md) and
  [docs/mcp-architecture.md](docs/mcp-architecture.md): the agent contract and
  the MCP map.
- [docs/guides/](docs/guides/): the user-facing guides, starting with
  [getting-started.md](docs/guides/getting-started.md), "An open-source health
  record you can connect to any AI".
- [docs/privacy-connector-addendum.md](docs/privacy-connector-addendum.md):
  draft privacy language for the AI connector.

TDQS

A4.4/5.0

Scored across 9 tools

Disambiguation4/5

Most tools have clearly distinct roles: read_record/get_plan for retrieval, add_measurement vs add_lab_values split cleanly by core-metric vs other labs, and correct_value/update_profile for corrections. The main overlap risk is import_documents vs file_results, which both ultimately file lab values, though their descriptions distinguish the source (Dropbox folder vs a file the agent itself read).

Naming Consistency4/5

All names are snake_case verb_noun, giving a predictable pattern (add_measurement, add_lab_values, correct_value, update_profile, import_documents, file_results, report_feedback). The only minor inconsistency is mixing 'get_plan' and 'read_record' for retrieval actions.

Tool Count5/5

Nine tools is well-scoped for a personal health-record server: read, plan, two add paths, correction, profile update, two import paths, and feedback. Each tool earns its place with no filler.

Completeness4/5

The surface covers the full lifecycle the domain allows: read, compute plan, add measurements and labs, correct values, update profile, import and file documents, and report feedback. Deletion is intentionally absent by design (rows become 'entered-in-error'), so the missing delete is not a real gap.

Maintenance

ActivityActive
ResponsivenessResponsive