# Reading and writing a health record with an AI agent Your health record is one file: `health-roadmap.json`. It lives in **your** storage, your Dropbox, your Google Drive, your GitHub repo, or your browser. We do not host it and we keep no copy of it. Any agent with filesystem access can open the file, read it, and write it back, with no API to call and no key to get. The hosted connector at `mcp.drstanfield.com` is the one path that runs through us: to answer one call it holds the record in server memory for the length of that request and stores none of it. This page is the contract for doing that safely. Tools that enforce this contract for you: `tools/get-plan.ts`, `tools/edit-record.ts`, `tools/mcp-server.ts`; the hosted server: [mcp-architecture.md](mcp-architecture.md). - Format: JSON Schema (draft 2020-12), [`docs/health-roadmap-file.schema.json`](health-roadmap-file.schema.json), published at `https://raw.githubusercontent.com/DrBradStanfield/roadmap/main/docs/health-roadmap-file.schema.json` - Current `schemaVersion`: **2**, the highest the app reads. A file stays at **1** until an erase lists what it removed (`erasedRows`, rule 6); only then is it stamped 2 (US-09 AC23). - TypeScript source of truth: `packages/health-core/src/roadmap-file.ts` - Compute the plan from the file: `npx tsx tools/get-plan.ts ` (in this repo), see `--help`. No file yet? `get-plan.ts --from-values ` builds one in memory from sex, height, age and values, and writes nothing. - Something wrong or missing? The MCP server's `report_feedback` tool reports it. On the hosted server it proposes the issue first and, once the user confirms, files it itself; the issue is public; a server with no GitHub token prepares a link the user submits instead. Tell the user before you call it, and put no health value in it, one is refused. ## Where the file is The file name is always `health-roadmap.json` (`ROADMAP_FILE_NAME` in `packages/health-core/src/adapter.ts`). Each backend scopes the app to one folder or repo, so this is the whole footprint. | Backend | Location | Local path with desktop sync | | --- | --- | --- | | Dropbox | App-folder app, so everything sits under `Apps/Health Plan by Dr Brad/` (a connection made before the app was renamed keeps `Apps/Health Roadmap by Dr Brad/`; Dropbox never renames an existing user's folder) | `~/Dropbox/Apps/Health Plan by Dr Brad/health-roadmap.json` (older connections: `…/Apps/Health Roadmap by Dr Brad/…`) | | Google Drive | Folder `Health Plan by Dr Brad` in My Drive (an older connection may still use `Health Roadmap by Dr Brad`; the app finds either, `DRIVE_LEGACY_FOLDER_NAME` in `drive-rest.ts`) | Wherever Drive for desktop mounts My Drive, on macOS usually `~/Library/CloudStorage/GoogleDrive-/My Drive/`, elsewhere often `~/Google Drive/My Drive/`. Set the folder "Available offline", or Drive streams it rather than storing it | | GitHub | Default branch of the one repo the token is scoped to, at the repo root | your clone: `health-roadmap.json` | | WebDAV / self-host | Relative to the configured base URL | wherever you mounted it | | Browser only | `localStorage` key `health_roadmap_file_v2` | no file on disk; the user connects a cloud provider in the tool, which merges the browser copy into the cloud file | Uploaded reports sit beside the JSON in human folders: `Lab results/`, `Scans/`, `Clinic letters/`, `Other documents/`, named `YYYY-MM-DD Title.ext`. Each `documents[].fileRef` is that path, but treat it as an opaque relative path, not a pattern: older rows use other shapes (`documents/doc_1.pdf`), and a text-only document has `fileRef: ""`. ## How to read 1. Parse the JSON. 2. Check `schemaVersion`. If it is greater than 2, stop, a newer app wrote this file and you may not understand every field. Read if you like; do not write. A file at 2 carries `erasedRows` (rule 6): if your tool predates it, do not write that file either. 3. Current values live in the arrays, not in a separate "current" object. - `measurements` (core metrics) and `labValues` (everything else) are **append-only history**. A row's slot is `(metric, calendar day of recordedAt)`, `metricType` for measurements, `metricName` for lab values. - Within a slot, at most one row has `status: "active"`. That is the slot's value. If several are active, the newest `createdAt` wins (larger `id` breaks a tie). A metric's current value is its active row with the latest `recordedAt`, by the same tie-break. - Through `read_record` rows carry no `createdAt` (US-32 AC41). Each `measurements` and `labValues` row carries `current` instead: `true` on the one row the plan counts for that metric, `false` on every other. Use it; do not work the tie-break out yourself. - Everything with `status: "entered-in-error"` is superseded history. Keep it, show it if asked, never treat it as current. 4. `medications`, `supplements`, `reminderPreferences` hold current state, one row per key. `medicationHistory` and `supplementHistory` are the append-only logs behind them. `profile` and `screenings` are singletons. Each may carry `fieldStamps`, the merge's clock for each field; it is not part of what the record says, so skip it when reading. `read_record` leaves it out, with the rest of the sync bookkeeping (US-32 AC41): `meta`, every row's `createdAt` and `lamport`, `updatedAt` except on the two history logs (there it is the date of the change), `externalId`, `profile.reportEmailCaptured`, the reminder opt-in and settings, recommendation snapshots, deleted documents and each document's storage fields. Row ids stay. 5. Skip any document with `deleted: true`. The row stays in the file forever; the user deleted it. ## How to write: the hard rules The file follows FHIR `Observation` discipline. Break these and you silently corrupt someone's medical history, or lose their data at the next device sync. 1. **Never mutate a row. Never delete a row.** Not in `measurements`, `labValues`, `documents`, or either history log. 2. **A correction is an append.** Add a new row with a fresh id and `correctsId` set to the old row's id, then set the old row's `status` to `"entered-in-error"`. That status flip is the only edit ever permitted to an existing row, and it is one-way, `entered-in-error` never goes back to `active`. The correction row keeps the original row's `recordedAt`, a correction changes the value, never the date. 3. **Check the slot before you append.** Look for an active row in the same `(metric, calendar day)` slot. If one exists you have exactly two options: flip it to `"entered-in-error"` (setting `correctsId` on your new row if you are correcting it), or write nothing. **Never leave two active rows in one slot**: nothing reconciles them for you (see Caveats). 4. **If the value is already there, write nothing.** Before appending, check whether the active row for that slot already carries this value. Re-importing the same lab report is the most dangerous thing an agent can do here: fresh ids and a fresh `createdAt` make your rows win every slot, permanently demoting the user's originals, including the manual corrections they made by hand. Import is not idempotent unless you make it so. 5. **Fresh UUID per row, always.** Never reuse an id, never renumber. Ids are the merge identity across devices. Treat ids you read as opaque strings: an id containing `#dup-` is a quarantined duplicate the merge created after an id was reused with different content. Never write a `#dup-` id yourself. 6. **Leave `meta` alone, except `meta.updatedAt`, which you MUST set to now (ISO 8601), using the same clock as your rows' `createdAt`/`updatedAt`.** It is the anchor of the clamp below: leave it stale and every row you just wrote is rewound to it on the next load, and can lose its slot or its record to an older entry. Never touch `meta.lamport`, `meta.eraseEpoch`, `meta.erasedAt`, or `meta.lastDeviceId`, they are the sync engine's clocks. Garbage there is clamped, reset to 0 or dropped on read, and a reset `eraseEpoch` makes the next merge discard your copy's rows that an erase listed, and, past an erase that listed nothing, every row but the measurements, lab values and documents created at or after the other copy's `erasedAt` (`eraseEpoch` is what makes "delete all my data" stick). **Leave the top-level `erasedRows` alone too** (US-09 AC23): it holds the root ids of the rows each erase removed (an id without its `#dup-…` or `#si` suffix) and the epoch its claim starts from. Every reader and every merge drops a row whose root id is listed, so a listed row never comes back, whoever writes it. A malformed list is dropped on read, which only makes the next merge stricter. Never reuse a listed id. `read_record` leaves the field out. 7. **Touch only the rows you added.** Do not reformat, re-sort, or normalise other rows. Updating a current-state row you legitimately own (a medication, supplement, or reminder preference) means setting that row's `updatedAt` to now and leaving its `lamport` exactly as you found it, or absent. 8. **Units.** `measurements[].value` is the SI canonical unit, kg, cm, mmHg, mmol/mol for HbA1c, mmol/L for lipids, g/L for ApoB, µmol/L for creatinine, ng/mL for PSA, nmol/L for Lp(a). Convert before you write. Lp(a) converts at the factor the app uses, 2.5 nmol/L per mg/dL (0.25 per mg/L; the EAS 2022 interim factor), so your rows match the app's. **Lab values are SI at rest too, since 2026-09-18 (US-21 phase 3).** Send `labValues[].value` and `unit` as the lab PRINTED them and let the record convert: a test in `lab-catalog.ts` is stored in that catalogue's canonical unit — reference bounds by the same factor — and a unit spelling the catalogue does not know for that test is REFUSED, naming the spellings it takes, rather than guessed from the number. A refusal can also be for the number under a spelling the test takes: a `cells/µL` count that is not a whole number, or not on the scale of its own printed range. A test the catalogue does not know has no SI definition, so it is stored exactly as reported. If you write the file yourself rather than through a tool, convert with the table in `health_roadmap_algorithm.md` §2 "Lab catalogue conversions"; a row already written in another unit is corrected on the next load (a `#si` row, `correctsId` set, the old row `entered-in-error`), so leave old rows alone. You do not have to remember the list: `read_record` publishes a `units` map keyed by `metricType`, and `get_plan` one keyed by its own `inputs` field names. `get_plan.inputs` is SI canonical; `currentValues` is the same values converted to the record's `unitSystem`, so read `inputs` when you are computing and `currentValues` when you are quoting. One exception to "converted": LDL and ApoB, the lipids with a target line, print on the side of their line the plan grades them (US-07 AC5). A value within one display step of the line can differ from a plain conversion: one step up to the line when the plan grades it at or above, or one more decimal place when it grades it below. `get_plan`'s tool description in `mcp-tools.ts` should say this too; it stays frozen while OpenAI reviews the app, so add it once that review is done. 9. **Timestamps are ISO 8601, and never in the future.** On every load the app clamps a row's write-clocks, `createdAt` on measurements and lab values, `updatedAt`/`lamport` on current-state rows and on each field stamp, to the file's own last write (the later of `meta.createdAt` and `meta.updatedAt`), so a future stamp is rewound, not rejected. `recordedAt` is never clamped: it is the clinical date, and a future one still puts a value on a day that has not happened, that mistake is yours to avoid. `recordedAt` may be `YYYY-MM-DD` or a full timestamp, only the day is used for slotting. 10. **Use catalogue keys for `metricName`.** If the test is in `packages/health-core/src/lab-catalog.ts` (ferritin, tsh, alt, …), use that `key`. Matching happens on those keys. Catalogue spellings, aliases and underscore/space variants all converge on the catalogue key at merge time, so "Vitamin D", `vitamin_d` and "vitamin d" land in one slot; only a name that is genuinely uncatalogued beyond that fold stays a separate row. Core metrics (LDL, HDL, HbA1c, creatinine and the rest) belong in `measurements` with a `metricType` from `METRIC_TYPES`, not here. 11. **Deleting a document** means setting `deleted: true` on its row. A removed row comes straight back from any other device. 12. **`reminderOptIn.token` is a capability secret.** Leave it where it is; never copy it out of the file. 13. **Never write `fieldStamps`** on `profile` or `screenings` (US-10 AC6). They say when each field was last written, so the merge can take each field's newest write instead of the newest whole object. To change a field by hand, change it, set that object's `updatedAt` to now, and leave its `lamport` as you found it, as rule 7 says for a current-state row. The object's stamp then usually runs ahead of its field stamps, and the merge reads your edit as a write to every field of it, as it did before field stamps: a change another device made to a different field since you read the file can be undone. Not always. When a field stamp is as late as your `updatedAt` (a device whose clock runs ahead of yours wrote it, or the clock clamp on read brought the two level), the merge reads the field stamps instead, your field keeps its old stamp, and your edit can lose to another copy's value. `update_profile` stamps only the fields it changes, so for the fields it covers (sex, birth year, birth month, height) use it rather than a hand edit. To clear a field by hand, set it to `null`; never remove the key. A key that is absent, with no field stamp, reads as never written, so the next merge puts back another copy's value. `update_profile` cannot clear a field. Validate your result against the schema before you write it back. ## Caveats: read these before you automate anything - **A write you make is now visible almost at once in an open browser tab** (US-34, 2026-09-02). The widget no longer waits for a timer: on Dropbox it holds a `list_folder/longpoll` open on the app folder and re-reads within about a second of your write; on Google Drive it walks the changes feed every 3 seconds. That is a reason to write the whole record in one go rather than in pieces, a half-finished sequence is on the user's screen while you are still writing it. - **There is no lock and no version guard on a filesystem write.** The web app writes with an optimistic-concurrency check; you do not have one. Read, modify, and write back promptly. Do not hold the file open across a long task, and do not write while the user has the app open in a browser tab. - **The merge is not a cleanup pass, and it does not run on load.** Opening the app reads and normalises the file; nothing reconciles slots until the app writes, which needs a second device or a user edit. Two active rows in one slot therefore stay visible. When a merge does run it never deletes, a slot loser becomes `entered-in-error`, and an id reused with different content becomes two rows, the extra one quarantined under `#dup-…`, but that is a safety net for concurrent devices, not a licence to write sloppily. Rule 3 is yours to enforce. - **Malformed rows may be dropped, ignored, or have their clocks rewritten.** The app normalises an untrusted file on every read, a non-array where an array belongs becomes an empty array, out-of-range counters and future write-stamps are clamped (see the timestamps rule), and a row missing fields the UI needs simply will not render. Beyond those clamps it does not repair rows, and it does not warn. A measurement or lab row whose `recordedAt` does not start with a real calendar day (`YYYY-MM-DD`) is removed at the next sync. - **Unknown fields survive at the top level and inside rows**: migrate and merge both spread what they do not recognise, so an older app will not strip something a newer one added. **`meta` is the exception:** `mergeFiles` rebuilds it from its five known fields, so anything else you put there is dropped at the next sync. Do not use this file to store your own state. - **A file with a higher `schemaVersion` than the app understands is refused, not downgraded** (`SchemaTooNewError`). Never bump `schemaVersion` yourself. - **Loading the file can add lab rows you did not write, and it does not bump `schemaVersion`** (US-21 phase 3, 2026-09-18). A lab row stored in a unit that is not the catalogue's canonical one is CORRECTED on load: the app appends the converted row under the id `#si` with `correctsId` set and flips the printed row to `entered-in-error`. The id is deterministic, so every device computes the same one and the copies merge into a single row; a second load changes nothing. Read the active row and leave the superseded one where it is. If you convert such a row yourself you will make a second, competing correction. - **A thin plan is not a clean bill of health.** On a record holding no measurement at all, `get_plan` still returns `protein-target`, `exercise` and `sleep` — they rest on no value whatever — plus the screening, skin and supplement cards, which follow from sex and age alone, and `fiber`, which shows whenever lipids are NOT elevated, absent lipids included: read it as "no lipid card", never as "lipids fine". Every other suggestion is conditional on a measurement the record holds, and `missingInputs` names the ones whose absence removed content. - **The plan is built for adults** (US-07 AC6, 2026-10-14). When the birth year (and month, if given) shows a known age under 18, `get_plan` returns one card, `adults-only` ("This plan is built for adults"), and no other suggestion; its `profile.bmiCategory`, `profile.eGFR`, `profile.idealBodyWeightKg` and `profile.proteinTargetG` are `null`, and `due` is empty. The measured values stay. Do not grade such a person's values against adult targets or suggest medicines; point them to their doctor (the result's `instruction` says so). With no birth year, any medicine card gives way to one `add-birth-year` card ("Add your birth year") where the first stood. The other cards stay, but those that named a medicine say none: `apob-very-high` and `ldl-very-high` end "Discuss treatment…with your doctor." in place of their statin sentence, `bp-stage2` says "Discuss treatment with your doctor." in place of "Medication is typically recommended at this level.", `bp-stage1` and `bp-stage2` leave out their GLP-1 sentence, and the `lpa-elevated` checklist leaves out its "not started" medicine lines. Each card's `reason` is unchanged. When the plan holds the `adults-only` card, a `null` in those four profile fields means "withheld". In any other plan a `null` means the inputs are missing (no creatinine, no weight): read `missingInputs` for which. ## The hosted path (US-32 Phase 1) Everything above describes an agent holding the FILE. There is a second way in: a hosted MCP server at `https://mcp.drstanfield.com/mcp`, which a web ChatGPT or Claude user connects once and then asks in words. It offers nine tools, `read_record`, `get_plan`, `add_measurement`, `add_lab_values`, `correct_value`, `update_profile`, `report_feedback`, `import_documents`, `file_results`, over the user's own Dropbox or Google Drive folder, and it enforces the rules above in code rather than asking you to keep them. Two ways a lab file comes in (US-35, US-36). `file_results` is the chat route: you drop a file into ChatGPT or Claude, on any device and on either cloud, and your assistant reads it itself; the file never reaches our server. Only the values it read do, in memory for one request, and they are written to your own folder, never kept by us. The server checks every row it sends: the printed name must agree with the metric it claims ("Lipoprotein(a)" cannot be filed as ApoB), the unit must be one the metric is measured in, the value must sit inside the app's own range, and a value that looks like a unit swap comes back as a question. `import_documents` is the folder route: it reads lab PDFs or images (or a ZIP of them; 5 MB per file, 20 MB per ZIP, 30 files a day; no HEIC) from the root of the Dropbox app folder and proposes values for you to accept, in the record's own unit system. A file inside a ZIP is named `/` (`labs.zip/2026/results.pdf`), as the website's upload names it, in `files`, in a `fileDates` answer and on the filed document; `fileNames` names the ZIP itself. It writes nothing to the record until the user confirms; the candidates wait in a pending file in the user's own folder (`imports/pending-.json`), and the receipt naming it expires after an hour. Those files are sent to Anthropic's API for extraction, under our key, at the extract step and before the user confirms anything. We keep none of them. Anthropic's [commercial terms](https://www.anthropic.com/legal/commercial-terms) say "Anthropic may not train models on Customer Content from Services", and their privacy centre says API inputs and outputs are automatically deleted within 30 days of receipt or generation, kept longer only to enforce their usage policy or comply with the law ([how long do you store my organization's data](https://privacy.claude.com/en/articles/7996866-how-long-do-you-store-my-organization-s-data)). Zero data retention is a separate arrangement for eligible customers; we do not have one. Nothing else here reaches a model we run. Google Drive's folder cannot be read that way (`drive.file` scope), so on Drive the chat route and the website upload are the ways in. Drop files in the Dropbox folder and the next time you ask your assistant anything about your record it offers them ("2 files in your folder are not in your record"); nothing runs while you are away, and nothing updates itself. A file it cannot read comes back with a `hint` in plain words (the type, the size limit, the day's quota, a missing date , answered with `fileDates: [{ file, date }]`). A report it read only in part carries `droppedValues` (how many values it left out) and a `hint` to relay, since once filed it dedups as already imported (US-12 AC8, 2026-10-13). `next` says what to do with what it found. Three tools are permanent — `correct_value`, `update_profile`, `report_feedback` — and on the hosted server they take two calls: a proposal with a `confirm` receipt, then the same call with it after your yes, carrying your words as `approval`, quoted. An import's `commit` carries `approval` the same way. The server holds those words for one request and never stores or logs them (the mechanism and its limits: docs/mcp-architecture.md §3). Every tool declares an `outputSchema` and answers with `structuredContent` beside the text: the same answer typed, so a row id is read, not parsed out of a sentence. A refusal carries none, it is an error result. A command-line assistant can connect to the same server: `claude mcp add --transport http drstanfield https://mcp.drstanfield.com/mcp`, or `gemini mcp add -s user --transport http drstanfield https://mcp.drstanfield.com/mcp`. Either opens a browser once for the consent screen and your cloud provider, then holds the token itself. Both backends are available: the consent screen offers Dropbox and Google Drive, and the choice is sealed into the token. Drive has no conditional write, so both the app and the server re-check the file version immediately before each save and verify every row after it, retrying on a conflict; concurrent writers there are durable-by-retry, not serialised. A user revokes a Drive connection at `myaccount.google.com/connections`, a Dropbox one at `dropbox.com/account/connected_apps`. Six things differ from the local path, and they are differences you will hit: - **`correct_value` requires `expectedValue`**: the number you believe the row holds right now. It is optional locally. A mismatch refuses the call and writes nothing, so read the record immediately before correcting. - **`update_profile` requires `expected` for every field it CHANGES**: the value you believe the record holds now. Narrowed 2026-09-16: a field the record does not hold yet is an ADD and needs no claim — there is no earlier value to protect, and demanding one only forced a `read_record` before a user could say how tall they are. Optional locally, for the same reason. A profile field is not append-only: there is no superseded copy to read back, so the claim is what stands between a stale read and a wrong plan. A profile change costs the same as a correction. - **A row older than 90 days cannot be corrected there.** Corrections fix recent mistakes; older history is out of reach and the user changes it in the app. - **Writes are budgeted, and a correction or a profile change costs five adds.** Reads are never budgeted. When the budget runs out the refusal says so and reading still works. `add_lab_values` also carries its own per-call cap of 50 rows, regardless of budget. - **A record whose `schemaVersion` is newer than the server understands is out of reach entirely: reads refuse too**, because the record is migrated before any tool sees it. - **Provider calls time out and surface as "did not answer".** A cloud provider that accepts the connection and then goes quiet fails the call rather than hanging it. Reading and saving a record gets 30 s; a larger upload gets proportionally longer, so a slow uplink is not cut off mid-send. Browsers without the timeout API run unbounded. Nothing was written; ask again. Trust model, revocation, and the residual risks we accepted knowingly: [mcp-architecture.md](mcp-architecture.md). The short version: your record still lives only in your cloud storage; to answer one call our server unseals the cloud credential your assistant holds, opens your folder with it, and holds the record in server memory for the length of that request, storing none of it; and you cancel the whole thing at your provider's connected-apps settings, which also disconnects the website from the folder, because both providers scope the folder to the app, not to the surface. **The version with no server of ours in it.** Everything above the "hosted path" heading is that version: `tools/mcp-server.ts` over stdio, or the CLI, running on the user's own machine against the user's own file. Same tool layer, same rules, no credential of ours to unseal and no request to us at all. Anyone who does not want a server reading their record in memory should run that instead ([guides/connect-claude-desktop.md](guides/connect-claude-desktop.md)). The one thing it cannot do is `import_documents`, which needs a model and a network it does not have. ## No telemetry Reading or writing this file DIRECTLY contacts none of our servers, no sync endpoint, no analytics hook on the file, by design. We cannot see that you did this, and we do not want to. The hosted path above is the one exception, and it is one by construction: it is a request to our server, so we see that a call happened, and we count it. What the counts hold: per call, which tool ran, which assistant asked, and whether it worked; per connection, which assistant and which provider; per import, the route, the phase and a file-count bucket. Never the values, never a metric name, never anything that says which person it was. With very few users, a timestamped row is still a thin activity log of somebody; it just cannot say what they measured.