The AI-native health data engine — collect, standardize, and reason over labs, wearables & genomics.
🚀 Mirobody
The AI-native health data engine — collect, standardize, and reason over labs, wearables & genomics.
Mirobody powers Theta Wellness, a live consumer health product used by 5,000+ registered users with 500+ daily active users.
📚 Documentation · 💬 Hosted chat — chat.mirobody.ai · 🔌 API platform — platform.mirobody.ai
Blood tests, wearables, genomics, imaging — all fragmented, all incompatible. Before AI can understand your health, someone has to unify these signals into a single standard AI can actually read. That is what this engine does.
The engine does three things, and the codebase (and Contributing) is organized around exactly these three stages — the same C · S · A the documentation uses:
| Stage | What it means | Where |
|---|---|---|
| ① Collect | Pull signals in: 3 device providers + a SQL source · 7 file formats · Apple Health (receive-only: a signed iOS client POSTs it in) | pulse/ |
| ② Standardize | One standard: resolve any reading to canonical codes (LOINC · SNOMED CT · RxNorm), normalize units, land against FHIR-recognized code systems | indicator/ |
| ③ Answers | Reason: agents read theoriginal documents through a virtual filesystem and answer with charts & citations | agent/ |
⚡ Try it in 60 seconds
Indicator resolution is the engine's front door and needs no key, no config and no network:
pip install mirobody
mirobody resolve "LDL cholesterol" 血红蛋白 ヘモグロビン "空腹血糖(GLU)" 血脂
Real output, and the GIF is a build artifact —
docs/demo/resolve.htmlrendered byscripts/make_demo_gifs.py, so it cannot drift away from the command it claims to show.
from mirobody.engine import resolve, resolve_reading
resolve("血红蛋白").loinc # '718-7' any language, one code
resolve("total cholesterol").loinc # '2093-3' [Mass/volume]
resolve_reading("total cholesterol", "5.0", "mmol/L") # '14647-2' [Moles/volume]
resolve_reading("total cholesterol", "193", "mg/dL") # '2093-3' the unit picks the code
resolve("中性粒细胞百分比").loinc # '26511-6' Neutrophils/Leukocytes
resolve_reading("中性粒细胞", "62 %", None).loinc # '26511-6' a percentage...
resolve_reading("中性粒细胞", "4.2", "10*9/L").loinc # '26499-4' ...and a count are two codes
resolve("血脂").resolved # False a category, not an observation
Pass the value and the unit when you have them. LOINC encodes the unit and
the result type into the identity, so the same name resolves to different codes —
filing a mmol/L result under a mg/dL code is how one series quietly ends up
holding two units. resolve abstains rather than guessing: "" is a gap worth a
second look, "refused" is the answer.
→ Engine reference ·
Indicators
What the standardization layer provides
Standardization here is not a lookup table but a complete terminology-normalization system:
- Concept graph: 440,961 nodes · 22,044,110 cross-vocabulary edges · 595,746 source ids distilled into canonical concepts (LOINC · SNOMED CT · RxNorm bridges).
- 49,253 multilingual aliases (中文 22,578 · 日本語 16,809 · +5:
de·es·fr·ko·ru).
hemoglobin,血红蛋白,血紅素andヘモグロビンall land on LOINC 718-7. - 繁體中文 is two problems, handled as two. Script folding is mechanical
(a shipped 3,336-character zh-Hant → zh-Hans table); vocabulary is not — Taiwan
usage picks different words, and folding
血紅素yields the HbA1c code. Those terms are curated under their Traditional spelling, and a curated row always beats a fold. - Units normalized to ~310 UCUM families, with dimensional analysis, a
molar-mass bridge keyed by LOINC code, and an explicit refusal for
%vs10*9/L. 300 standard pulse indicators. - A second tier exists, and stays opt-in. Everything above is lexical, so it
abstains on terms it does not know — an honest ceiling. Cosine recall
(
indicator/semantic.py) reaches past it but cannot abstain: for a term it has never seen it returns its nearest neighbour with the confidence of a correct answer, and no threshold separates the two. No matrix ships and none is published to download: it is 108,248 LOINC rows × 1024 dims (~221 MB) and it is specific to one (provider, model) pair, soscripts/build_loinc_embeddings.pybuilds yours against the embedding model you configure. A matrix from a different model does not error — it ranks confidently in the wrong space, which is why the build stamps<matrix>.meta.jsonand loading refuses a mismatch. Until you pointMIROBODY_SEMANTIC_INDEXat one,resolve()is unchanged; after, use it to suggest a code a human confirms, never to mint an identity. → Semantic recall — the benchmark, the two axis gates, and whymin_scoreis not a correctness threshold. - We measure the claim instead of asserting it.
test_engine_coverage.pyscores the offline resolver against the panels an ordinary checkup includes, written the way a report prints them, in English, 简体中文, 繁體中文 and 日本語 — plus the wearable vocabulary the platform API teaches. 211/211 today; it scored 32/94 the day it was written. It grades clinical correctness: answering血红蛋白with the HbA1c code is a failure, and血脂is required to resolve to nothing.
pytest mirobody/test_engine_coverage.py -s # offline, about a second
Which LOINC, and what it does and does not cover
The shipped bundle is cut from LOINC 2.82, and the package says so at runtime rather than in a comment that can drift:
>>> import mirobody; mirobody.BUNDLE_VERSION
'loinc-2.82+2026.08.28-af2524b7a285'
The release, the cut date, and a digest over the bundle's own members — so a
build-time consumer of the vocabulary and a runtime pip pin can be asserted
to be the same corpus, which the package version alone never told you.
LOINC's licence requires every copy to carry the
version number; res/fhir_loinc_bundle.NOTICE does, and
scripts/stamp_bundle_version.py --check keeps the stamp honest.
Why 2.82 and not 2.83. The axis table and the 677k-row corpus are coupled
through the folded LONG_COMMON_NAME, and 2.83 renamed 2,842 of them
(Cerebral spinal fluid → Cerebrospinal Fluid and that family). Measured:
upgrading the axis alone loses 3,486 name→code links and gains none, so a
real upgrade means rebuilding the corpus — which spans SNOMED CT, RxNorm, CVX
and DCM, each licensed separately and none redistributable here. The known
cost of staying: 650 codes that 2.83 has marked DISCOURAGED or DEPRECATED are
still answerable, which shows up as 52 of the 6,815 benchmark cases that
resolve. Withholding them was measured too and not taken — LOINC offers a
replacement for only 9 of the 658, so it would mostly turn a dated code into no
code, and a reading with no code cannot be grouped at all.
LOINC covers more of the wearable world than people expect. It is not only
lab panels: BDYWGT.* codes body composition (101685-6 body bone mass,
73964-9 body muscle mass, 101684-9 percentage of body water), HRTRATE.*
distinguishes resting heart rate (40443-4) from a spot reading, and there are
codes for step counts (41950-7), sleep stages (93831-6 deep, 93830-8
light), HRV SDNN (112429-6), VO₂ peak and elevation climbed. Where it stops
is vendor composites — Garmin's Body Battery and stress score have no code,
correctly, because they are one company's formula rather than a measurement.
Coverage of a vocabulary is not the same as recall on it, and the gap is
ours, not LOINC's: Body bone mass resolves to 101685-6 here, while the
Chinese 骨量 resolves to a dental volume code, because no alias routes it.
That is what res/resolver_overrides.tsv
is for — a row written by a person beats a surface match in the index, every
time.
→ loinc.org · licence · release notes · the download is free but requires an account, which is why the derived bundle ships and the source release does not.
→ Standardization · Architecture · Data flow
📊 Benchmarks — open and independently reproducible
Our health-AI benchmarks are the most-downloaded in their category on Hugging Face (4,000+ each):
| Benchmark | What it measures | Downloads |
|---|---|---|
| ESL-Bench | Event-driven longitudinal health agents — 100 synthetic users, 10,000 queries, programmatic ground truth (arXiv:2604.02834) | 4,800+ |
| MedHall-Bench | Medical hallucination | 4,500+ |
| MedHarm-Bench | Harmful medical advice | 4,300+ |
Reproduce any of them with one command via mirobody-eval, which also seeds a deployment with synthetic (PHI-free) trajectories.
🚀 Run the whole thing
git clone https://github.com/thetahealth/mirobody.git && cd mirobody
git lfs pull # the engine's data bundles; `resolve` needs them
./deploy.sh # Postgres + pgvector, Redis, server, worker
Then open http://localhost:18060. The server prints the accounts it accepts
at startup — the shipped one is caregiver@mirobody.ai, code 111111, named for
the role it plays: you sign in as the caregiver and the record you read belongs to
someone else.
No mail provider? You do not need one. The sign-in page opens on password, with email-code as a third tab:
curl -X POST localhost:18060/password/register -H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"at-least-8-chars"}'
One key runs everything. Set an OpenRouter key
in OPENROUTER_API_KEY — for the Docker stack that means the .env file next
to compose.yaml, then docker compose restart (that alone suffices: the app
re-reads /app/.env; a shell export does not reach the containers) — and
conversation, vision file parsing and semantic
indicator search are all live — chat via Claude/GPT/DeepSeek, embeddings via
the open-weights Qwen3-Embedding-8B (self-hostable: serve the same model
behind any OpenAI-compatible /v1/embeddings and point
OPENROUTER_BASE_URL at it).
Indicator search embeds your own indicator names, not the LOINC corpus:
the worker's IndicatorSyncTask writes th_series_dim.embedding_qwen3_8b on
each ingest, and the query is matched against that. It needs mirobody worker
running, which ./deploy.sh starts. That is a different index from the
downloadable-corpus matrix the second tier wants, and it is the one that comes
for free.
If openrouter.ai is unreachable from your network (the case in mainland
China), a DashScope key in
DASHSCOPE_API_KEY is a drop-in replacement — chat via Qwen (DeepSeek/Kimi
one uncomment away), vision via qwen3-vl, embeddings via text-embedding-v4.
No further configuration either way; direct provider keys (Google, OpenAI)
remain supported — see config.yaml.
→ Docker deployment ·
Configuration ·
Local Python setup
👨👩👧 The whole engine, in four minutes
SEED_DEMO_DATA defaults to on, so the ① → ② → ③ chain is walkable the moment
./deploy.sh finishes — signing in and browsing the seeded record need no
key; the upload extraction in part 2 and the questions after it ride the one
key configured above. Four parts, each recorded against the running stack.
1 · Arrive. You sign in owning a thin record — a few weeks of
self-tracked vitals and one unremarkable checkup, seeded as your own — and find
one synthetic person sharing a thick one with you: Demo (synthetic),
244 indicators and 14,273 readings across two years, five documents the agent
can read_file. Same question, two records: your HbA1c answers with one
boring-normal value from data you own; hers answers with a two-year story
from data you can only view. Isolation you can see, not just read about.
The switch in that diagram is a column, not a promise:
care_circle_members.health_access, NOT NULL DEFAULT 0, on your own row.
Being invited into a circle shares nothing — the member decides, and no other
person's action can raise it. The check that reads it raises rather than
returning a falsy value, so a route that forgets to look answers 403 instead of
handing over a record.
examples/06_care_circle_rules.py prints
the whole decision table offline.
2 · ③ Answers, on someone else's record. Ask about her HbA1c and the agent finds the data itself, cross-references the lab draws against the sensor-derived series, and charts both — then tells you the improvement did not hold.
lab-drawn HbA1c 7.2 % (2024-04) → 6.5 % (2024-10) → 6.6 % (2025-04)
only 3 lab draws in two years — the sensor eA1C has 104
3 · ① Collect + ② Standardize, on your own.
mirobody/demo/lab_report_2025-10-15.pdf is a panel deliberately held out of the
seed, so uploading it is not a no-op. Drop it on the Data page and twelve
analytes come out with their values and units in seconds, each linking back to
the page it was read from.
4 · ③ Answers, on what you just uploaded. Ask again, now about your own record. The agent reads the report through the virtual filesystem, flags all twelve results against their printed reference ranges — and says plainly that one date is not a trend.
That contrast is the demo's point: two years of history buys a trend, one panel buys an interpretation. Both answers cite what they read.
Every value is synthetic — generated for ESL-Bench by
mirobody-eval and vendored, so the
seed needs no network and no key. Set SEED_DEMO_DATA=false for a deployment that
will hold real data. What the extraction pass does not yet do with those twelve
readings is written down in docs/roadmap.md rather than glossed
over here.
🧩 Extend it
Five directory keys point at plugin roots; drop a file in and restart. Tools become both agent tools and MCP tools with no extra wiring.
| You want | Drop it in | Docs |
|---|---|---|
| A new tool | mirobody/agent/tools/ |
Adding tools |
| An Agent Skill (SKILL.md) | mirobody/agent/skills/ |
Skills |
| A whole agent | mirobody/agent/ |
Agents |
| A device provider | mirobody/pulse/providers/ |
Provider integration |
| Someone else's MCP server | Settings → MCP | MCP integration |
Every tool the agent has is also served over MCP at /mcp, gated per user.
→ Built-in tools ·
MCP servers
🔌 Use it from your own code
| Surface | For | Docs |
|---|---|---|
pip install mirobody |
Offline resolution and units — 2 packages, no key, no network | Engine |
pip install 'mirobody[parse]' |
The above, plus reading documents with one model key | Engine |
mirobody.bundle |
Build-time: the LOINC axis table and alias sources, for generating a seed or corpus | mirobody/bundle.py |
| HTTP API | Your app talking to a deployment | API overview · Data |
| MCP | Claude, Cursor, or any MCP client reading a user's record | MCP servers |
| Backbone mode | Your own agent, our data layer | Backbone |
Not sure which? → Choose your API
🏗️ Repository layout
mirobody/
├── engine.py the front door — resolve() and parse_file()
├── units/ UCUM units, unit_family, conversions ┐ the library:
├── lexical.py surface folding + the CJK-aware tokenizer │ numpy only,
├── bundle.py build-time: the axis table and alias sources │
├── res/ the shipped LOINC bundles ┘ 2 packages
├── pulse/ ① Collect — providers, file parsing, aggregation
├── indicator/ ② Standardize — resolver internals, concept graph, bundle build
├── agent/ ③ Answers — DeepAgent, tools, skills, chat
├── mcp/ the MCP server
├── user/ identity and the care circle — who may read whose record
├── schema/ the DDL, replayed at boot in dev
└── demo/ care-circle demo data
Two forms, and they want opposite things. The PyPI package is a LIBRARY and
is meant to be small enough that nobody has to think about it: pip install mirobody is 2 packages, 52 MB — the top four entries above, on numpy.
[parse] adds document reading; [app] is everything, and the only thing that
installs it is requirements.txt, because the Docker application is
git clone && ./deploy.sh and never a pip install.
Machine-enforced, not documented: three import-linter contracts hold the
lines — the library layer imports nothing but numpy, and the engine never
imports the agent layer — and lint-imports fails the build. A fourth gate, scripts/check_wheel_data.py, keeps the bundle-build passes and the
v2 semantic pipeline — 19,000 lines nobody who installs the package can run —
out of the artifact.
→ Architecture · CONTRIBUTING.md
📚 Documentation
For full documentation, see docs.mirobody.ai (English and Simplified Chinese).
| Quickstart · Installation · Self-host | Getting it running |
| Indicators · Providers · File processing | How the three stages work |
| API reference · Streaming · Function calling | Building against it |
| Contributing · Setup | Working on it |
In-repo, for contributors
Each package carries a README.md saying what it is; long-form guides live in
docs/. All of it is English, whichever README you arrived from.
| Where | |
|---|---|
| Runnable examples | examples/ |
| ① Collect | pulse/ · providers · aggregation · Apple Health |
| ① guides | connect a wearable · write a provider · file processing · Apple Health API |
| ② Standardize | indicator/ · indicators & units |
| ③ Answers | agent/ · tools · ChatGPT widgets |
| Plumbing | configuration · database schema · the web client |
| Working on it | CONTRIBUTING.md · testing · aggregator script · roadmap · CHANGELOG · SECURITY |
🤝 Contributing
The highest-leverage contribution is a term the resolver gets wrong. Run
mirobody resolve "<term>", and if the answer is wrong or empty add a row to
resolver_overrides.tsv plus a case to
test_engine_coverage.py — the coverage score
is the review.
pip install -e '.[test]' && pytest -q && lint-imports
→ Contributing guide · CONTRIBUTING.md
⭐ Star History
📚 Docs · 💬 Chat · 🔌 Platform · 🧪 Eval
Apache 2.0 · © 2026 Theta Health