# Mirobody documentation (full text)
Source: https://docs.mirobody.ai
---
# Introduction to Mirobody
https://docs.mirobody.ai/en/api-reference
What Mirobody is, the three stages it runs, and how to choose between Mirobody Cloud and self-hosting.
import NameOrigin from '/snippets/name-origin.mdx';
import RunModes from '/snippets/run-modes.mdx';
## What is Mirobody?
**Mirobody** is a health data engine. It takes readings from any source — lab reports, wearables, genetic tests, imaging — resolves each one to a canonical code, and stores it as a comparable record an AI can reason over.
The engine is open source, Apache 2.0 licensed. **Mirobody Cloud is its hosted form:** we operate the storage, the models and the keys, and expose the whole thing behind an OpenAI-compatible `/v1` API, so an existing OpenAI SDK reaches it by changing a base URL and a key. Standardization of structured readings is the same on both. If you would rather run it on your own infrastructure, that is the [Open Source](/en/self-host) tab.
The fastest path to a working call is the [Quickstart](/en/api-reference/quickstart): create a key, write one record, ask one question.
## The three stages: Collect, Standardize, Answers
The engine does three things — **① Collect → ② Standardize → ③ Answers** — and Cloud is the same three, with the operational half handled for you. One difference matters: on Cloud, **collection happens on your side.** You obtain the data (device OAuth, in-app capture, a user uploading a report) and hand it to us; we host, standardize and serve it.
Structured readings go to [`POST /v1/data`](/en/api-reference/data). Lab PDFs, photos, and spreadsheets go to [`POST /v1/files`](/en/api-reference/files), which stores the original, extracts its text, and standardizes any readings it finds. [`POST /v1/standardize`](/en/api-reference/extract) runs that same standardization synchronously — for a dry-run preview, or on narrative text.
Names resolve to **LOINC** deterministically (no LLM code-guessing), values normalize to **UCUM** units, every reading gets a **FHIR** mirror. `"血糖(空腹)"`, `"FBG"` and `"Glucose, fasting"` become one series. See [Standardization](/en/api-reference/standardization).
Ask the [Answers API](/en/api-reference/chat) for a grounded, evidence-cited answer — or build a full agent on the [Agent API](/en/api-reference/responses): your own tools, stored conversations, and the openai-agents SDK working out of the box. Answering is the most direct use of standardized data, and the same records are what let you mine insights, generate a report, or raise an alert.
## Choose how you run Mirobody
## Two API surfaces, one engine
`POST /v1/responses` (OpenAI Responses-compatible). Your function tools, `previous_response_id` / `session_id` state, `response.*` streaming. The openai-agents SDK needs only a new base URL.
`POST /v1/chat/completions`. A closed, grounded completion — one question, one evidence-backed answer. Drop-in for any OpenAI SDK, and ideal as [a tool inside your own agent](/en/api-reference/use-as-a-tool).
Not sure? [Choose your API](/en/api-reference/choose-your-api).
## Four shapes of data, four endpoints
These are the same three sources the console's **Data** page offers, plus the journal case.
| Source | Endpoint |
| --- | --- |
| **Files / photos** — lab reports, checkup PDFs, phone photos | [`POST /v1/files`](/en/api-reference/files) |
| **Structured records** — device and wearable data above all, plus manual entries | [`POST /v1/data`](/en/api-reference/data) |
| **Narrative text** — a note or a report with readings inside it | [`POST /v1/standardize`](/en/api-reference/extract) |
| A purely subjective journal entry | [`POST /v1/responses`](/en/api-reference/responses) with `store: true` |
Which endpoint fits which case, with worked examples, is in the [Quickstart](/en/api-reference/quickstart).
## Why Mirobody
- **Grounded, not plausible** — answers can include `health_records`, `citations`, and the server tool steps used to gather evidence.
- **Standardized structured data** — LOINC + UCUM + FHIR on records written through `/v1/data` or stored by `/v1/standardize`; your analytics and the agent read the same series.
- **OpenAI-compatible twice over** — Chat Completions *and* Responses protocols; existing SDKs and agent frameworks just work.
- **Multi-tenant by design** — one key, an isolated Subject per end-user; per-Subject [right-to-be-forgotten deletes](/en/api-reference/overview#endpoints-at-a-glance).
- **Open at the core** — Cloud runs the same [open-source engine](/en/self-host), organized around the same three stages. Self-host it and ① Collect becomes ours too: device providers pull on a schedule, and the terminology layer runs offline inside your own process. See [The Engine as a Library](/en/engine).
## Start here
Key → data → standardized records → your first agent, in minutes.
Answers and Agent — how to choose between the two surfaces.
Auth, multi-tenancy, retention, errors, every endpoint.
Which cluster to use, and what is live in each.
For agents and scripts, the whole site is also published as plain text: [`/llms.txt`](/llms.txt) indexes every page, and [`/llms-full.txt`](/llms-full.txt) is the full corpus in one file.
---
# Quickstart
https://docs.mirobody.ai/en/api-reference/quickstart
Three steps: collect your data, watch it standardize, then open Answers.
import BaseUrl from '/snippets/base-url.mdx';
import IngestMentalModel from '/snippets/ingest-mental-model.mdx';
Mirobody in three steps: **① Collect data → ② Auto-standardize → ③ Answers.** Everything is OpenAI-compatible — you'll need an `mb_live_*` key and ~10 minutes.
**API keys, usage, and a live Playground** live in the developer console at [**platform.mirobody.ai**](https://platform.mirobody.ai/). Sign in with an email code, open **API Keys**, create one (the secret is shown **once**):
```bash
export MIROBODY_API_KEY="mb_live_..."
```
Write structured readings with [`POST /v1/data`](/en/api-reference/data) (`retention` is **required**), or upload a lab report — PDF, photo, spreadsheet — with [`POST /v1/files`](/en/api-reference/files):
```bash
# Structured records
curl https://api.mirobody.ai/v1/data \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"user": "alice",
"retention": "permanent",
"records": [
{"indicator": "fasting_glucose", "value": 97, "unit": "mg/dL", "time": "2026-06-16T07:30:00Z"},
{"indicator": "FBG", "value": 92, "unit": "mg/dL", "time": "2026-06-17T07:25:00Z"}
]
}'
# Or a document (the original is stored right away; its text is ready a moment later)
curl https://api.mirobody.ai/v1/files \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-F "user=alice" -F "file=@lab_report.pdf"
```
`user` is the [tenant-isolation key](/en/api-reference/overview#multi-tenancy-the-user-field) — pass each end-user's stable id and their data never mixes.
Note the two spellings above — `fasting_glucose` and `FBG`. Read the data back and both rows carry the **same LOINC code**, a canonical name, UCUM-parsed values, and a FHIR mirror id:
```bash
curl "https://api.mirobody.ai/v1/data?user=alice&limit=10" \
-H "Authorization: Bearer $MIROBODY_API_KEY"
```
```json
{
"object": "list",
"data": [
{
"indicator": "FBG",
"value": "92 mg/dL",
"parsed_value": "92",
"parsed_unit": "mg/dL",
"loinc_code": "1558-6",
"canonical_name": "Fasting glucose [Mass/volume] in Serum or Plasma",
"fhir_resource_id": "8f3c9a1e-...",
"time": "2026-06-17T07:25:00+00",
"source": "api",
"comment": ""
}
],
"has_more": false,
"subject": "alice"
}
```
That's the [standardization pipeline](/en/api-reference/standardization) — deterministic name→LOINC (no LLM code-guessing) + UCUM + FHIR, on every **structured-record** write. To extract and standardize readings from a document, use [`POST /v1/standardize`](/en/api-reference/extract) with the file itself or an uploaded `file_key`.
A grounded answer is three lines — the agent reads the standardized series, so "glucose" finds the rows written as "FBG":
```python Answers API (3 lines)
from openai import OpenAI
client = OpenAI(api_key="mb_live_...", base_url="https://api.mirobody.ai/v1")
print(client.chat.completions.create(model="mirobody-flash", user="alice",
messages=[{"role": "user", "content": "How is my fasting glucose trending?"}]
).choices[0].message.content)
```
And a real agent — with **your own tool** — is ten. The [openai-agents SDK](https://github.com/openai/openai-agents-python) talks to the [Agent API](/en/api-reference/responses) with only a new base URL:
```python openai-agents SDK (10 lines)
from agents import Agent, ModelSettings, Runner, function_tool, set_default_openai_client, set_tracing_disabled
from openai import AsyncOpenAI
set_default_openai_client(AsyncOpenAI(
base_url="https://api.mirobody.ai/v1", api_key="mb_live_..."))
set_tracing_disabled(True)
@function_tool
def book_appointment(date: str) -> str:
"""Book a clinic appointment for the end user (ISO date)."""
return f"Booked: {date} 09:30, Dr. Chen"
agent = Agent(name="Health assistant", model="mirobody-flash", tools=[book_appointment],
model_settings=ModelSettings(extra_body={"user": "alice"}))
print(Runner.run_sync(agent, "Check my recent glucose; book a follow-up if it's trending up.").final_output)
```
Every agents-SDK agent that touches Mirobody tools must pass the Subject via `model_settings=ModelSettings(extra_body={"user": ...})`. Without it, the run reads the **account-default Subject**, not the user you meant.
Mirobody's built-in tools read alice's real data; when the model decides to book, it hands off to **your** function. See [Function calling](/en/api-reference/function-calling) for the protocol underneath.
## The console Playground
The console Playground follows the same flow, in the same words: **① Collect data → ② Auto-standardize → ③ Agent or ③ Answers**. Upload a file, inspect the extracted text or standardized records, and run either API without writing code.
Collect data, inspect standardization, then open Agent or Answers.
Request counts for the last 30 days.
## Next steps
Answers vs Agent — closed completion or full agent.
Client tools, stored conversations, streaming events.
OCR → extraction → LOINC → UCUM → FHIR, explained.
curl / Python / Node / openai-agents, end to end.
---
# Choose Your API
https://docs.mirobody.ai/en/api-reference/choose-your-api
Answers API vs Agent API — pick the right surface in one minute.
Mirobody exposes the same grounded health agent through **two surfaces**. Both answer from the Subject's real, standardized health data, and both return the tool trace behind the answer.
Both are **③ Answers** — the third of the engine's three stages (① Collect → ② Standardize → ③ Answers). The choice on this page is not *what* you get but *who drives the loop*: the Answers API closes it server-side and hands you a finished answer, while the Agent API leaves it open so your own tools take part. That is the same distinction the self-hosted engine draws between its two agents, so a decision made here carries over if you later [run it yourself](/en/engine).
`POST /v1/chat/completions` — closed, one-shot, evidence-backed completions. No client tools, no state. Drop-in OpenAI swap.
`POST /v1/responses` — OpenAI Responses-compatible. Multi-turn state, your own function tools, `response.*` streaming.
The **Answers API** speaks the older Chat Completions protocol and stays deliberately closed — no client tools, no stored state, `chat.completion.chunk` streaming only. That's what lets it drop into an existing OpenAI integration unchanged, or [wrap as a single tool](/en/api-reference/use-as-a-tool) inside a larger agent. The **Agent API** speaks the newer Responses protocol: state between turns (`store`, `previous_response_id`, `session_id`), your own function tools with a full `function_call` handoff, `response.*` events. It is what the `openai-agents` SDK talks to out of the box — change only the `base_url`.
Everything else is identical: the same server-side grounding over your [standardized data](/en/api-reference/data), the same `mirobody-flash` / `mirobody-expert` models, the same Subject-based tenancy, the same usage accounting.
## See also
- [Answers API (Chat Completions)](/en/api-reference/chat) — the closed, grounded completion.
- [Agent API (Responses)](/en/api-reference/responses) — the open agent loop.
- [Quickstart](/en/api-reference/quickstart) — a key, a record and a first answer.
---
# API Overview
https://docs.mirobody.ai/en/api-reference/overview
OpenAI-compatible health-data API — base URL, auth, multi-tenancy, retention, errors.
import BaseUrl from '/snippets/base-url.mdx';
import AuthKey from '/snippets/auth-key.mdx';
**Mirobody Cloud is OpenAI-compatible.** Point any OpenAI SDK at the base URL below, pass an `mb_live_*` key, and call the [Answers API](/en/api-reference/chat) (`/v1/chat/completions`) or the [Agent API](/en/api-reference/responses) (`/v1/responses`) — the agent answers from each end-user's **real, standardized health data**, and returns the tool trace behind the answer. Not sure which surface? See [Choose your API](/en/api-reference/choose-your-api). The same engine also runs **self-hosted** — clone the [open-source engine](/en/self-host) and bring it up with `./deploy.sh`; the hosted API adds managed storage, keys, and billing on top.
Keys, usage, and an interactive Playground live in the [developer console](https://platform.mirobody.ai/). These docs are the API reference.
## Base URL
Global and China are production clusters; Japan and EU are in preparation. Pick the one that matches where the data must be processed — see [Regions](/en/api-reference/regions/overview).
## Authentication
Console sign-in (email code or WeChat) is a separate session login for the console itself — not an API credential, and `/v1` keys are not JWTs.
## OpenAI compatibility
Standard OpenAI fields work as-is; Mirobody extensions ride along in `extra_body` (Python SDK) or as plain top-level JSON (curl/fetch).
| | Fields |
| --- | --- |
| **Standard** | `model`, `messages` / `input`, `stream`, `user` — plus, on the [Agent API](/en/api-reference/responses): `instructions`, `store`, `previous_response_id`, `tools`, `tool_choice`, `text.format` |
| **Mirobody extensions** | `retention` (data lifetime — see below), `session_id` (session scoping; on [`/v1/responses`](/en/api-reference/responses) it binds a durable conversation), `mode` + `builtin_tools` ([backbone mode](/en/api-reference/backbone-mode)), `strict` (strict validation), `reasoning` / `reasoning_effort` (deep-thinking effort — see [Agent API](/en/api-reference/responses)) |
| **Response extensions** | `reasoning_content` / `reasoning` items, `tool_steps[]` (server tool trace), top-level `health_records[]` / `citations[]`, `usage.billed_tokens` (the actual token total you're metered on) |
**Sampling parameters are accepted for compatibility, but the agent runs its own generation.** `max_tokens` is accepted but **not enforced**; `temperature`, `top_p`, `stop`, and `seed` are accepted but **ignored**. On the Answers API, `tools` / `tool_choice` / `response_format` / `n>1` are **explicitly rejected with `400`** — see [Unsupported parameters](/en/api-reference/chat#unsupported-parameters).
## Multi-tenancy: the `user` field
Every request carries a `user` string — the **tenant-isolation key**. The backend maps `(your account, user)` to an internal **Subject**, and Subjects are fully isolated — pass each end-user's stable id as `user` and their data never crosses over. Omit it and the call falls back to your account's default Subject. Subjects are not web-app accounts — they're invisible to the Mirobody app and to other developers.
## Data retention
Anything you write to the data plane (structured records, uploaded files, stored extractions) carries a `retention` that decides how long it's kept:
| `retention` | Meaning | Lifetime |
| --- | --- | --- |
| `permanent` (alias `persistent`) | Kept until explicitly deleted | Until `DELETE /v1/data` / `DELETE /v1/files/{key}` / `DELETE /v1/subjects/{user}` |
| `session` | Bound to a `session_id` | Until `DELETE /v1/sessions/{id}` |
| `1d` / `6h` / `2h` / `1h` | Auto-expires after the grain | Hidden from reads at expiry, then permanently deleted (FHIR mirrors included) |
On **[`POST /v1/data`](/en/api-reference/data) `retention` is required** — no default; omitting it (or any value outside the enum) returns `400` (`code: invalid_retention`). `retention=session` additionally requires a `session_id`. On [`POST /v1/standardize`](/en/api-reference/extract) it's required when `store=true`; on [`POST /v1/files`](/en/api-reference/files) it's optional (defaults to `permanent`).
There is **no `retention: "none"`**. For use-and-forget analysis, run [`POST /v1/standardize`](/en/api-reference/extract) with `store=false` (dry-run — nothing persisted), or write with `retention: "1h"`. The agent **only ever reads the Subject's currently-unexpired data**.
Conversation persistence on the Agent API is a separate knob (`store`) — see [State & memory](/en/api-reference/state-and-memory). For purely subjective entries (journaling), ingest them as stored single-turn agent calls — see the [Journaling recipe](/en/api-reference/state-and-memory#journaling).
## Evidence: health_records & citations
Answers are **explainable and checkable** — not "sounds plausible," but "this conclusion came from that record of yours." Both API surfaces return two top-level evidence arrays of `{tool, data}` pairs:
- **`health_records`** — outputs of the health-data tools the answer relied on (the Subject's actual records).
- **`citations`** — outputs of the external-evidence tools (`search_medical_evidence`, `read_source`); empty when no external evidence was consulted.
The full server tool trace (every call with arguments and results) is in the `tool_steps` extension.
## Error format
Errors use the OpenAI-style envelope:
```json
{
"error": {
"message": "`retention` is required.",
"type": "invalid_request_error",
"code": "invalid_retention",
"param": "retention"
}
}
```
`type` is consistently `invalid_request_error` for caller mistakes; `code` and `param` identify the specific problem. Observed (status, code) pairs:
| HTTP | `code` | When |
| --- | --- | --- |
| `400` | `invalid_retention`, `invalid_session`, … | Malformed request / missing required field — `param` names the field |
| `400` | `unsupported_parameter` | A parameter this surface rejects (e.g. `tools` on the Answers API, `text.format` on agent mode, an unknown top-level param under `strict`) — `param` names it |
| `401` | `null` | Missing or malformed `Authorization` header |
| `401` | `invalid_api_key` | Bad or revoked `mb_live_*` key |
| `404` | — | Unknown resource (`file_key` / `response_id` / `previous_response_id` / subject) |
| `413` | — | Upload exceeds the size limit |
| `422` | — | File text extraction failed (`/v1/standardize`) |
| `429` | `rate_limit_exceeded` | Per-key request rate limit exceeded — carries `Retry-After` + `X-RateLimit-*`. See [Rate Limits & Quota](/en/api-reference/rate-limits) |
| `429` | `insufficient_quota` | Monthly account usage cap reached — resets next month. See [Rate Limits & Quota](/en/api-reference/rate-limits) |
| `500` | `internal_error` | Unexpected server-side failure |
| `502` | — | Upstream agent error — transient; retry with backoff |
In **streaming**, an upstream failure arrives as an SSE error frame (`{"error": ...}` on the Answers API, `response.failed` on the Agent API) before the stream ends.
## Endpoints at a glance
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | [`/v1/models`](/en/api-reference/models) | List capability tiers (`mirobody-flash` / `mirobody-expert`) |
| `POST` | [`/v1/chat/completions`](/en/api-reference/chat) | **Answers API** — closed grounded completion (stream, evidence) |
| `POST`·`GET`·`DELETE` | [`/v1/responses`](/en/api-reference/responses) | **Agent API** — client tools, stored conversations, `response.*` streaming |
| `POST` · `GET` · `DELETE` | [`/v1/data`](/en/api-reference/data) | Write / read / erase structured records (standardized on write) |
| `POST` | [`/v1/standardize`](/en/api-reference/extract) | Document → standardized indicators (dry-run by default) |
| `POST` · `GET` · `DELETE` | [`/v1/files`](/en/api-reference/files) | Upload & parse files (OCR / Excel), list, fetch text, delete |
| `DELETE` | [`/v1/sessions/{id}`](/en/api-reference/lifecycle#sessions) | End a session and purge its session-scoped data |
| `DELETE` | [`/v1/subjects/{user}`](/en/api-reference/lifecycle#offboarding-a-subject) | Erase everything for one Subject (right to be forgotten) — see [Compliance](/en/api-reference/compliance) |
## See also
- [Quickstart](/en/api-reference/quickstart) — the shortest working path.
- [Choose Your API](/en/api-reference/choose-your-api) — Answers or Agent.
- [Rate Limits & Quota](/en/api-reference/rate-limits) — limits, quota and backoff.
---
# Models
https://docs.mirobody.ai/en/api-reference/models
GET /v1/models — list the available capability tiers.
## Endpoint
```http
GET /v1/models
```
Lists the capability tiers you can pass as `model` to the [Answers API](/en/api-reference/chat) and the [Agent API](/en/api-reference/responses). OpenAI-compatible shape.
**Public** — the model catalog is a static capability list (no tenant data), so this endpoint needs **no API key**. An `Authorization` header, if sent (the OpenAI SDKs always attach one), is ignored. Every other `/v1` endpoint requires `Authorization: Bearer mb_live_*`.
## Tiers
| `model` | Tier | Use case |
| --- | --- | --- |
| `mirobody-flash` | Default — fast, low cost | Everyday health Q&A, high concurrency |
| `mirobody-expert` | Deeper reasoning | Complex report interpretation, multi-step analysis |
Hosted model usage is metered from the backing provider's token counts. Each model response includes `usage.billed_tokens`; combine those counts with the current catalog's `pricing` to estimate cost. The console Usage page reports request counts, not token-level cost. The open-source engine is free software — self-host it and you pay only for your own infrastructure and LLM providers.
## Per-tier metadata
Each entry carries a conservative capacity budget and the per-tier price:
| Field | Meaning |
| --- | --- |
| `context_window` | Advertised input-plus-output capacity for the tier. Treat it as a planning budget, not a promise that every request shape will fit. |
| `max_output_tokens` | Advertised output capacity — catalog metadata only; agent mode does not enforce request fields such as `max_tokens`. |
| `pricing` | `{input_per_1m_tokens, output_per_1m_tokens, currency}` — standard price per **1M tokens** for estimating from `usage.billed_tokens`. Cache adjustments are not represented in this object. |
## Example
```bash curl
# no API key needed
curl https://api.mirobody.ai/v1/models
```
```python Python (OpenAI SDK)
from openai import OpenAI
# api_key is unused by /v1/models but the SDK requires the field to be set
client = OpenAI(api_key="unused", base_url="https://api.mirobody.ai/v1")
for m in client.models.list().data:
print(m.id)
```
## Response
An OpenAI-style `{ "object": "list", "data": [ … ] }` — each tier extends the standard model object with `context_window`, `max_output_tokens`, and `pricing`. The values below illustrate the response shape; read the live endpoint for current values:
```jsonc
{
"object": "list",
"data": [
{ "id": "mirobody-flash", "object": "model", "owned_by": "mirobody",
"context_window": 65536, "max_output_tokens": 8192,
"pricing": { "input_per_1m_tokens": 0.75, "output_per_1m_tokens": 4.50, "currency": "USD" } },
{ "id": "mirobody-expert", "object": "model", "owned_by": "mirobody",
"context_window": 65536, "max_output_tokens": 8192,
"pricing": { "input_per_1m_tokens": 5.0, "output_per_1m_tokens": 25.0, "currency": "USD" } }
]
}
```
Capacity, backing providers, and pricing may differ by cluster and can change without changing the stable tier id. Always read `pricing` from the cluster you call rather than hard-coding the example.
Always pass a stable tier as `model` — `mirobody-flash` (default) or `mirobody-expert`.
## See also
- [Rate Limits & Quota](/en/api-reference/rate-limits) — the per-key rate limit and the monthly quota.
- [API Overview](/en/api-reference/overview) — authentication and the base URL.
- [Regions Overview](/en/api-reference/regions/overview) — which tiers each cluster serves.
---
# Files / Photos
https://docs.mirobody.ai/en/api-reference/files
POST /v1/files, GET /v1/files, DELETE /v1/files/{file_key} — upload reports, retrieve extracted text, list, and delete.
Upload a health document to the Subject's store. Mirobody saves the original first, then in the background extracts its readable text — **OCR** for PDFs and images, a straight conversion for spreadsheets and text files — and runs the same standardization pipeline as [`POST /v1/standardize`](/en/api-reference/extract) over it, so an uploaded lab report also lands as standardized records. The agent reads both when it answers.
Uploads are capped at **100 MB** per file — larger requests return `413`. For big exports or realtime streams, use the [WebSocket upload](/en/api-reference/files#websocket-upload) path.
## Upload a file
```http
POST /v1/files
Authorization: Bearer mb_live_*
Content-Type: multipart/form-data
```
| Field | Description |
| --- | --- |
| `file` | The document (PDF / image / Excel / CSV). |
| `user` | Subject the file belongs to. **Optional** — omitting it falls back to your account's **`default`** Subject, so always pass it for per-end-user isolation. |
| `retention` | **Optional here** (unlike `POST /v1/data`, where it's required). Same values as [Data retention](/en/api-reference/overview#data-retention). |
| `session_id` | Optional; scopes `retention=session` uploads to a [session](/en/api-reference/lifecycle#sessions). |
```bash
curl https://api.mirobody.ai/v1/files \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-F "user=alice" \
-F "file=@checkup_2026.pdf"
```
Response:
```json
{
"object": "file",
"id": "AUmIn_R-Ddg.../b/bM--fG29X....pdf",
"filename": "checkup_2026.pdf",
"bytes": 20544,
"status": "processed",
"created_at": 1782924296,
"subject": "alice"
}
```
The file's identifier is **`id`** (a slash-containing key) — use it as `{file_key}` in the fetch and delete calls below. `created_at` is **epoch seconds**.
`status: "processed"` confirms the original was accepted and stored — its text may still be processing. A `GET /v1/files/{file_key}` immediately after upload can return an empty `extracted_text`; retry after a short delay.
An upload makes the document's text readable by the agent, and Mirobody also pulls the report's readings out of it — [standardized](/en/api-reference/standardization) exactly like a `POST /v1/data` write — and stores them as records. Query them from [`GET /v1/data`](/en/api-reference/data) without re-entering anything; re-uploading the same file creates no duplicates. Prefer [`POST /v1/standardize`](/en/api-reference/extract) when you'd rather run that extraction yourself and check the readings first. Already have structured records? Send them to [`POST /v1/data`](/en/api-reference/data).
## List files
```http
GET /v1/files?user=alice
Authorization: Bearer mb_live_*
```
```json
{
"object": "list",
"data": [
{
"object": "file",
"id": "AUmIn_R-Ddg.../b/bM--fG29X....pdf",
"file_key": "AUmIn_R-Ddg.../b/bM--fG29X....pdf",
"filename": "checkup_2026.pdf",
"file_type": "application/pdf",
"bytes": 20544,
"created_at": 1782924296
}
],
"subject": "alice"
}
```
`data` holds the files; `subject` echoes the Subject. Each item exposes both `id` and `file_key` (identical values). `created_at` is **epoch seconds**, matching the upload response.
## Fetch parsed text
```http
GET /v1/files/{file_key}?user=alice
Authorization: Bearer mb_live_*
```
Returns the extracted text for one file — the same content the agent reads:
```json
{
"object": "file",
"id": "AUmIn_R-Ddg.../b/bM--fG29X....pdf",
"filename": "checkup_2026.pdf",
"extracted_text": "Annual checkup 2026-06-16\nFasting glucose 97 mg/dL (ref 70-110)\n...",
"abstract": "",
"subject": "alice"
}
```
`extracted_text` can be empty while the file's text is still being processed. `abstract` may also be empty; it isn't generated for every file type.
## WebSocket upload
For large exports or realtime capture, `wss://…/v1/files/stream` uploads over one socket with chunking and progress. Auth rides the query string (browsers can't set WebSocket headers):
```text
wss://api.mirobody.ai/v1/files/stream?key=mb_live_...&user=alice&retention=permanent
```
Frame sequence (all JSON text frames):
1. **Server → you**: `{"type": "connection_established"}` — send nothing before this.
2. **You → server**: `{"type": "upload_start", "messageId": "", "files": [{"filename", "contentType", "size"}]}` — one `messageId` for the whole batch.
3. **You → server**, per 256 KB chunk: `{"type": "upload_chunk", "messageId", "filename", "chunk": "", "chunkIndex", "totalChunks"}`.
4. **You → server**: `{"type": "upload_end", "messageId"}` → server replies `upload_end_response` when every original file has been stored. Its text may still be processing.
Same 100 MB per-file cap, same Subject isolation, and the stored files are identical to a `POST /v1/files` upload — list them with `GET /v1/files`.
## Delete a file
```http
DELETE /v1/files/{file_key}?user=alice
Authorization: Bearer mb_live_*
```
Removes the file from the API surface immediately (`404` if it isn't the Subject's file or is already deleted):
```json
{ "object": "file", "id": "AUmIn_R-Ddg.../b/bM--fG29X....pdf", "deleted": true, "subject": "alice" }
```
Deleting a file removes the **file** but **not** the records auto-extracted from it. To remove those, delete them from the data plane with [`DELETE /v1/data`](/en/api-reference/data#erase-records) (by `indicator` or `id`), or erase the whole Subject with [`DELETE /v1/subjects/{user}`](/en/api-reference/lifecycle#offboarding-a-subject). (Session-scoped uploads **are** erased together with their session-scoped records by [`DELETE /v1/sessions/{id}`](/en/api-reference/lifecycle#sessions).)
Time-bounded uploads are also removed automatically at **[retention](/en/api-reference/overview#data-retention) expiry** — an expired file disappears from every `GET /v1/files*` read immediately and is then permanently deleted. To erase everything about a Subject at once, use `DELETE /v1/subjects/{user}` — see [Compliance](/en/api-reference/compliance).
## See also
- [Narrative Text & Reports](/en/api-reference/extract) — standardizing a report without storing the file.
- [Structured Records](/en/api-reference/data) — the structured records extraction produces.
- [Data Lifecycle](/en/api-reference/lifecycle) — retention and deletion for files.
---
# Structured Records
https://docs.mirobody.ai/en/api-reference/data
POST /v1/data, GET /v1/data, DELETE /v1/data — write, read, and erase structured health records.
import SubjectUserParam from '/snippets/subject-user-param.mdx';
import IngestMentalModel from '/snippets/ingest-mental-model.mdx';
Write structured records straight into a Subject's store, and read them back. **Device and wearable data is the main form these records take** ([below](/en/api-reference/data#device-and-wearable-data)). Every write runs through the platform's [standardization pipeline](/en/api-reference/standardization): values and units are parsed, recognized indicators get a LOINC code and a canonical name, and each record is mirrored into FHIR. Rows that don't resolve to a code stay readable. The agent reads the same data with its built-in tools.
## Write records
```http
POST /v1/data
Authorization: Bearer mb_live_*
Content-Type: application/json
```
| Field | Type | Description |
| --- | --- | --- |
| `records` | array | Each record `{indicator, value, unit?, time?, end_time?, source?}`. **Max 500 records per request.** `end_time` (optional, ISO time) closes an episode — sleep, a workout — that starts at `time`. `measured_at` / `start_time` are accepted as aliases for `time` — rows from POST /v1/standardize (which returns `measured_at`) can be forwarded as-is. |
| `user` | string | Subject the records belong to (tenant-isolation key). |
| `retention` | string | **Required — there is no default.** One of `permanent` / `1h` / `2h` / `6h` / `1d` / `session` (`persistent` is accepted as an alias of `permanent`). Timed tiers auto-delete when their window is up — `1d` is the longest. Omitting it — or sending any value outside the enum — returns `400` (`code: invalid_retention`). |
| `session_id` | string | **Required when `retention=session`** (`400`, `code: invalid_session`, otherwise). Use a globally unique, opaque id (for example a UUID; never reuse it across Subjects). Tags the records so [`DELETE /v1/sessions/{id}`](/en/api-reference/lifecycle#sessions) can purge them. |
```bash
curl https://api.mirobody.ai/v1/data \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"user": "alice",
"retention": "permanent",
"records": [
{"indicator": "fasting_glucose", "value": 97, "unit": "mg/dL", "time": "2026-06-16T07:30:00Z"},
{"indicator": "fasting_glucose", "value": 92, "unit": "mg/dL", "time": "2026-06-17T07:25:00Z"}
]
}'
```
Response — `standardized` counts the records that resolved to a LOINC code on the way in:
```json
{ "status": "ok", "ingested": 2, "standardized": 2, "subject": "alice" }
```
**Every write is standardized, not just stored.** Each value and unit is parsed to UCUM, and the indicator name is matched to a LOINC code — never a guessed one, so a low-confidence match stays uncoded. See [How standardization works](/en/api-reference/standardization). To see the result without writing anything, use [`POST /v1/standardize`](/en/api-reference/extract) with `store=false`.
Writes are not idempotent — a retry can create duplicate records. Check the outcome before retrying a timed-out request, and keep your own ingestion ledger.
### Episodes (sleep, workouts)
Episode-type records span a period rather than a point in time — put the start in `time` and the end in the optional `end_time`:
```bash
curl https://api.mirobody.ai/v1/data \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"user": "alice",
"retention": "permanent",
"records": [
{"indicator": "sleep_duration", "value": 7.5, "unit": "h",
"time": "2026-07-09T23:10:00Z", "end_time": "2026-07-10T06:40:00Z",
"source": "garmin"}
]
}'
```
## Read records
```http
GET /v1/data?user=alice&indicator=fasting_glucose&limit=100
Authorization: Bearer mb_live_*
```
| Query | Description |
| --- | --- |
| `user` | Subject to read from. |
| `indicator` | Optional filter by indicator name (substring match). |
| `limit` | `1`–`1000` (default `100`). |
| `offset` | Skip N records for paging (ordering is stable: newest first). `has_more: true` → request the next page with `offset += limit`. |
The response is an OpenAI-style list, newest first. `value` is the value exactly as written (`"97"`); the unit lives separately in `parsed_unit`, alongside the other machine-readable fields:
```json
{
"object": "list",
"data": [
{
"id": 1287,
"indicator": "fasting_glucose",
"value": "97",
"parsed_value": "97",
"parsed_unit": "mg/dL",
"loinc_code": "1558-6",
"canonical_name": "Fasting glucose [Mass/volume] in Serum or Plasma",
"fhir_resource_id": "8f3c9a1e-...",
"time": "2026-06-16T07:30:00+00",
"end_time": null,
"source": "api",
"comment": ""
}
],
"has_more": false,
"subject": "alice"
}
```
| Field | Description |
| --- | --- |
| `data` | The records (up to `limit`). |
| `id` | Row id (integer) — pass it to `DELETE /v1/data?id=` for a row-level delete. |
| `value` | The value as written (string, e.g. `"97"`); the unit lives in `parsed_unit`. |
| `parsed_value` / `parsed_unit` | Numeric value + **UCUM** unit from the standardization pipeline (`null` when unparseable). |
| `loinc_code` / `canonical_name` | Deterministic LOINC resolution + its display name (`null` when the name didn't resolve). |
| `fhir_resource_id` | Id of the record's FHIR Observation mirror. |
| `end_time` | End of the period for episode records (sleep, workouts); `null` for point-in-time readings. |
| `reference_low` / `reference_high` / `reference_text` / `abnormal` | Reference range and abnormality flag, when the source (e.g. a lab report) carried them; `null` otherwise. |
| `source` / `comment` | How the record was created: `api` (written via `POST /v1/data`), `extract` (from `POST /v1/standardize`), `upload` (read from a `/v1/files` upload), or `consolidation` (drawn from a stored conversation). Any origin tag you sent in a record's own `source` field comes back in `comment`. |
| `has_more` | `true` when this page hit `limit` — fetch the next page with `offset += limit`. |
| `subject` | The Subject the records belong to (resolved from `user`). |
## Erase records
```http
DELETE /v1/data?user=alice&indicator=fasting_glucose
Authorization: Bearer mb_live_*
```
**Permanently erases** the Subject's records — right-to-be-forgotten semantics (the data is removed, not merely hidden from reads). Three scopes, from narrowest to widest:
| Query | Scope |
| --- | --- |
| `id` | Exactly **one row** — the integer ids `GET /v1/data` returns. Takes precedence over `indicator`. |
| `indicator` | One indicator (substring match, mirroring the GET filter). |
| *(neither)* | **All** of the Subject's records. |
```http
DELETE /v1/data?user=alice&id=1287
```
```json
{ "status": "ok", "deleted": 1, "subject": "alice" }
```
To erase *everything* about a Subject (records + files + conversations + the identity mapping) at once, use [`DELETE /v1/subjects/{user}`](/en/api-reference/lifecycle#offboarding-a-subject) — see [Compliance](/en/api-reference/compliance).
## Device and wearable data
Device data is the main form structured records take. This is the pattern once your
vendor integration — Terra, Junction, or a vendor API of your own — is already
delivering samples into your backend.
**Mirobody does not host device OAuth** — there is no Terra-style connect widget.
Vendor consent screens, token refresh and webhook plumbing stay in your product,
because every vendor's flow is different and it belongs in your own UX. What happens
*after* the data arrives — storage, standardization, retention, deletes, AI — is
Mirobody's job.
Your vendor integration delivers samples, by webhook push or periodic pull.
Roll high-frequency series up to one record per day before writing. Episodes keep their own period.
Up to 500 records per request, `retention` required — the contract above.
### What to aggregate
`POST /v1/data` accepts any granularity, but for most products one row per meaningful
reading is enough — less write volume, and a series that stays easy to reason about.
- **Daily totals** (steps, calories, distance) — one record per day.
- **Continuous series** (heart rate, SpO₂ sampled every few minutes) — aggregate to a daily value such as resting or mean heart rate, and write min/max as their own indicators if you need them. Go finer only where your product genuinely needs it.
- **Episodes** (sleep, workouts) — one record per episode, carrying its period with `time` + `end_time`.
### A day of device records in one call
```bash
curl https://api.mirobody.ai/v1/data \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"user": "alice",
"retention": "permanent",
"records": [
{"indicator": "steps", "value": 12450, "time": "2026-07-10T00:00:00Z", "source": "garmin"},
{"indicator": "resting_heart_rate", "value": 58, "unit": "/min", "time": "2026-07-10T00:00:00Z", "source": "garmin"}
]
}'
```
Batch a full day, or a backfill window, per Subject into one call. Tag the vendor in
`source` — it comes back in the `comment` field when you read the record.
### On-device sample batches
Some samples never pass through a vendor API at all: your app reads them from the phone's
own health store — Apple HealthKit, Android Health Connect, or a Bluetooth scale pairing
straight with your app — and uploads them itself. That is the same endpoint, with two
things to settle in the client:
- **Batch and pre-aggregate on the device.** A month of HealthKit heart-rate samples is
tens of thousands of rows; roll them up to the daily values you actually query, then
send at most 500 records per request.
- **Send the sample's own timestamp**, not the upload time — `time` is when the reading was
taken, and `end_time` carries the period for sleep and workouts.
Use `source` to record where the samples came from (`"healthkit"`, `"health_connect"`,
your device model), so a reading stays traceable after the fact.
## Use-and-forget data
There is **no `retention: "none"`** — writing to the store while asking not to store is contradictory, and the value is rejected like any other non-enum value. Two clean patterns instead:
- **Dry-run analysis, zero side effects** — [`POST /v1/standardize`](/en/api-reference/extract) with `store=false` (the default): standardization results out, nothing written.
- **Short-lived working data** — write with `retention: "1h"` ([auto-expires](/en/api-reference/overview#data-retention) after an hour), or with `retention: "session"` + a `session_id` you [delete](/en/api-reference/lifecycle#sessions) when done.
## See also
- [How Standardization Works](/en/api-reference/standardization) — what happens to a reading on write.
- [Data Lifecycle](/en/api-reference/lifecycle) — how records leave the platform.
---
# Narrative Text & Reports
https://docs.mirobody.ai/en/api-reference/extract
POST /v1/standardize — lab report in, standardized indicators out; dry-run by default.
import SubjectUserParam from '/snippets/subject-user-param.mdx';
```http
POST /v1/standardize
Authorization: Bearer mb_live_*
```
`POST /v1/extract` is a deprecated alias for this endpoint and still works — same request, same response.
**Document in, standardized indicators out.** `/v1/standardize` reads a lab report or health document and returns every reading it found, each matched to a LOINC code and normalized to a UCUM unit (the mechanism is in [How standardization works](/en/api-reference/standardization)). The call is **synchronous** — one request, readings back — so a full multi-page document can take several seconds; size your client timeout accordingly.
By default it is a **dry-run** (`store=false`): you get the standardization result and **nothing is persisted** — zero side effects. Set `store=true` (with a `retention`) to also write those readings into the Subject's store as records, through the same pipeline as [`POST /v1/data`](/en/api-reference/data).
`store=true` is not idempotent — repeating the same call can write duplicate records. Inspect a document with the default dry-run first, and don't blindly retry after a timeout.
## Request
Two input shapes:
- **multipart/form-data** — a `file` (PDF / image / Excel / plain text; images and PDFs are OCR'd) plus the fields below as form fields.
- **application/json** — `{"text": "..."}` (raw report text) **or** `{"file_key": "..."}` (a file already uploaded via [`/v1/files`](/en/api-reference/files)).
| Field | Type | Description |
| --- | --- | --- |
| `file` / `text` / `file_key` | — | Exactly one source. A `file_key` that is unknown or has no extractable text → `404`. |
| `user` | string | The Subject this call runs for. |
| `store` | bool | Default **`false`** (dry-run). `true` also ingests the readings. |
| `retention` | string | **Required when `store=true`** — same enum as [`POST /v1/data`](/en/api-reference/data) (`permanent` / `1h` / `2h` / `6h` / `1d` / `session`). |
| `session_id` | string | Required when `retention=session`. |
### Dry-run (default)
```bash
curl https://api.mirobody.ai/v1/standardize \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-F "user=alice" \
-F "file=@lab_report.pdf"
```
### Standardize and store
```bash
curl https://api.mirobody.ai/v1/standardize \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"user": "alice",
"text": "Fasting glucose 97 mg/dL (2026-07-01); LDL cholesterol 120 mg/dL",
"store": true,
"retention": "permanent"
}'
```
## Response
```json
{
"object": "extraction",
"data": [
{
"indicator_raw": "Fasting glucose",
"canonical_name": "Fasting glucose [Mass/volume] in Serum or Plasma",
"loinc_code": "1558-6",
"value_raw": "97",
"parsed_value": "97",
"unit_raw": "mg/dL",
"unit_ucum": "mg/dL",
"confidence": 0.82,
"measured_at": "2026-07-01"
},
{
"indicator_raw": "LDL cholesterol",
"canonical_name": "Cholesterol in LDL [Mass/volume] in Serum or Plasma",
"loinc_code": "2089-1",
"value_raw": "120",
"parsed_value": "120",
"unit_raw": "mg/dL",
"unit_ucum": "mg/dL",
"confidence": 0.79,
"measured_at": null
}
],
"stored": false,
"stored_count": 0,
"subject": "alice"
}
```
| Field | Description |
| --- | --- |
| `data[]` | One row per extracted reading. |
| `indicator_raw` / `value_raw` / `unit_raw` | Exactly what the document said. |
| `canonical_name` / `loinc_code` | Deterministic LOINC resolution (`null` = no confident code — **never a guessed one**). |
| `parsed_value` / `unit_ucum` | Parsed value (returned as a string) + UCUM-normalized unit. |
| `confidence` | Similarity score of the LOINC match (0–1). |
| `measured_at` | Timestamp found in the document, if any. |
| `page` | 1-based source page the reading came from; present for multi-page documents. |
| `abnormal` | Abnormal-range flag carried by the source (e.g. `"H"` / `"L"` / `"高"`); present when non-empty. |
| `stored` | Whether this call wrote to the store (echoes `store`). |
| `stored_count` | Readings actually written (`0` on a dry-run). |
| `dropped` | **Optional, top-level.** Readings discarded as implausible (out-of-range or garbled values) — present when at least one was filtered out, so you can see what didn't reach `data`. |
| `note` | Present **only when `data` is empty** — explains that no quantifiable readings were found (see below). |
## Narrative text with no readings
`/v1/standardize` returns **quantifiable readings**. Purely narrative text — *"dizzy and a headache all afternoon"* — is a documented boundary, not an error: the call succeeds (`200`) with an empty `data` array and a `note`:
```json
{
"object": "extraction",
"data": [],
"stored": false,
"stored_count": 0,
"subject": "alice",
"note": "no quantifiable readings found in the text; for subjective/journal entries, send a single-turn POST /v1/responses with store:true instead"
}
```
Mixed text does what you'd expect — *"headache all day, temperature was 38.2 °C"* yields the temperature reading and drops the narrative.
**Purely subjective entries (journaling) use the Agent API — no dedicated endpoint.** Send each entry as a single-turn [`POST /v1/responses`](/en/api-reference/responses) with `store: true`, a unique `session_id` such as `journal-{entry_id}`, `builtin_tools: "none"`, and short-acknowledgement `instructions`. Mirobody then pulls any quantifiable readings and durable memories out of the stored entry automatically. With one response per `session_id`, deleting that response also retracts the memories it produced; readings already written to the data plane stay until you delete them through `/v1/data`. See [Journaling](/en/api-reference/state-and-memory#journaling) for the exact lifecycle.
## Errors
| HTTP | When |
| --- | --- |
| `400` | No `file` / `text` / `file_key`; `store=true` without a valid `retention`; `retention=session` without a `session_id` |
| `404` | `file_key` unknown or has no extractable text |
| `413` | File exceeds the upload size limit |
| `422` | Text could not be extracted from the file |
| `502` | Extraction model unavailable — transient; retry with backoff |
Every failure is explicit — there is no silent partial success.
## See also
- [How Standardization Works](/en/api-reference/standardization) — the pipeline this endpoint runs synchronously.
- [Structured Records](/en/api-reference/data) — writing structured readings directly.
- [Files / Photos](/en/api-reference/files) — uploading the original document instead.
---
# How Standardization Works
https://docs.mirobody.ai/en/api-reference/standardization
How structured readings become coded, unit-normalized, FHIR-mirrored data.
Health data arrives messy — "血糖(空腹)", "FBG", "Glucose, fasting" all name the same indicator, and the units vary just as much. Mirobody standardizes every **structured reading** written through [`POST /v1/data`](/en/api-reference/data) or stored by [`POST /v1/standardize`](/en/api-reference/extract), so the agent and your queries see one coherent, coded dataset.
[`POST /v1/files`](/en/api-reference/files) is a separate storage and text-extraction surface; uploading a file standardizes its report values into structured readings automatically. `/v1/standardize` is the explicit path for that same extraction — use it to inspect the result (dry-run), or to standardize `text` / `file_key` sources on demand.
## The pipeline
Both a document and a structured record enter the same pipeline:
The important part is step 3: **codes are matched, never invented.** A model is great at *reading* a document but shouldn't be trusted to *recite* a code system — a wrong LOINC code is worse than none. So when the match isn't confident, Mirobody keeps the raw name and leaves `loinc_code` null rather than guess.
When self-hosting, this pipeline covers structured readings: the engine's provider channel and its on-device batch import share one normalized write, while file extraction writes straight to the store and keeps the report's own indicator names for semantic search to reconcile. See [Data Flow](/en/concepts/data-flow).
## Before / after
What you send to `/v1/data` (or what `/v1/standardize` reads from a report) vs. what the structured store holds:
| | Before (as written) | After (standardized) |
| --- | --- | --- |
| Indicator | `"血糖(空腹)"` / `"FBG"` / `"Glucose, fasting"` | `loinc_code: "1558-6"`, `canonical_name: "Fasting glucose [Mass/volume] in Serum or Plasma"` |
| Value | `"97 mg/dL"` (one string) | `value: "97"` (raw value, no unit), `parsed_value: "97"`, `parsed_unit: "mg/dL"` (UCUM) |
| Unit spelling | `"mg/dl"`, `"MG/DL"`, `"mg/dL"` | `"mg/dL"` (one UCUM form) |
| Interop | free text | FHIR R4 Observation (`fhir_resource_id`) |
| Original | — | **kept**: `value` holds the raw value as written (e.g. `97`, no unit appended — the unit rides in `parsed_unit`); raw names/units preserved |
Three spellings of the same test become **one series** — trend queries, the agent's data tools, and your own analytics all see it as one indicator.
## Standardized output in the API
| Surface | What appears |
| --- | --- |
| [`POST /v1/data`](/en/api-reference/data) | Response counts `standardized` alongside `ingested`. |
| [`GET /v1/data`](/en/api-reference/data#read-records) | Every row carries `parsed_value` / `parsed_unit` / `loinc_code` / `canonical_name` / `fhir_resource_id`. |
| [`POST /v1/standardize`](/en/api-reference/extract) | The whole pipeline as a synchronous call — **dry-run by default**, so you can inspect standardization before committing a write. |
| The agent | Grounded answers query the standardized series — which is why "how's my glucose?" finds records written as "FBG". |
## Cookbook: one call, report → structured data
```bash
# Inspect first (nothing persisted) …
curl https://api.mirobody.ai/v1/standardize \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-F "user=alice" -F "file=@lab_report.pdf"
# … then commit the same extraction
curl https://api.mirobody.ai/v1/standardize \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-F "user=alice" -F "file=@lab_report.pdf" \
-F "store=true" -F "retention=permanent"
```
See [Standardize a report](/en/api-reference/extract) for the full row shape (`indicator_raw`, `loinc_code`, `confidence`, …).
## See also
- [Structured Records](/en/api-reference/data) — the endpoint most writes go through.
- [Narrative Text & Reports](/en/api-reference/extract) — running the same pipeline on a document.
- [Models](/en/api-reference/models) — what the answer layer runs on.
---
# Data Lifecycle
https://docs.mirobody.ai/en/api-reference/lifecycle
Retention, session cleanup, and Subject offboarding — how data leaves the platform.
Data flows in as structured records and as files. Records are [standardized](/en/api-reference/standardization); uploaded files keep both their original and their extracted text. Either can ground the agent's answers. This page closes the loop — how data **leaves**, on your terms. Three exits, from automatic to total:
1. **Retention expiry** — time-bounded writes (`1h` / `2h` / `6h` / `1d`) expire on their own; see [Data retention](/en/api-reference/overview#data-retention).
2. **Session deletion** — one call tears down a named working scope (below).
3. **Subject offboarding** — one call erases everything about a Subject ([below](#offboarding-a-subject)).
## Sessions
A `session_id` can name two things at once — a working-data scope and an Agent API conversation:
1. **Scopes `retention=session` data** — records written with `retention: "session"` via [`POST /v1/data`](/en/api-reference/data) (or [`POST /v1/standardize`](/en/api-reference/extract) with `store=true`) **must** carry a `session_id`. They live until the session is deleted, and deleting it is the only thing that clears them.
2. **Names a conversation on the Agent API** — passing `session_id` to [`POST /v1/responses`](/en/api-reference/responses) makes that conversation durable and resumable under the id. See [State & memory](/en/api-reference/state-and-memory).
```http
DELETE /v1/sessions/{session_id}
Authorization: Bearer mb_live_*
```
Ends the working-data scope: records written with `retention=session` under this `session_id` are erased (together with their FHIR mirrors), session-scoped file uploads are removed, and the underlying chat session is marked closed.
This endpoint does **not** delete stored response objects. A response created with the same `session_id` remains available through `GET /v1/responses/{id}` and can still be chained. Delete the response objects with `DELETE /v1/responses/{id}`, or use Subject offboarding to erase every stored response for that Subject.
```bash
curl -X DELETE "https://api.mirobody.ai/v1/sessions/sess_abc123?user=alice" \
-H "Authorization: Bearer $MIROBODY_API_KEY"
```
Pass the same `user` you used when writing the session's data; omitting it targets your account's default Subject.
```json
{ "status": "ok", "session_id": "sess_abc123", "deleted": 2, "subject": "alice" }
```
`deleted` counts session-scoped records, session-scoped files, and the underlying chat session. It does not count stored response objects. `0` means none of those resources matched.
```python
import requests
BASE = "https://api.mirobody.ai/v1"
H = {"Authorization": "Bearer mb_live_..."}
# Write working data scoped to the session
requests.post(f"{BASE}/data", headers=H, json={
"user": "alice",
"retention": "session",
"session_id": "sess_abc123",
"records": [{"indicator": "systolic_bp", "value": 148, "unit": "mmHg",
"time": "2026-06-16T08:00:00Z"}],
})
# ... run agent turns with session_id="sess_abc123" ...
# When the interaction ends, purge its session-scoped working data:
requests.delete(f"{BASE}/sessions/sess_abc123", headers=H, params={"user": "alice"})
```
## Offboarding a Subject
```http
DELETE /v1/subjects/{user}
Authorization: Bearer mb_live_*
```
Right to be forgotten in a single call. It erases **everything** about one Subject:
1. **Structured records** — everything written via [`POST /v1/data`](/en/api-reference/data), plus stored extractions, whatever their `retention` — FHIR resources included.
2. **Files** — everything uploaded via [`POST /v1/files`](/en/api-reference/files), immediately gone from the API.
3. **Stored Agent API conversations** — immediately unreadable (`GET /v1/responses/{id}` returns `404`) and then permanently deleted.
4. **The identity mapping** — the Subject itself becomes unreachable. A later request that passes the same `user` [mints a fresh, empty Subject](/en/api-reference/overview#multi-tenancy-the-user-field) with no connection to the erased one.
```bash
curl -X DELETE "https://api.mirobody.ai/v1/subjects/alice" \
-H "Authorization: Bearer $MIROBODY_API_KEY"
```
An erase never creates the very Subject it is erasing: resolution looks up the existing mapping only. Passing a `user` your account has never used returns `404` — nothing is minted.
```json
{ "status": "ok", "subject": "alice", "deleted": { "records": 12, "files": 3, "conversations": 5 } }
```
| Field | Description |
| --- | --- |
| `deleted.records` | Structured health records erased. |
| `deleted.files` | Files removed from the API surface. |
| `deleted.conversations` | Stored Agent API conversations removed. |
For **finer-grained** deletion, reach for the per-resource deletes instead: `DELETE /v1/data` (records), `DELETE /v1/files/{key}` (one file), `DELETE /v1/responses/{id}` (one stored response), `DELETE /v1/sessions/{id}` ([session-scoped data and files](#sessions)). See [Compliance](/en/api-reference/compliance) for the full user-rights picture.
## Errors
| HTTP | When |
| --- | --- |
| `404` | `DELETE /v1/subjects/{user}`: your account has no Subject for this `user` — including one already erased |
## See also
- [Structured Records](/en/api-reference/data) — writing and erasing structured records.
- [State & Memory](/en/api-reference/state-and-memory) — what `store` and `retention` each control.
- [Privacy & Compliance](/en/api-reference/compliance) — the controls behind these operations.
---
# Agent API (Responses)
https://docs.mirobody.ai/en/api-reference/responses
POST /v1/responses — OpenAI Responses-compatible agent surface: client tools, stored conversations, previous_response_id chaining.
import BaseUrl from '/snippets/base-url.mdx';
import SubjectUserParam from '/snippets/subject-user-param.mdx';
import StoreRetentionMatrix from '/snippets/store-retention-matrix.mdx';
## Endpoint
```http
POST /v1/responses Create a response
GET /v1/responses/{response_id} Retrieve a stored response
DELETE /v1/responses/{response_id} Delete a stored response; tear down its conversation if it was the last live response
Authorization: Bearer mb_live_*
```
The **Agent API** speaks the [OpenAI Responses protocol](https://platform.openai.com/docs/api-reference/responses) — the **recommended way to build agents** on Mirobody. It does everything the [Answers API](/en/api-reference/chat) does (grounded answers over the Subject's real health data, server-side tools, reasoning), plus:
- **Client function tools** — inject your own tools; the model hands off via `function_call` output items ([Function calling](/en/api-reference/function-calling)).
- **Stored conversations** — `store` defaults to `true`; chain turns with `previous_response_id` or bind a durable conversation with `session_id` ([State & memory](/en/api-reference/state-and-memory)).
- **Standard `response.*` streaming events** ([Streaming](/en/api-reference/streaming)).
Because it speaks that protocol, the **[openai-agents SDK](https://github.com/openai/openai-agents-python) needs only a base-URL change**:
```python
from agents import Agent, ModelSettings, Runner, set_default_openai_client, set_tracing_disabled
from openai import AsyncOpenAI
set_default_openai_client(AsyncOpenAI(
base_url="https://api.mirobody.ai/v1", api_key="mb_live_..."))
set_tracing_disabled(True) # tracing would call api.openai.com
agent = Agent(name="Health assistant", model="mirobody-flash",
model_settings=ModelSettings(extra_body={"user": "alice"}))
print(Runner.run_sync(agent, "How is my fasting glucose trending?").final_output)
```
## Create a response
```bash curl
curl https://api.mirobody.ai/v1/responses \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mirobody-flash",
"input": "How is my fasting glucose trending?",
"user": "alice"
}'
```
```python Python (OpenAI SDK)
from openai import OpenAI
client = OpenAI(api_key="mb_live_...", base_url="https://api.mirobody.ai/v1")
resp = client.responses.create(
model="mirobody-flash",
input="How is my fasting glucose trending?",
user="alice",
)
print(resp.output_text)
```
### Request body
| Field | Type | Description |
| --- | --- | --- |
| `model` | string | `mirobody-flash` (default) or `mirobody-expert`. See [Models](/en/api-reference/models). |
| `input` | string \| array | **Required.** A string (one user turn) or an array of items: `message` items (`{role, content}`), and — when continuing a tool handoff — `function_call` / `function_call_output` items. See [Function calling](/en/api-reference/function-calling). |
| `instructions` | string | System-level instruction for this turn (prepended as a system message). |
| `stream` | bool | `true` → SSE of standard `response.*` events. See [Streaming](/en/api-reference/streaming). |
| `store` | bool | **Default `true`.** Persist the response + conversation: 30-day TTL, or permanent when bound to a `session_id`. Enables `previous_response_id` and `GET /v1/responses/{id}`. |
| `previous_response_id` | string | Continue the conversation of a stored response (server-side state — no need to resend history). `404` if the id is unknown, expired, or deleted. |
| `session_id` | string | Mirobody extension. Binds this and future turns to a **durable named conversation** (never auto-expires). Generate a globally unique opaque id (for example a UUID) and never reuse it across Subjects. |
| `tools` | array | Client **function** tools (`{"type": "function", "name", "description", "parameters"}`; the completions-nested form is also accepted) and/or remote **MCP** servers (`{"type": "mcp", "server_label", "server_url", …}` — executed server-side). Max 64; names must match `[a-zA-Z0-9_-]{1,64}` and may not shadow built-in tool names. See [Function calling](/en/api-reference/function-calling) · [MCP servers](/en/api-reference/mcp-servers). |
| `tool_choice` | string \| object | `"auto"` (default) / `"none"` / `"required"` / a named **client** tool. `"required"` and named guarantees apply in [backbone mode](/en/api-reference/backbone-mode); the full contract (incl. the `400` cases) is there. |
| `mode` | string | `"agent"` (default, full server-side agent) or `"model"` ([backbone mode](/en/api-reference/backbone-mode) — bare inference, no server tools/state). |
| `builtin_tools` | string \| array | `"auto"` (default) / `"none"` / an allowlist of built-in tool names. Trims the server-side domain tools — see [backbone mode](/en/api-reference/backbone-mode). |
| `text.format` | object | Structured output (`json_object` / `json_schema`). **Backbone mode only** — agent mode returns `400`. See [Structured output](/en/api-reference/structured-output). |
| `strict` | bool | When `true` (or header `X-Mirobody-Strict: 1`), an unknown top-level parameter is rejected with `400 unsupported_parameter` instead of being ignored. See [Strict validation](#strict-validation). |
| `user` | string | Tenant-isolation key → a Subject. |
### Response object
```jsonc
{
"id": "resp_147e9b14c172429a8b19da4be9489243",
"object": "response",
"created_at": 1783741077,
"status": "completed",
"model": "mirobody-flash",
"output": [
{ "type": "reasoning", "id": "rs_0", "status": "completed",
"summary": [{ "type": "summary_text", "text": "..." }] },
{ "type": "message", "id": "msg_0", "status": "completed", "role": "assistant",
"content": [{ "type": "output_text", "text": "Your fasting glucose has trended down ..." }] }
],
"output_text": "Your fasting glucose has trended down ...",
"usage": {
"input_tokens": 9, // YOUR visible input only
"output_tokens": 5,
"total_tokens": 14,
"input_tokens_details": { "system_tokens": 10096, "cached_tokens": 0 },
"output_tokens_details": { "reasoning_tokens": 0 },
"billed_tokens": { "input": 20391, "output": 841, "total": 21232 } // the actual token total you're metered on
},
"tool_steps": [], // Mirobody extension: server tool trace
"health_records": [], // Mirobody extension: {tool, data} evidence
"citations": [], // Mirobody extension: literature evidence
"previous_response_id": null,
"store": true,
"tools": [],
"error": null,
"metadata": {}
}
```
The `output` array contains **only standard OpenAI item types** — `reasoning`, `message`, and (on a client-tool handoff) `function_call`. Official SDKs parse it as-is.
**Server-side built-in tool runs are deliberately *not* output items.** The trace rides in the **top-level `tool_steps` extension field** (`{id, name, arguments, result}` — the same shape as the Answers API), which SDKs safely ignore. In streaming it surfaces as the side-channel event [`response.mirobody_tool_call`](/en/api-reference/streaming#server-tool-side-channel). `health_records` and `citations` carry the evidence the answer used, exactly as on the [Answers API](/en/api-reference/chat#response).
### Usage accounting
`usage.input_tokens` reports the input **you actually sent**; the platform's system prompt / tool-schema overhead is broken out as `input_tokens_details.system_tokens`. `output_tokens_details.reasoning_tokens` reports provider reasoning tokens. Counts are summed across every model call of the agent turn.
`usage.billed_tokens` (`{input, output, total}`) is what you're metered on — present on both the Agent API and the [Answers API](/en/api-reference/chat). Applying the catalog's standard input/output rates gives an estimate; prompt-cache discounts can make actual metered cost lower.
## Retrieve a stored response
```bash
curl https://api.mirobody.ai/v1/responses/resp_147e9b14... \
-H "Authorization: Bearer $MIROBODY_API_KEY"
```
Returns the stored response object. Only `store=true` responses are retrievable; expired (30-day TTL) or deleted responses return `404`.
## Delete a stored response
```bash
curl -X DELETE https://api.mirobody.ai/v1/responses/resp_147e9b14... \
-H "Authorization: Bearer $MIROBODY_API_KEY"
```
```json
{ "id": "resp_147e9b14...", "object": "response.deleted", "deleted": true }
```
If it was the **last** live response of its conversation, the whole conversation is torn down too — its history and any conversation-derived memory. That makes this the self-service right-to-be-forgotten lever for stored conversations.
## State model
Full details — TTLs, chaining semantics, stateless replay, and the cross-session memory that `store=true` feeds — in [State & memory](/en/api-reference/state-and-memory).
## Strict validation
By default the surface is **lenient** (compatibility-first) — an unknown top-level parameter is ignored. Set `strict: true` in the body **or** send the header `X-Mirobody-Strict: 1` to make it a hard `400 unsupported_parameter` instead, with `param` pointing at the first offending key. Both surfaces (`/v1/responses` and `/v1/chat/completions`) honor it. Turn it on while integrating to catch typos and misplaced fields early.
```bash
curl https://api.mirobody.ai/v1/responses \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Mirobody-Strict: 1" \
-d '{"model":"mirobody-flash","input":"hi","bogus_param":1,"user":"alice"}'
# → 400 {"error":{"code":"unsupported_parameter","param":"bogus_param", ...}}
```
`reasoning` (e.g. `{"effort": "low" | "medium" | "high"}`) is **honored**: the effort maps to the underlying model's extended-thinking / reasoning budget. It streams as the `response.reasoning_summary_text.*` events and lands in the non-stream `reasoning` output item; `usage.output_tokens_details.reasoning_tokens` counts it. Omit `reasoning` for the model's default behavior. Models without a thinking mode ignore it (no error).
## Errors
Standard [error envelope](/en/api-reference/overview#error-format). Surface-specific cases:
| HTTP | When |
| --- | --- |
| `400` | Missing/empty `input`; invalid `tools` (bad name, reserved name, duplicate, > 64, unsupported type, unreachable MCP server); unsupported `tool_choice` (see the [contract](/en/api-reference/backbone-mode)); `text.format` on agent mode; `mode:"model"` with `previous_response_id`/`session_id`/MCP tools; unknown `builtin_tools` name; unknown top-level param under `strict`; malformed handoff continuation (see [Function calling](/en/api-reference/function-calling#continuation-errors)) |
| `404` | Unknown / expired / deleted `previous_response_id` or `response_id` |
| `429` | `rate_limit_exceeded` (per-key rate limit) or `insufficient_quota` (monthly account cap) — see [Rate Limits & Quota](/en/api-reference/rate-limits) |
| `502` | Upstream agent error — transient; retry with backoff. In [backbone mode](/en/api-reference/backbone-mode) provider errors are classified (400 / 429 / 502) rather than a blanket 502. |
## See also
- [Function Calling](/en/api-reference/function-calling) — declaring client tools and continuing after a handoff.
- [State & Memory](/en/api-reference/state-and-memory) — `store`, session state and cross-session memory.
- [Streaming](/en/api-reference/streaming) — the `response.*` event sequence.
- [Backbone Mode](/en/api-reference/backbone-mode) — turning this endpoint into a bare inference backend.
---
# Backbone Mode
https://docs.mirobody.ai/en/api-reference/backbone-mode
Run the Agent API as a bare LLM backend: mode:\"model\", the builtin_tools allowlist, and the full tool_choice contract.
import BaseUrl from '/snippets/base-url.mdx';
**Backbone mode** turns [`POST /v1/responses`](/en/api-reference/responses) into a *bare inference backend* — an OpenAI-compatible LLM with client function tools, but **no** server-side agent runtime, tools, planning, or stored state. Use it when your own agent framework (LangChain, openai-agents, a custom loop) is the orchestrator and Mirobody is "the model."
The default (`mode: "agent"`) is the whole Mirobody agent: server-side runtime, built-in health-data tools, planning, and stored conversations.
Reach for backbone mode only when you own the orchestration. If you want grounded answers over the Subject's real health data, stay on the default agent mode — that's where the [built-in tools](/en/api-reference/function-calling#built-in-server-tools) live.
## `mode`
```jsonc
{ "mode": "agent" | "model" } // default "agent"
```
| | `mode:"agent"` (default) | `mode:"model"` (backbone) |
| --- | --- | --- |
| Runtime | planning, sub-agents, code eval, virtual filesystem | single bare-model inference |
| Server tools | built-in domain tools (trim with `builtin_tools`) | **none** |
| System prompt | full agent base (~10k tokens) | minimal safety base (<100 tokens) |
| State | `previous_response_id` / `session_id` threads | **stateless** (both → `400`) |
| Continuation | stateful resume **or** stateless full replay | stateless full replay only |
| Client tools | handoff via `function_call` (server-issued `call_id`) | bound directly to the model (**`call_id` preserved**) |
| MCP tools | supported (server-executed) | `400` (no server tool loop) |
| Server-side thread state | yes (deleted at request end when `store=false`) | none |
| [`text.format`](/en/api-reference/structured-output) | `400` (not supported) | **supported** |
With `store=true`, model mode still persists the response object (`GET /v1/responses/{id}` works), but the conversation is tagged as model-mode: a later `previous_response_id` against it returns an explicit `400` ("replay the transcript to continue") rather than silently resuming an empty thread.
```bash
curl https://api.mirobody.ai/v1/responses \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mirobody-flash",
"mode": "model",
"builtin_tools": "none",
"input": "Summarize the attached lab panel.",
"user": "alice"
}'
```
Model mode is **stateless**. Continue a run by resending the full item transcript in `input` (the [stateless full-replay path](/en/api-reference/function-calling) — what openai-agents does by default). `previous_response_id`, `session_id`, and MCP tools each return `400`.
## `builtin_tools`
```jsonc
{ "builtin_tools": "auto" | "none" | ["query_health_data", ...] } // default "auto"
```
Controls the server-side **domain-tool family** — the health, clinical-record, and external-evidence tools:
`query_health_data` · `list_clinical_records` · `list_family_members` · `search_medical_evidence` · `read_source`
The external-evidence surface accepts only `search_medical_evidence` and `read_source`. Arbitrary URL fetching is not available: `read_source` accepts refs returned by search (`pmid:`, `pmc:`, `nct:`, or `doi:`), not model-constructed URLs.
| Value | Effect |
| --- | --- |
| `"auto"` (default) | All domain tools available — current behavior. |
| `"none"` | Domain tools all hidden. **Required when your backbone caller brings its own data or tools** — otherwise the model prefers to query the (empty) Mirobody Subject and skips your client tool. |
| `["name", ...]` | Allowlist. An unknown name returns `400 invalid_value` and lists the available set. |
The server-side orchestration primitives (`write_todos`, filesystem, `task`, `eval`) belong to agent mode itself, **outside** this parameter's scope — they may still appear in `tool_steps`. For a **zero** server-tool trace, use `mode:"model"`.
## `tool_choice`
`tool_choice` follows OpenAI semantics, with model mode adding hard guarantees the agent runtime cannot make:
| `tool_choice` | `mode:"model"` | `mode:"agent"` (default) |
| --- | --- | --- |
| omitted / `"auto"` | Model decides | Model decides |
| `"none"` | Plain text (auto-drops into model mode) | Same, when stateless; with `previous_response_id`/`session_id` → `400` |
| `"required"` | **Guarantees** the output has at least one client `function_call` | `400 unsupported_parameter` (the agent runtime can't force a client-tool call) |
| `{"type":"function","name":X}` | **Guarantees** only `X` is called (stray calls pruned) | Accepted but best-effort (not forced) |
| `required` / named + empty `tools` | `400 invalid_value`, `param="tool_choice"` | Same |
**`tool_choice:"none"` disables *all* tools**, including the built-in ones (OpenAI semantics), and internally degrades to model-mode plain-text generation. The old behavior — clearing only client tools while the built-ins ran on — is gone.
### Guarantees for forced tool calls
`required` / a named tool is a hard guarantee, not a hint — if it can't be honored you get a `502`, never a silent downgrade. In streaming, a forced call is synthesized into the standard event sequence (`response.output_item.added` → `response.function_call_arguments.delta`/`.done` → `response.output_item.done`), identical to the non-streaming behavior.
This works with LangChain `create_agent` under both `ToolStrategy(...)` (which auto-sends `tool_choice:"required"`) and `ProviderStrategy(...)`.
## Provider error classification (model mode)
In model mode, provider exceptions are classified by retryability instead of a blanket `502`:
| Provider side | Returned | Meaning |
| --- | --- | --- |
| `400` / `404` / `413` / `422` (params, over-length) | **`400`** `invalid_request_error` | The provider's actionable message is passed through (truncated). **Do not retry.** |
| `429` | **`429`** `rate_limit_error` | `Retry-After` passed through or synthesized. |
| `401` / `403` (platform-side credentials / quota) | **`502`** `upstream_error` | A platform fault, not your request — no internal detail leaked. |
| `5xx` / timeout / transport | **`502`** `upstream_error` | Error type only; full text is logged with an `X-Request-Id`. |
## See also
- [Structured output](/en/api-reference/structured-output) — `text.format` (model mode only).
- [Function calling](/en/api-reference/function-calling) — declaring client tools and the two continuation paths.
- [Streaming](/en/api-reference/streaming) — `response.*` events, including the server-tool side-channel.
- [Rate Limits & Quota](/en/api-reference/rate-limits) — per-key rate limit and the monthly account quota.
---
# Function Calling
https://docs.mirobody.ai/en/api-reference/function-calling
Built-in server tools + your own client function tools on the Agent API, with both handoff continuation styles.
The [Agent API](/en/api-reference/responses) runs two kinds of tools:
1. **Built-in server tools** — the platform's health-data tools. They run **server-side**; you never execute them. Their trace is reported, not delegated.
2. **Client function tools** — tools **you** declare on the request. When the model wants one, the response hands off with a `function_call` output item; you execute it and continue the run.
## Built-in server tools
The agent always has its platform toolset over the Subject's data (same catalog as the [Answers API](/en/api-reference/chat#built-in-tools)):
- `query_health_data` — search and aggregate the Subject's records
- `list_clinical_records` — list clinical documents / FHIR-backed records
- `list_family_members` — resolve care-circle members the Subject is allowed to query
- `search_medical_evidence` — search literature, guidelines/consensus, and registered trials (feeds `citations`)
- `read_source` — read one search result by ref; arbitrary URL fetching is not supported
The `/v1` agent's domain tools are read-only. It also runs internal planning and analysis tools. Every server-tool run lands in the response object's **top-level `tool_steps` extension** — never as an `output` item, which official SDKs would mis-parse — and in streaming as the [`response.mirobody_tool_call` side-channel event](/en/api-reference/streaming#server-tool-side-channel).
## Declaring client tools
```jsonc
{
"model": "mirobody-flash",
"input": "Check my recent glucose and book a follow-up if it is trending up.",
"user": "alice",
"tools": [
{
"type": "function",
"name": "book_appointment",
"description": "Book a clinic appointment for the end user.",
"parameters": {
"type": "object",
"properties": { "date": { "type": "string", "description": "ISO date" } },
"required": ["date"]
}
}
]
}
```
Rules (violations are explicit `400`s, never silently dropped):
| Rule | Detail |
| --- | --- |
| Type | `"type": "function"` (client handoff) and `"type": "mcp"` (server-side remote tools) are supported. Both the flat Responses form and the completions-nested `{"type":"function","function":{...}}` form are accepted. `{"type": "mcp"}` attaches your remote MCP servers (server-side execution, no handoff) — see [MCP servers](/en/api-reference/mcp-servers). |
| Count | Max **64** tools per request. |
| Names | Must match `[a-zA-Z0-9_-]{1,64}`; must be unique; must **not shadow a built-in tool name** (`query_health_data`, `read_file`, `task`, …). |
| `parameters` | A JSON Schema object (defaults to `{"type":"object","properties":{}}`). |
| `tool_choice` | `"auto"` (default), `"none"` (disables **all** tools for the turn, built-ins included — see [backbone mode](/en/api-reference/backbone-mode)), or a named client tool. Anything else → `400 unsupported_parameter`. |
**Security note:** the agent holds tools over the Subject's health data. Subject isolation already limits every call to that developer's own data, but treat your tool descriptions and results as part of the prompt surface — don't feed untrusted third-party text through them without review.
## The handoff
When the model calls your tool, the response **completes** with a `function_call` output item (`status: "completed"` — the *response* is done; the *conversation* is waiting on you):
```jsonc
{
"id": "resp_abc...",
"object": "response",
"status": "completed",
"output": [
{ "type": "function_call", "id": "fc_0", "call_id": "call_9f2...",
"status": "completed", "name": "book_appointment",
"arguments": "{\"date\": \"2026-07-14\"}" }
],
"output_text": "",
...
}
```
In streaming, the same handoff arrives as a `response.output_item.added` → `response.function_call_arguments.delta` / `.done` → `response.output_item.done` event group ([Streaming](/en/api-reference/streaming)).
Execute the tool, then continue the run in **either** of two ways:
### Path 1 — stateful resume (`previous_response_id`)
Send **only** `function_call_output` items, referencing the handoff response. The server resumes the paused agent thread — no history resend:
```bash
curl https://api.mirobody.ai/v1/responses \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mirobody-flash",
"previous_response_id": "resp_abc...",
"input": [
{ "type": "function_call_output", "call_id": "call_9f2...",
"output": "Booked: Mon 2026-07-14 09:30, Dr. Chen" }
],
"user": "alice"
}'
```
Requirements: outputs must cover **exactly** the pending `call_id`s (parallel calls → one `function_call_output` each); you may not mix `message` items into a resume; a handoff can be resumed **once** (a duplicate resume fails loudly). The handoff response must have been stored (`store=true`, the default).
### Path 2 — stateless full replay
What **openai-agents** does by default: resend the *entire* item transcript in `input` — including the `function_call` / `function_call_output` pairs — with **no** `previous_response_id`:
```jsonc
{
"model": "mirobody-flash",
"input": [
{ "role": "user", "content": "Check my recent glucose and book a follow-up if it is trending up." },
{ "type": "function_call", "call_id": "call_9f2...", "name": "book_appointment",
"arguments": "{\"date\": \"2026-07-14\"}" },
{ "type": "function_call_output", "call_id": "call_9f2...",
"output": "Booked: Mon 2026-07-14 09:30, Dr. Chen" }
],
"tools": [ ... ],
"user": "alice"
}
```
The pairs are reconstructed as conversation history and the run continues as a fresh turn. Works with `store=false` end to end.
## End-to-end with openai-agents
The SDK handles the whole loop — declaration, handoff, execution, replay:
```python
from agents import Agent, ModelSettings, Runner, function_tool, set_default_openai_client, set_tracing_disabled
from openai import AsyncOpenAI
set_default_openai_client(AsyncOpenAI(
base_url="https://api.mirobody.ai/v1", api_key="mb_live_..."))
set_tracing_disabled(True)
@function_tool
def book_appointment(date: str) -> str:
"""Book a clinic appointment for the end user (ISO date)."""
return f"Booked: {date} 09:30, Dr. Chen"
agent = Agent(
name="Health assistant",
model="mirobody-flash",
instructions="Check real health data before acting.",
tools=[book_appointment],
model_settings=ModelSettings(extra_body={"user": "alice"}), # tenant isolation — REQUIRED
)
result = Runner.run_sync(agent, "Check my recent glucose and book a follow-up if it's trending up.")
print(result.final_output)
```
Every agents-SDK agent that touches Mirobody tools must pass the Subject via `model_settings=ModelSettings(extra_body={"user": ...})`. Without it, the run reads the **account-default Subject**, not the user you meant.
The model reads the Subject's real glucose data with **built-in server tools**, then hands off to **your** `book_appointment` — the SDK executes it locally and replays the transcript automatically.
## Continuation errors
| HTTP `400` message | Cause |
| --- | --- |
| `previous response has no pending function calls` | Resuming a response that wasn't a handoff (or was already resumed). |
| `cannot mix message items with function_call_output when resuming via previous_response_id` | A resume must contain only tool outputs. |
| `unknown call_id(s): [...]` / `missing function_call_output for call_id(s): [...]` | Outputs must match the pending calls exactly. |
| `previous response has pending function call(s) — provide function_call_output items for: ...` | Continuing a handoff conversation without supplying the outputs. |
| `function_call_output without matching function_call items — replay the full transcript, or resume via previous_response_id` | A stateless replay must include the `function_call` items too. |
## See also
- [Agent API (Responses)](/en/api-reference/responses) — the endpoint these tools run on.
- [MCP Servers](/en/api-reference/mcp-servers) — attaching a remote MCP server instead of a local tool.
- [Structured Output](/en/api-reference/structured-output) — constraining the final answer to a schema.
---
# Structured Output
https://docs.mirobody.ai/en/api-reference/structured-output
text.format — constrain the model to JSON (json_object) or a JSON Schema (json_schema) in backbone mode.
Use `text.format` on [`POST /v1/responses`](/en/api-reference/responses) to request JSON output. It follows the [OpenAI Responses `text.format` shape](https://platform.openai.com/docs/api-reference/responses/create#responses-create-text) and is available **only in [backbone mode](/en/api-reference/backbone-mode) (`mode:"model"`)**.
`text.format` requires `mode:"model"`. On the default agent mode it returns `400 unsupported_parameter` (`param: "text"`). The Answers API (`/v1/chat/completions`) likewise rejects `response_format` with `400`.
## `json_object`
Constrains the output to a syntactically valid JSON object (no schema):
```jsonc
{
"model": "mirobody-flash",
"mode": "model",
"input": "Reply with a JSON object having keys a and b.",
"text": { "format": { "type": "json_object" } }
}
```
The message text is a JSON string you parse yourself:
```jsonc
{ "output_text": "{\"a\":1,\"b\":2}", "status": "completed", ... }
```
## `json_schema`
Requests output that follows a schema you supply. `strict: true` is forwarded to providers that support native JSON Schema:
```bash curl
curl https://api.mirobody.ai/v1/responses \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mirobody-flash",
"mode": "model",
"input": "Give me a manifest for apples with count 3.",
"text": {
"format": {
"type": "json_schema",
"name": "manifest",
"strict": true,
"schema": {
"type": "object",
"properties": {
"item": { "type": "string" },
"count": { "type": "integer" }
},
"required": ["item", "count"],
"additionalProperties": false
}
}
}
}'
```
```python Python (OpenAI SDK)
from openai import OpenAI
client = OpenAI(api_key="mb_live_...", base_url="https://api.mirobody.ai/v1")
resp = client.responses.create(
model="mirobody-flash",
extra_body={"mode": "model"},
input="Give me a manifest for apples with count 3.",
text={
"format": {
"type": "json_schema",
"name": "manifest",
"strict": True,
"schema": {
"type": "object",
"properties": {"item": {"type": "string"}, "count": {"type": "integer"}},
"required": ["item", "count"],
"additionalProperties": False,
},
}
},
)
print(resp.output_text) # {"item":"apples","count":3}
```
## Provider fallback
`text.format` passes through to the underlying model. If the provider rejects native `json_schema`, Mirobody retries once as `json_object` with the schema included in the system prompt. That fallback targets the requested shape but does **not** validate the returned object against your schema. Parse and validate `output_text` yourself before using it.
## See also
- [Backbone mode](/en/api-reference/backbone-mode) — `mode:"model"`, the prerequisite for `text.format`.
- [Function calling](/en/api-reference/function-calling) — for tool-argument shaping, pair `text.format` with `tool_choice:"required"`.
---
# MCP Servers
https://docs.mirobody.ai/en/api-reference/mcp-servers
Bring your own remote MCP tools into the agent turn — OpenAI-native tools entry on the Agent API.
The agent ships with built-in tools over the Subject's health data ([Function calling → Built-in server tools](/en/api-reference/function-calling#built-in-server-tools)). Attaching a remote MCP server adds **your own tools** to that set: the platform connects, lists the server's tools, and lets the agent call them alongside the built-ins during the turn.
This is **not** "Mirobody as an MCP server." It's the reverse: you bring external MCP tools *into* the agent's reasoning. The platform executes the calls server-side — unlike [client function tools](/en/api-reference/function-calling), there is no handoff back to you.
## Attach a server
Use the OpenAI Responses protocol's own `tools` entry, on the **Agent API**:
```bash
curl https://api.mirobody.ai/v1/responses \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mirobody-expert",
"input": "Cross-check my recent labs against our formulary.",
"user": "alice",
"tools": [
{
"type": "mcp",
"server_label": "formulary",
"server_url": "https://mcp.your-company.com/mcp",
"authorization": "YOUR_TOKEN",
"allowed_tools": ["lookup_drug"]
}
]
}'
```
| Field | Notes |
| --- | --- |
| `server_label` | Required. Alphanumeric/`_`/`-`; shows up in `tool_steps` names as `mcp__{label}__{tool}`. |
| `server_url` | Required. **Public http(s)** streamable-HTTP MCP endpoint — hosts resolving to private/loopback ranges are rejected (`400`). |
| `authorization` | Optional. Sent as the `Authorization` header (auto-prefixed `Bearer ` unless you pass a scheme). |
| `allowed_tools` | Optional allowlist of tool names; everything else on the server is ignored. |
| `require_approval` | Only `"never"` (default). `"always"` returns `400` — the server is yours and calls run server-side, so an approval round-trip adds nothing. |
Mix freely with your `type: "function"` client tools in the same `tools` array. Limits: ≤ 8 servers per request, ≤ 64 tools total.
## The response
MCP calls are **server-side tool steps** — they appear in the response's top-level `tool_steps` (name `mcp__{label}__{tool}`, with arguments and result) and stream as `response.mirobody_tool_call` events. The `output` array stays pure OpenAI item types; there is no `function_call` handoff for MCP tools.
## Errors
| HTTP `400` message | Cause |
| --- | --- |
| `mcp server '' unusable: …` | Endpoint unreachable / not an MCP streamable-HTTP server. |
| `mcp server host resolves to a non-public address` | SSRF guard — the URL must be publicly routable. |
| `require_approval is not supported …` | Pass `"never"` or omit. |
## In the web app
End users of [Mirobody Chat](https://chat.mirobody.ai/), the consumer app on this same backend, can attach their own MCP servers under **Settings → MCP**, and its agent picks them up automatically each turn. That is the outbound direction, the same as this endpoint. (A self-hosted engine also has an inbound **Settings → MCP Url**, which mints a URL so external clients can call *into* that instance — see [Self-Host Mirobody](/en/self-host).) A misconfigured or down server is skipped there (chat never fails because of it); on the API surface the same problem is an explicit `400`, because you asked for that server in this request.
## When to prefer client function tools
If your tool needs to run **inside your own process** (private network, local state, human confirmation), use [client function tools](/en/api-reference/function-calling) instead: the model hands off with `function_call`, you execute, and you resume the run — the openai-agents SDK automates that loop.
## See also
- [Function Calling](/en/api-reference/function-calling) — client tools, the other way to extend the agent.
- [Agent API (Responses)](/en/api-reference/responses) — the endpoint that accepts the `tools` entry.
- [Streaming](/en/api-reference/streaming) — how server-side tool steps appear in the stream.
---
# Streaming
https://docs.mirobody.ai/en/api-reference/streaming
response.* SSE events on the Agent API, including the mirobody_tool_call side-channel.
Set `stream: true` on [`POST /v1/responses`](/en/api-reference/responses) and the reply arrives as standard **OpenAI Responses `response.*` SSE events** — every frame is `event: ` + `data: `, with a monotonic `sequence_number`. Official SDK stream loops consume it as-is.
```python
from openai import OpenAI
client = OpenAI(api_key="mb_live_...", base_url="https://api.mirobody.ai/v1")
with client.responses.stream(
model="mirobody-flash",
input="How is my fasting glucose trending?",
user="alice",
) as stream:
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
```
## Event table
| Event | Meaning |
| --- | --- |
| `response.created` | The run started — carries a response snapshot with `status: "in_progress"`. |
| `response.in_progress` | Follows immediately (OpenAI parity). |
| `response.output_item.added` | A new output item opened (`reasoning`, `message`, or `function_call`) — carries `output_index` + the in-progress item. |
| `response.reasoning_summary_part.added` / `.done` | A reasoning summary part opened / closed. |
| `response.reasoning_summary_text.delta` / `.done` | Provider-supplied reasoning-summary fragments, then the full summary. |
| `response.content_part.added` / `.done` | An answer content part opened / closed. |
| `response.output_text.delta` / `.done` | Answer text fragments, then the full text — **the channel most clients render**. |
| `response.function_call_arguments.delta` / `.done` | A client-tool handoff's arguments (see [Function calling](/en/api-reference/function-calling)). |
| `response.output_item.done` | The open item completed — carries the finished item. |
| `response.mirobody_tool_call` | **Mirobody extension, side-channel** — a built-in server tool was *called*. See below. |
| `response.mirobody_tool_result` | **Mirobody extension, side-channel** — a built-in server tool's *result* landed. See below. |
| `response.completed` | Terminal: carries the **full final response object** (`output`, `usage`, `tool_steps`, `health_records`, `citations`). |
| `response.failed` | Terminal error: carries a response snapshot with `status: "failed"` and an `error` object. |
Items stream strictly one at a time: reasoning first (when the tier emits it), then the message, then any `function_call` handoffs — `output_index` increments per item, and the indexes match the final `output` array on `response.completed`.
## Server-tool side-channel
Built-in server tools (data search, literature, …) are **not** output items — official SDKs would mis-parse unknown item types, so their trace rides a dedicated event that standard stream loops safely ignore:
```text
event: response.mirobody_tool_call
data: {"type":"response.mirobody_tool_call","sequence_number":7,
"tool_step":{"id":"mtc_0","call_id":"call_1a2b...","name":"query_health_data",
"arguments":"{\"query\": \"fasting glucose last 90 days\"}"}}
```
It fires when the tool is *called*; it does **not** open an output item or advance `output_index`. Render it if you want an activity feed ("Searching your records…"); skip it and nothing breaks.
`response.mirobody_tool_result` is its pair, pushed when that tool's **result** lands — the other half of the side-channel, equally ignorable:
```text
event: response.mirobody_tool_result
data: {"type":"response.mirobody_tool_result","sequence_number":9,
"tool_step":{"id":"mtc_0","call_id":"call_1a2b...","name":"query_health_data",
"result":"...","truncated":false}}
```
Match a result to its call by `id` / `call_id`. The streamed `result` is **truncated to 4096 characters** (`truncated: true` when clipped); the full value is always on the final response object (`response.completed`) under `tool_steps[].result`.
## Failure events
On an upstream error the stream ends with `response.failed` (not a broken pipe):
```text
event: response.failed
data: {"type":"response.failed","sequence_number":12,
"response":{"id":"resp_...","status":"failed",
"error":{"type":"upstream_error","message":"..."}}}
```
For the Answers API's simpler `chat.completion.chunk` streaming, see [Answers API → Streaming](/en/api-reference/chat#streaming).
## See also
- [Agent API (Responses)](/en/api-reference/responses) — the request that produces this stream.
- [Answers API (Chat Completions)](/en/api-reference/chat) — the SSE shape on the Answers API.
- [Function Calling](/en/api-reference/function-calling) — the handoff that pauses a stream.
---
# State & Memory
https://docs.mirobody.ai/en/api-reference/state-and-memory
How store, previous_response_id, session_id and data retention compose on the Agent API.
import StoreRetentionMatrix from '/snippets/store-retention-matrix.mdx';
Two independent knobs govern what persists:
## Conversation state (`store`)
`store` defaults to **`true`** (OpenAI parity). A stored response persists three things: the response object (for `GET /v1/responses/{id}`), the conversation thread (so `previous_response_id` can continue it), and what the turn contributes to the platform's conversation memory.
| Mode | How | Lifetime |
| --- | --- | --- |
| **One-shot** | `store: false` | Nothing persists after the reply. Multi-turn still possible via [stateless replay](/en/api-reference/function-calling#path-2--stateless-full-replay). |
| **Chained** | `store: true` (default), continue with `previous_response_id` | **30-day TTL** per response; expired responses `404` on GET and can't be chained. |
| **Durable conversation** | pass `session_id` | Bound responses **never auto-expire**. The same `session_id` always resumes the same conversation — a stable handle for "the user's ongoing thread". |
```python
# Turn 1 — stored by default
r1 = client.responses.create(model="mirobody-flash",
input="How is my fasting glucose trending?", user="alice")
# Turn 2 — server-side state: no history resend
r2 = client.responses.create(model="mirobody-flash",
input="And compared with last quarter?",
previous_response_id=r1.id, user="alice")
```
`previous_response_id` on an unknown, **expired**, or deleted response returns `404` — treat a chained conversation older than 30 days as gone unless it was `session_id`-bound. A response that ended in a client-tool handoff must be continued with `function_call_output` items first — see [Function calling](/en/api-reference/function-calling#continuation-errors).
### Cleanup
`DELETE /v1/responses/{id}` always removes that stored response. Only when it is the **last live response in its conversation** does the platform tear down the conversation history and retract conversation-derived memory. See [Agent API → Delete](/en/api-reference/responses#delete-a-stored-response).
## Cross-session memory
Stored conversations let the agent remember durable facts about a Subject across later conversations. `store: false` turns stay out of it entirely. Deleting the last live response in a conversation retracts that conversation's derived memory; deleting an earlier one retracts nothing while other responses in the thread remain.
## Journaling
A subjective journal entry — *"headache all afternoon, eased after two coffees"* — is **a single-turn `POST /v1/responses` with `store: true`**. No dedicated endpoint: the knobs on this page already compose into the recipe. Give each entry its own `session_id` (for example, `journal-{entry_id}`) so it never hits the 30-day TTL and can be deleted independently. `builtin_tools: "none"` plus a short-acknowledgement `instructions` keeps the reply — which you don't consume — as cheap as possible:
```bash
curl -s https://api.mirobody.ai/v1/responses \
-H "Authorization: Bearer $MIROBODY_API_KEY" -d '{
"input": "Headache all afternoon; eased after two cups of coffee",
"user": "patient-42",
"session_id": "journal-entry-018",
"store": true,
"builtin_tools": "none",
"instructions": "The user is journaling, not asking a question. Reply with a single short acknowledgement."
}'
```
Everything after that is standard:
- **Indicators and memories are extracted for you.** Mirobody pulls any quantifiable indicators and durable memories out of the stored entry shortly after the write, independent of the reply.
- **Read back a single entry** with `GET /v1/responses/{id}`. There is **no list endpoint** (OpenAI parity) — keep your own `(entry → response_id)` index. The platform is the data/intelligence layer, not a note-taking app.
- **Delete one entry** with `DELETE /v1/responses/{id}`. With the recommended one-response-per-`session_id` design, that response is the conversation's last live response, so its conversation-derived memory is also retracted. If several responses share a `session_id`, the shared memory is retracted only once you delete all of them.
- **Delete session-scoped working data** with [`DELETE /v1/sessions/{id}`](/en/api-reference/lifecycle#sessions). It does not delete stored response objects.
- **Erase the Subject** with [`DELETE /v1/subjects/{user}`](/en/api-reference/lifecycle#offboarding-a-subject).
- **One nuance**: readings already extracted into the data plane are ordinary records — deleting the journal entry retracts memories but leaves those readings; remove them with [`DELETE /v1/data`](/en/api-reference/data) (by `id` or `indicator`) or the Subject-level wipe.
## Data-plane retention (`retention`)
Health records and files carry their own lifetime, set **where the data is written** — [`POST /v1/data`](/en/api-reference/data) (required), [`POST /v1/files`](/en/api-reference/files) (optional), [`POST /v1/standardize`](/en/api-reference/extract) (required when `store=true`):
| `retention` | Lifetime |
| --- | --- |
| `permanent` (alias `persistent`) | Until explicitly deleted (`DELETE /v1/data`, `DELETE /v1/files/{key}`, `DELETE /v1/subjects/{user}`) |
| `1d` / `6h` / `2h` / `1h` | **Auto-expires after the grain** — expired records disappear from reads immediately and are [then permanently deleted](/en/api-reference/overview#data-retention) |
| `session` | Bound to a `session_id`; purged by [`DELETE /v1/sessions/{id}`](/en/api-reference/lifecycle#sessions) |
There is **no `retention: "none"`** — writing to the store while asking not to store is contradictory. For use-and-forget analysis, run [`POST /v1/standardize`](/en/api-reference/extract) with `store=false` (dry-run, zero side effects), write with `retention: "1h"` (auto-expires), or use `retention: "session"` and delete the session when done.
## Common patterns
| Goal | Settings |
| --- | --- |
| Fully ephemeral one-off | `store: false`; don't write data (or write with `retention: "1h"`, or `retention: "session"` and delete the session after) |
| A user's ongoing assistant thread | `session_id: ` on every turn; data `retention: "permanent"` |
| A user's journal | Single-turn `store: true` + a unique `session_id: journal-{entry_id}` for each entry — see [Journaling](#journaling) |
| Short-lived triage conversation | default `store: true`, chain with `previous_response_id`; working data `retention: "session"` + the same `session_id`; delete the stored response IDs and then `DELETE /v1/sessions/{id}` when done |
| Right to be forgotten | [`DELETE /v1/subjects/{user}`](/en/api-reference/lifecycle#offboarding-a-subject) — everything, including stored conversations; or per-piece: `DELETE /v1/data` / `DELETE /v1/files/{key}` / `DELETE /v1/responses/{id}` |
## See also
- [Agent API (Responses)](/en/api-reference/responses) — the endpoint `store` belongs to.
- [Data Lifecycle](/en/api-reference/lifecycle) — deleting stored responses and Subjects.
- [Structured Records](/en/api-reference/data) — the `retention` set where data is written.
---
# Answers API (Chat Completions)
https://docs.mirobody.ai/en/api-reference/chat
POST /v1/chat/completions — a closed, grounded completion over the Subject's real health data.
import BaseUrl from '/snippets/base-url.mdx';
import SubjectUserParam from '/snippets/subject-user-param.mdx';
## Endpoint
```http
POST /v1/chat/completions
Authorization: Bearer mb_live_*
Content-Type: application/json
```
The **Answers API** is a **closed, grounded completion**: one question in, one evidence-backed answer out. The platform's agent gathers the Subject's real health data with **server-side tools** (you can't add or remove them here), then returns one clean answer plus a traceable record of the tools it used. Set `stream: true` for SSE.
It is deliberately *closed* — no client tools, no response formatting, no multi-turn state. That makes it easy to embed: call it directly, or [wrap it as one tool inside your own agent](/en/api-reference/use-as-a-tool). If you need client-side tools, stored conversations, or `previous_response_id` chaining, use the [Agent API (`POST /v1/responses`)](/en/api-reference/responses).
## Quickstart
```bash curl
curl https://api.mirobody.ai/v1/chat/completions \
-H "Authorization: Bearer $MIROBODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mirobody-flash",
"messages": [{"role": "user", "content": "How is my fasting glucose trending?"}],
"user": "alice"
}'
```
```python Python (OpenAI SDK)
from openai import OpenAI
client = OpenAI(api_key="mb_live_...", base_url="https://api.mirobody.ai/v1")
resp = client.chat.completions.create(
model="mirobody-flash",
messages=[{"role": "user", "content": "How is my fasting glucose trending?"}],
user="alice", # tenant-isolation key (a Subject)
)
print(resp.choices[0].message.content)
# resp.choices[0].message.reasoning_content # provider-supplied reasoning text (may be empty)
# resp.choices[0].message.tool_steps # server tool trace: {id, name, arguments, result}
```
```javascript Node.js (OpenAI SDK)
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.MIROBODY_API_KEY,
baseURL: "https://api.mirobody.ai/v1",
});
const resp = await client.chat.completions.create({
model: "mirobody-flash",
messages: [{ role: "user", content: "How is my fasting glucose trending?" }],
user: "alice",
});
console.log(resp.choices[0].message.content);
```
## Request body
| Field | Type | Description |
| --- | --- | --- |
| `model` | string | `mirobody-flash` (default) or `mirobody-expert`. See [Models](/en/api-reference/models). |
| `messages` | array | OpenAI `{role, content}`. The surface is **stateless** — send the full history each call; a `system` message shapes tone/format (see [System prompts](#system-prompts)). |
| `stream` | bool | `true` → SSE stream of `chat.completion.chunk` frames. |
| `user` | string | Tenant-isolation key → a Subject. Pass each end-user's stable id. See [multi-tenancy](/en/api-reference/overview#multi-tenancy-the-user-field). |
`retention` and `session_id` are not fields on this stateless surface. Set retention when writing [data](/en/api-reference/data), [files](/en/api-reference/files), or readings stored by [`/v1/standardize`](/en/api-reference/extract). For server-side conversation state, use the [Agent API](/en/api-reference/responses).
## Unsupported parameters
This surface is a closed grounded completion — tools run **server-side only**, and the answer is free-form text. Parameters that contradict that are **explicitly rejected with `400`** (`code: unsupported_parameter`, `param` names the offender) rather than silently swallowed:
| Parameter | Result |
| --- | --- |
| `tools`, `functions` | `400 unsupported_parameter` — bring your own tools via the [Agent API](/en/api-reference/function-calling) |
| `tool_choice`, `function_call` | `400 unsupported_parameter` |
| `response_format` | `400 unsupported_parameter` — structured output is not available on this surface |
| `n` > 1 | `400 unsupported_parameter` — always a single choice |
**Sampling parameters are accepted but ignored** (industry norm for agent-backed surfaces): `max_tokens` doesn't cap output; `temperature`, `top_p`, `stop`, and `seed` have no effect. The response always contains a single choice.
**`reasoning_effort` is honored** (`"low" | "medium" | "high"`): it maps to the underlying model's extended-thinking / reasoning budget. The reasoning streams as `delta.reasoning_content` and is included as `message.reasoning_content` in the non-stream response; `usage.completion_tokens_details.reasoning_tokens` counts it. Omit it for the model's default; models without a thinking mode ignore it.
## Response
`message.content` carries **one clean final answer**. Optional provider reasoning text and server tool calls use separate channels, so the answer itself is not mixed with tool narration.
```jsonc
{
"id": "chatcmpl-...",
"object": "chat.completion",
"created": 1783741077,
"model": "mirobody-flash",
"choices": [{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "Your fasting glucose has trended down ~8% over the last 30 days ...",
"reasoning_content": "", // provider-supplied reasoning text; may be empty
"tool_steps": [ // server tool trace, ordered
{ "id": "call_abc", "name": "query_health_data",
"arguments": { "query": "fasting glucose last 90 days" },
"result": "{...}" }
]
}
}],
"usage": {
"prompt_tokens": 12, // YOUR visible input only
"completion_tokens": 210,
"total_tokens": 222,
"prompt_tokens_details": { "system_tokens": 10096 }, // reported separately from visible prompt_tokens
"completion_tokens_details": { "reasoning_tokens": 0 },
"billed_tokens": { "input": 20391, "output": 841, "total": 21232 } // the actual token total you're metered on
},
"health_records": [ { "tool": "query_health_data", "data": "..." } ],
"citations": [ { "tool": "search_medical_evidence", "data": "..." } ]
}
```
| Channel | Content |
| --- | --- |
| `message.content` | Final answer only (the "reply" channel). |
| `message.reasoning_content` | Provider-supplied reasoning text. It may be empty and is not a guarantee of complete internal reasoning. |
| `message.tool_steps[]` | The server tool trace — each `{id, name, arguments, result}`, merged by `id`. **Always returned**; there is no opt-in flag and no truncation switch. |
| `health_records` | Traceable health-data tool outputs the answer used — `{tool, data}` pairs. This is where the explainability lives. |
| `citations` | External-evidence outputs (`search_medical_evidence` / `read_source`) — `{tool, data}` pairs; empty when no evidence was consulted. |
| `usage` | Token accounting — see below. |
`reasoning_content` and `tool_steps` are additive channels — clients that only read `message.content` keep working.
### Usage accounting
`usage.prompt_tokens` reports the input **you actually sent** (your `messages`); the platform's system prompt and tool schemas are reported separately in `prompt_tokens_details.system_tokens`. `completion_tokens_details.reasoning_tokens` reports provider reasoning tokens. Counts are summed across every model call in the agent turn.
`usage.billed_tokens` is the actual token total you're metered on. Multiplying it by the catalog's standard input/output rates gives a cost estimate; prompt-cache discounts can make actual metered cost lower.
## Streaming
With `stream: true`, frames arrive in order. The **first chunk carries `delta.role: "assistant"`** (OpenAI parity — many clients key message init off it), then optional reasoning/tool deltas, then a single uninterrupted answer stream:
```text
data: {"object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":""}}]}
data: {"object":"chat.completion.chunk","choices":[{"delta":{"reasoning_content":""}}]}
data: {"object":"chat.completion.chunk","choices":[{"delta":{"tool_steps":[{"id":"call_abc","name":"query_health_data","arguments":{...}}]}}]}
data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":""}}]}
data: {"object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}],"health_records":[...],"citations":[...],"usage":{...}}
data: [DONE]
```
Merge `tool_steps` by `id` (streamed steps carry `{id, name, arguments}`; full `result` payloads are available on the non-stream response). The **final** frame (before `[DONE]`) carries the top-level `health_records` / `citations` **and `usage`**. An upstream failure arrives as an SSE `{"error": ...}` frame before the stream ends. Clients that only read `delta.content` keep working.
## System prompts
A `system` message sets the tone, persona, format, and language of the answer. Server-tool availability and Subject scoping remain platform-controlled; the prompt cannot grant access to another Subject or add tools.
## Built-in tools
Beyond plain tool-calling, the hosted agent can **plan** multi-step work, **delegate** subtasks, run **sandboxed code** for numeric analysis (trends, correlations), and read the Subject's uploaded files. You don't invoke any of this — the agent does, against the Subject's data.
The tools the hosted agent can call:
- `query_health_data` — search and aggregate the Subject's records
- `list_clinical_records` — list clinical documents / FHIR-backed records
- `list_family_members` — resolve care-circle members the Subject is allowed to query
- `search_medical_evidence` — search literature, guidelines/consensus, and registered trials (feeds `citations`)
- `read_source` — read one result by its returned ref; arbitrary URL fetching is not supported
The `/v1` agent's main loop is read-only. Health facts mentioned in stored conversations are extracted automatically (see [State & memory](/en/api-reference/state-and-memory)), not written by a tool during the answer. Planning, delegation, and code-analysis tools are internal and not part of the API contract. `ask_user` serves the web app but is **disabled on the `/v1` surfaces** because an API caller has no widget to answer it. Files uploaded through [`/v1/files`](/en/api-reference/files) become readable to the agent once processed.
To add **your own** tools, use the [Agent API's function calling](/en/api-reference/function-calling) — this surface intentionally has no tool injection.
## Charts (vis-chart)
When the answer involves a trend / comparison / distribution, it may embed a fenced ` ```vis-chart ` block of pure-data JSON (`{type, title, axisXTitle, axisYTitle, data}`). **Rendering is the client's job** — the API only returns the data spec. Detect the fenced block and render it (line / area / bar / pie).
## See also
- [Agent API (Responses)](/en/api-reference/responses) — the full agent surface, with your own tools and stored state.
- [Use the Answers API as a Tool](/en/api-reference/use-as-a-tool) — wrapping this endpoint as one tool inside your own agent.
- [Streaming](/en/api-reference/streaming) — the SSE frames this endpoint emits.
---
# Use the Answers API as a Tool
https://docs.mirobody.ai/en/api-reference/use-as-a-tool
Cookbook: wrap the Answers API as one tool inside your own agent (openai-agents SDK / LangChain).
The [Answers API](/en/api-reference/chat) is a **closed grounded completion** — question in, evidence-backed answer out, no knobs. That shape is exactly what an outer agent wants in a tool: your agent (running on any model, any framework) keeps orchestration and delegates *"what do this user's real health records say?"* to Mirobody.
When to prefer this over the [Agent API](/en/api-reference/responses): you already **have** an agent and need grounded health answers as one capability inside it. When you want Mirobody to *be* the agent (and call **your** tools), use the [Agent API](/en/api-reference/function-calling) instead.
## openai-agents SDK
Your outer agent runs on OpenAI (or any Responses-compatible backend); the tool body calls Mirobody:
```python
from agents import Agent, Runner, function_tool
from openai import OpenAI
mirobody = OpenAI(
api_key="mb_live_...",
base_url="https://api.mirobody.ai/v1",
)
@function_tool
def health_answers(question: str, user_id: str) -> str:
"""Answer a question from this end user's REAL health records
(labs, vitals, reports). Grounded and citation-backed — use it for
anything about the user's own health data."""
resp = mirobody.chat.completions.create(
model="mirobody-flash",
messages=[{"role": "user", "content": question}],
user=user_id, # the end user's stable id (Subject)
)
return resp.choices[0].message.content
coach = Agent(
name="Wellness coach",
model="gpt-4.1", # your model, your orchestration
instructions=(
"You are a wellness coach. For anything about the user's own labs, "
"vitals or reports, call health_answers with their user_id — never guess."
),
tools=[health_answers],
)
result = Runner.run_sync(coach, "user_id=alice — Should I be worried about my recent glucose?")
print(result.final_output)
```
The outer model decides *when* health data is needed; Mirobody's agent does the record search, trend math, and evidence citation inside a single tool call.
## LangChain
```python
from langchain_core.tools import tool
from langchain.agents import create_agent # LangChain v1 agent API
from openai import OpenAI
mirobody = OpenAI(
api_key="mb_live_...",
base_url="https://api.mirobody.ai/v1",
)
@tool
def health_answers(question: str, user_id: str) -> str:
"""Answer a question from this end user's real health records
(labs, vitals, reports). Grounded, with traceable evidence."""
resp = mirobody.chat.completions.create(
model="mirobody-flash",
messages=[{"role": "user", "content": question}],
user=user_id,
)
return resp.choices[0].message.content
agent = create_agent(model="openai:gpt-4.1", tools=[health_answers])
out = agent.invoke({"messages": [
{"role": "user", "content": "user_id=alice — how did my LDL respond to the diet change?"}
]})
print(out["messages"][-1].content)
```
## Tips
- **Thread the Subject id.** The `user` param is the isolation key — if your framework supports per-call context injection, take it from your own auth context rather than letting the model free-type it.
- **Return evidence too.** If your outer agent should show sources, include the response's `health_records` / `citations` extensions in the tool's return value (serialize them alongside `content`).
- **Timeouts.** A grounded answer runs a real agent turn (seconds, not milliseconds) — give the tool call a generous timeout and stream your outer agent's narration meanwhile.
- **Cost control.** `mirobody-flash` is the right default inside a tool; reserve `mirobody-expert` for deep report interpretation. See [Models](/en/api-reference/models).
## See also
- [Answers API (Chat Completions)](/en/api-reference/chat) — the endpoint being wrapped.
- [SDK Examples](/en/api-reference/sdk-examples) — the same call in curl, Python and Node.
- [Choose Your API](/en/api-reference/choose-your-api) — when to reach for the Agent API instead.
---
# Regions Overview
https://docs.mirobody.ai/en/api-reference/regions/overview
Pick the cluster that matches your users
Every code sample in these docs uses the global production `base_url`. Regional environments have separate hosts and storage; use only the endpoint enabled for your account and contract.
| Cluster | API base URL | Console | Web app | Status |
| --- | --- | --- | --- | --- |
| [🌐 Global](/en/api-reference/regions/global) | `https://api.mirobody.ai/v1` | [platform.mirobody.ai](https://platform.mirobody.ai/) | [chat.mirobody.ai](https://chat.mirobody.ai/) | Live |
| [🇨🇳 China](/en/api-reference/regions/china) | `https://api.mirobody.cn/v1` | [platform.mirobody.cn](https://platform.mirobody.cn/) | [chat.mirobody.cn](https://chat.mirobody.cn/) | Live |
| 🇯🇵 Japan | — | — | — | In preparation |
| 🇪🇺 EU | — | — | — | In preparation |
Call the cluster whose data-processing location fits your users: the China hosts process data in the China-region environment, the global hosts in the global environment. Model providers and pricing differ by region — read `GET /v1/models` from the cluster you call. See [Privacy & Compliance](/en/api-reference/compliance).
## See also
- [Global Cluster](/en/api-reference/regions/global) — hosts and cluster details.
- [China Region](/en/api-reference/regions/china) — hosts and cluster details.
- [Privacy & Compliance](/en/api-reference/compliance) — where data is processed, and the paperwork.
---
# China Region
https://docs.mirobody.ai/en/api-reference/regions/china
China production endpoints and regional differences.
The China region is live, with its own hosts and storage.
## Hosts
| Role | URL |
| --- | --- |
| API (`base_url`) | `https://api.mirobody.cn/v1` |
| Developer console | `https://platform.mirobody.cn/` |
| Web app | `https://chat.mirobody.cn/` |
## API contract
The China deployment exposes the same stable `/v1` resources as the global cluster: Models, Answers, Agent, Data, Standardize, Files, Sessions, and Subjects. Model providers and pricing differ by region, so clients must read [`GET /v1/models`](/en/api-reference/models) from the cluster they call.
## Choosing a region
Call the cluster whose data-processing location fits your users: `https://api.mirobody.cn/v1` processes data in the China-region environment, and `https://api.mirobody.ai/v1` in the global environment.
See [Privacy & Compliance](/en/api-reference/compliance) for the controls to review before sending regulated data.
## See also
- [Regions Overview](/en/api-reference/regions/overview) — every cluster and its status.
- [Global Cluster](/en/api-reference/regions/global) — the other production cluster.
- [Privacy & Compliance](/en/api-reference/compliance) — regional processing and the compliance package.
---
# Global Cluster
https://docs.mirobody.ai/en/api-reference/regions/global
api.mirobody.ai — the primary cluster.
The **global cluster on `mirobody.ai`** is Mirobody's primary environment.
## Hosts
| Role | URL |
| --- | --- |
| **API** (`base_url`) | `https://api.mirobody.ai/v1` |
| **Developer console** | [platform.mirobody.ai](https://platform.mirobody.ai/) |
| **Web app** | [chat.mirobody.ai](https://chat.mirobody.ai/) |
## Cluster info
- **LLM providers**: managed by Mirobody (`mirobody-flash` / `mirobody-expert`) — no LLM key of your own needed.
- **Regional processing**: global environment. Confirm contractual data-residency requirements before sending regulated data.
- **Privacy controls**: Subject isolation, explicit retention, and self-service deletion — see [Privacy & Compliance](/en/api-reference/compliance).
## See also
- [Regions Overview](/en/api-reference/regions/overview) — every cluster and its status.
- [China Region](/en/api-reference/regions/china) — the China production cluster.
- [Models](/en/api-reference/models) — the tiers served here.
---
# SDK Examples
https://docs.mirobody.ai/en/api-reference/sdk-examples
End-to-end /v1 examples in curl / Python / Node.js — Answers, Agent (Responses), data, files.
Minimal end-to-end examples against Mirobody Cloud.
## curl
```bash
BASE=https://api.mirobody.ai/v1
KEY="mb_live_..."
# 1) Grounded answer (Answers API)
curl $BASE/chat/completions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"model":"mirobody-flash","messages":[{"role":"user","content":"How is my fasting glucose trending?"}],"user":"alice"}'
# 2) Write a record (retention is REQUIRED), then ask again
curl $BASE/data \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"user":"alice","retention":"permanent","records":[{"indicator":"fasting_glucose","value":97,"unit":"mg/dL","time":"2026-06-16T07:30:00Z"}]}'
# 3) Upload a report. The original is stored immediately; its extracted text follows shortly after.
curl $BASE/files -H "Authorization: Bearer $KEY" -F "user=alice" -F "file=@report.pdf"
# 4) Agent API (Responses)
curl $BASE/responses \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"model":"mirobody-flash","input":"How is my fasting glucose trending?","user":"alice"}'
```
## Python (OpenAI SDK)
```python
from openai import OpenAI
client = OpenAI(api_key="mb_live_...", base_url="https://api.mirobody.ai/v1")
# Answers API — non-streaming
resp = client.chat.completions.create(
model="mirobody-flash",
messages=[{"role": "user", "content": "How is my fasting glucose trending?"}],
user="alice",
)
print(resp.choices[0].message.content)
# Answers API — streaming
stream = client.chat.completions.create(
model="mirobody-flash",
messages=[{"role": "user", "content": "Summarize my last checkup."}],
user="alice",
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta
if getattr(delta, "content", None):
print(delta.content, end="", flush=True)
# Agent API (Responses) — stored by default, chainable
r1 = client.responses.create(model="mirobody-flash",
input="How is my fasting glucose trending?", user="alice")
print(r1.output_text)
r2 = client.responses.create(model="mirobody-flash",
input="And compared with last quarter?",
previous_response_id=r1.id, user="alice")
print(r2.output_text)
```
```python
# File upload uses multipart, so call the REST endpoint directly.
# status="processed" means the original was accepted, not that text extraction is finished.
import requests
up = requests.post(
"https://api.mirobody.ai/v1/files",
headers={"Authorization": "Bearer mb_live_..."},
data={"user": "alice"},
files={"file": open("report.pdf", "rb")},
)
print(up.json()) # {"object": "file", "id": "...", "filename": "report.pdf",
# "bytes": 20544, "status": "processed", "created_at": 1782924296,
# "subject": "alice"}
```
## openai-agents SDK (your tools + Mirobody's agent)
The [openai-agents SDK](https://github.com/openai/openai-agents-python) works against the [Agent API](/en/api-reference/responses) by changing only the base URL:
```python
from agents import Agent, ModelSettings, Runner, function_tool, set_default_openai_client, set_tracing_disabled
from openai import AsyncOpenAI
set_default_openai_client(AsyncOpenAI(
base_url="https://api.mirobody.ai/v1", api_key="mb_live_..."))
set_tracing_disabled(True) # tracing would call api.openai.com
@function_tool
def book_appointment(date: str) -> str:
"""Book a clinic appointment for the end user (ISO date)."""
return f"Booked: {date} 09:30, Dr. Chen"
agent = Agent(name="Health assistant", model="mirobody-flash", tools=[book_appointment],
model_settings=ModelSettings(extra_body={"user": "alice"}))
result = Runner.run_sync(agent, "Check my recent glucose and book a follow-up if it's trending up.")
print(result.final_output)
```
See [Function calling](/en/api-reference/function-calling) for the underlying handoff protocol.
## Node.js (OpenAI SDK)
```javascript
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.MIROBODY_API_KEY,
baseURL: "https://api.mirobody.ai/v1",
});
// Answers API — non-streaming
const resp = await client.chat.completions.create({
model: "mirobody-flash",
messages: [{ role: "user", content: "How is my fasting glucose trending?" }],
user: "alice",
});
console.log(resp.choices[0].message.content);
// Answers API — streaming
const stream = await client.chat.completions.create({
model: "mirobody-flash",
messages: [{ role: "user", content: "Summarize my last checkup." }],
user: "alice",
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
// Agent API (Responses)
const r = await client.responses.create({
model: "mirobody-flash",
input: "How is my fasting glucose trending?",
user: "alice",
});
console.log(r.output_text);
```
More: [Answers API](/en/api-reference/chat) · [Agent API](/en/api-reference/responses) · [Structured Records](/en/api-reference/data) · [Files / Photos](/en/api-reference/files) · [Standardize](/en/api-reference/extract).
## See also
- [Quickstart](/en/api-reference/quickstart) — the same calls with the setup around them.
- [Agent API (Responses)](/en/api-reference/responses) — the request and response objects in full.
- [Use the Answers API as a Tool](/en/api-reference/use-as-a-tool) — calling Mirobody from inside another agent.
---
# Rate Limits & Quota
https://docs.mirobody.ai/en/api-reference/rate-limits
Per-key request rate limiting and the per-account usage quota.
Two independent controls protect the platform and your spend. Both return an
OpenAI-compatible error envelope so the standard SDKs surface them cleanly.
## Request rate limit (per key)
Each API key has a **requests-per-minute** setting (60 by default). The same limit applies across authenticated `/v1` endpoints; it is not a separate allowance for each path.
On overflow you get HTTP `429` with `Retry-After` plus `X-RateLimit-*` headers:
```text
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 12
```
```json
{"error": {"message": "Rate limit exceeded: this API key allows 60 requests per minute. Retry after 12s.",
"type": "rate_limit_error", "code": "rate_limit_exceeded"}}
```
Treat the per-key limit as a floor — brief bursts slightly above it may succeed, so don't rely on it as an exact ceiling. Contact [Mirobody Support](mailto:developer@thetahealth.ai)
to raise a key's limit.
## Monthly account cap
Hosted environments can enforce a **monthly account cap** in USD. Once it is
enabled, authenticated `/v1` requests return HTTP `429` with `insufficient_quota`
as soon as the current month's metered usage reaches the cap — until the next
calendar month, or until support raises it:
```json
{"error": {"message": "You have reached your $5 monthly API usage limit (billed $5.00 this month). It resets next month; contact support to raise it.",
"type": "insufficient_quota", "code": "insufficient_quota"}}
```
The cap covers **all keys under the account together**. The error message reports
the configured cap and the current-month metered amount. To confirm or change your
account's cap, contact [Mirobody Support](mailto:developer@thetahealth.ai).
## Client pacing
Keep the combined request rate for one key below its configured RPM. Streaming calls count when the request starts. Batch structured records into one `POST /v1/data` request (up to 500 records), and don't poll an uploaded file in a tight loop before its text is ready.
## Client backoff
- On `429 rate_limit_exceeded`, **respect `Retry-After`** — never retry immediately; double the backoff after two consecutive hits, up to 5 minutes.
- On `429 insufficient_quota`, do **not** retry — the cap resets monthly or on a support change; surface it to the operator.
## See also
- [Models](/en/api-reference/models) — per-tier capacity and price.
- [API Overview](/en/api-reference/overview) — the error envelope these limits return.
- [Streaming](/en/api-reference/streaming) — how a stream ends when a limit is hit.
---
# Privacy & Compliance
https://docs.mirobody.ai/en/api-reference/compliance
Regional isolation, data controls, and security-review resources.
Mirobody's platform is designed around HIPAA and US health-data protection principles — access control, encryption, isolation, retention limits, and erasure controls. It runs in separate regional environments: the global and China clusters are live, and Japan and EU clusters are in preparation. See [Regions](/en/api-reference/regions/overview) before choosing a base URL.
This page describes the product controls the API exposes and the regulatory frameworks that may apply. It is not a certification claim: your obligations depend on your use case, contract, data flows, and region.
## Product controls
### Subject isolation
Every authenticated `/v1` request is scoped to the API-key owner. Within that account, the `user` field resolves to an isolated Subject. A developer cannot use an API key to read another account's Subjects.
Use a stable, non-sensitive identifier for `user`; do not put an email address, medical record number, or other direct identifier in the field unless your data policy requires it.
### API credentials
API keys use the `mb_live_*` format. The full secret is returned only when the key is created; the service stores a hash, and the developer console then shows only the key prefix. Revoke a key immediately if it may have been exposed.
### Retention and deletion
The API exposes explicit controls for each storage surface:
| Scope | Control |
| --- | --- |
| Structured records | `DELETE /v1/data`; timed retention (`1h`, `2h`, `6h`, `1d`) |
| Files | `DELETE /v1/files/{file_key}`; timed or session retention |
| Stored responses | `DELETE /v1/responses/{id}` |
| Session-scoped data and files | `DELETE /v1/sessions/{id}` |
| Entire Subject | [`DELETE /v1/subjects/{user}`](/en/api-reference/lifecycle#offboarding-a-subject) |
`DELETE /v1/sessions/{id}` does **not** delete stored response objects. For the exact behavior of every deletion path, see [Data Lifecycle](/en/api-reference/lifecycle) and [State & Memory](/en/api-reference/state-and-memory).
### Traceable outputs
Agent responses can include `tool_steps`, `health_records`, and `citations` so applications can inspect the tool calls and evidence returned for an answer. These fields are an execution trace, not a guarantee that private model reasoning or every internal operation is exposed.
On the Agent API, `store:false` prevents the response object and conversation thread from being retained after the request. It also keeps that turn from contributing to the Subject's stored memory.
### Regional processing
The regional environments use separate hosts and storage. Data sent to `api.mirobody.ai/v1` is processed in the global environment; data sent to `api.mirobody.cn/v1` is processed in the China-region environment. Do not switch regions for the same Subject unless your own compliance review permits the resulting data transfer.
## Regulatory review
For a US healthcare workload, review your obligations under the [HIPAA Privacy and Security Rules](https://www.hhs.gov/hipaa/index.html) and confirm whether a Business Associate Agreement (BAA) is required for your deployment. Product capability alone does not make a workload HIPAA-eligible — the applicable agreements and your own safeguards must also be in place.
If you use the **China-region** deployment (`api.mirobody.cn`), your workload may additionally be subject to Chinese data-protection law:
| Law | Official text |
| --- | --- |
| Personal Information Protection Law (PIPL) | [National People's Congress](http://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html) |
| Data Security Law | [State Council](https://www.gov.cn/xinwen/2021-06/11/content_5616919.htm) |
| Cybersecurity Law | [State Council](https://www.gov.cn/xinwen/2016-11/07/content_5129723.htm) |
## Security and compliance package
For a security questionnaire, architecture review, data-processing terms, or the current availability of a DPA, BAA, or independent assessment, contact [Mirobody Support](mailto:developer@thetahealth.ai). Obtain the applicable documents before sending regulated production data.
## See also
- [Data Lifecycle](/en/api-reference/lifecycle) — retention, session cleanup and Subject offboarding.
- [API Overview](/en/api-reference/overview) — the `user` field and how tenants are isolated.
- [Regions Overview](/en/api-reference/regions/overview) — where each cluster processes data.
---
# Self-Host Mirobody
https://docs.mirobody.ai/en/self-host
Run the Mirobody engine on your own infrastructure: what you get, the three stages, how the data flows, and how to extend it.
Run Mirobody on your own machine, server, or cloud instance. The engine is open source
under Apache 2.0 — a Python package (3.12+) usable as a library, plus an HTTP server, a
background worker, PostgreSQL with pgvector, and Redis when you want the full capability.
The source is at [thetahealth/mirobody](https://github.com/thetahealth/mirobody).
For what Mirobody is and the problem it solves, start at
[Introduction to Mirobody](/en/api-reference). This page is about running it yourself.
## The three stages in the engine
The code layout, the contribution areas and this documentation all follow the same three stages — **C · S · A**, where the A is both **Answers** and the **agent** that produces them.
| | Stage | What it means | Where in the package |
| --- | --- | --- | --- |
| ① | **Collect** | Take data in from device providers, uploaded documents and on-device batches | `mirobody/pulse/` |
| ② | **Standardize** | Resolve readings to canonical codes (LOINC · SNOMED CT · RxNorm), normalize units, store as FHIR R4 | `mirobody/indicator/` |
| ③ | **Answers** | An agent reads the original documents through a virtual filesystem and answers with charts and citations. The same standardized series also drives the derived uses: scheduled insights, a drafted report, or the alerts you build on top | `mirobody/agent/` |
Providers, file parsing and on-device batch imports, with every source landing in one indicator table.
A concept graph and multilingual aliases put every spelling of a test on the same code.
Two agents share one MCP tool surface; the tool loop can run locally or in an external model. Answering is one use of the standardized data, not the only one.
## The standardization mechanism
The same measurement is written differently by every source that produces it, with its own units and reference ranges — differences that are enough to stop one person's own history from being compared against itself. That is the layer Mirobody occupies: an indicator name is resolved **before** the reading is stored, rather than kept as the string it arrived as. `血红蛋白`, `ヘモグロビン` and `hemoglobin` all resolve to LOINC `718-7`; `LDL-C` and 低密度脂蛋白胆固醇 both resolve to LOINC `13457-7`. Units are normalized to UCUM in the same pass, so `毫摩尔每升` and `mmol/L` are recorded as one unit. The result is that reports from different devices, laboratories and languages can be placed on a single time series.
Resolution runs locally against vocabularies shipped inside the package — no key, no configuration, and no network connection:
```bash
pip install mirobody
mirobody resolve "LDL cholesterol" "血红蛋白" "ヘモグロビン"
```
```python
from mirobody.engine import resolve
resolve("血红蛋白").loinc # -> '718-7'
```
Standardization is installable on its own, so this stage can be adopted without running the rest of the services. The resolution rules and the shipped vocabularies are described in [Health Indicators](/en/concepts/indicators); the library API is in [The Engine as a Library](/en/engine).
## Data flow
Providers and on-device batches converge on `StandardPulseData` before anything is written; file extraction writes straight to the store, keeping the report's own indicator names for semantic search to reconcile. Both routes land in the same indicator table, which is why the agent queries indicators rather than one shape per vendor. The write path, the two tables behind it and the aggregation that follows are described in [Data Flow](/en/concepts/data-flow).
## Where the tool loop runs
There is an agent for each, and the difference is **who runs the tool loop**.
| | **DeepAgent** — you run the engine | **BaseAgent** — an external model calls in |
| --- | --- | --- |
| Tool loop runs | here, in your deployment | in the LLM provider, against `/mcp` over HTTP |
| For | running the whole engine yourself | Claude Desktop · Cursor · ChatGPT Apps · any MCP client |
| Extras | virtual filesystem, QuickJS, Agent Skills, charts | whatever the MCP tool surface exposes |
The engine mints a **per-user MCP URL** on request — `POST /personal/mcp` — so one person's MCP client reaches only their own data. The bundled web client offers it under Settings; your own client can call the endpoint directly. See [Agent Types](/en/tools/agents) and [MCP Integration](/en/tools/mcp-integration).
## Extension directories
Five configuration keys each point at a set of directories, which the engine scans at startup. Extending it requires no compile step and no entry in any registry.
| Key | Default | What goes there |
| --- | --- | --- |
| `MCP_TOOL_DIRS` | `mirobody/agent/tools` | Tool modules: a function or a `*Service` class becomes an MCP tool |
| `MCP_RESOURCE_DIRS` | `mirobody/agent/resources` | MCP UI resources |
| `AGENT_DIRS` | `mirobody/agent` | Agent implementations |
| `PROVIDER_DIRS` | `mirobody/pulse/providers` | Data providers, one `mirobody_/` package per source |
| `SKILL_DIRS` | `mirobody/agent/skills` | Agent Skills: a directory holding one `SKILL.md` |
Add your own directory in `config.{env}.yaml` and place it first in the list to take precedence over the packaged one. See [Adding Custom Tools](/en/tools/adding-tools), [Agent Skills](/en/tools/skills) and [Building a Provider](/en/development/provider-integration).
## Deployments and surfaces
Self-hosting is one of [two ways to run Mirobody](/en/api-reference#choose-how-you-run-mirobody), and each comes with finished surfaces, not only an API:
| Surface | Address | What it is |
| --- | --- | --- |
| **Your own deployment** | `localhost:18080` | `git clone` → `./deploy.sh` → sign in. Your data stays on your machine, and the bundled web client is a full app: `/data` for documents and readings, `/ask` for the agent, plus **Care Circle** sharing: a member can let family — or a clinician — ask the AI using their records. |
| **Your MCP endpoint** | `localhost:18080/mcp` | For Claude Desktop / Cursor. Set `MCP_PUBLIC_URL` for HTTPS and remote clients. |
| **Hosted chat** | [chat.mirobody.ai](https://chat.mirobody.ai/) | The client we operate. |
| **Mirobody Cloud** | [platform.mirobody.ai](https://platform.mirobody.ai/) | Keys, usage, and the health-data API for building on top; see the [Cloud](/en/api-reference) tab. |
| **WeChat miniprogram** | search **mirobody** in WeChat | China only, Chinese interface. Same backend, same records and indicators as the hosted chat client. |
## Next steps
Follow the [Quickstart](/en/quickstart): clone, `./deploy.sh`, sign in.
Add an LLM key **and** an embedding key in `config.{env}.yaml`; see [Configuration](/en/configuration). They are two different keys, and the second one is why indicators stay at 0 when it is missing.
Read [The Engine as a Library](/en/engine) for the install layers, then the [Architecture Overview](/en/concepts/architecture).
Add a tool with [Adding Custom Tools](/en/tools/adding-tools), or a data source with [Building a Provider](/en/development/provider-integration).
## Contributing and support
Contributions are organized around the same three stages. The lowest-barrier kind is **making an unresolved indicator name resolve**: check it with `mirobody resolve ""`, add a mapping to `resolver_overrides.tsv`, and add a case to `test_engine_coverage.py`.
Source code, issues, and pull requests
The three areas, and how to submit changes
A lab report that parses incorrectly makes a valuable issue; please attach the de-identified sample
Direct technical contact
---
# Quickstart
https://docs.mirobody.ai/en/quickstart
Clone Mirobody, run ./deploy.sh, sign in at localhost:18080, and add an LLM key.
One path from an empty directory to a signed-in Mirobody: clone, run `./deploy.sh`, open the browser, sign in with a demo account, then add an LLM key. Everything runs in Docker, so you do not need Python or Node on the host for this page.
For a local Python development setup, or for `pip install mirobody`, see [Installation](/en/installation).
## Prerequisites
| Requirement | Why |
| ----------- | --- |
| **Docker** + Docker Compose | `deploy.sh` builds one image and starts four containers. |
| **Git** | To clone the repository. |
| **Git LFS** | Terminology and indicator resources under `mirobody/res/` are LFS objects. Without LFS you get pointer files and startup fails. |
## 1. Clone the repository
Install Git LFS **before** cloning: `apt install git-lfs`, `brew install git-lfs`, or bundled with Git for Windows.
```bash
git lfs install # once per machine
git clone https://github.com/thetahealth/mirobody.git
cd mirobody
```
## 2. Run the deploy script
```bash
./deploy.sh
```
The script does four things:
Sets `ENV=localdb` and generates a 32-character `CONFIG_ENCRYPTION_KEY`. Existing files are left alone.
Your override file, seeded with a random `JWT_KEY`, the demo login codes, and commented-out placeholders for the LLM keys.
An Ubuntu 24.04 image with a Python virtualenv and Node.js. If `hub.docker.com` is unreachable the script falls back to the `docker.1ms.run` mirror, and npm is pointed at `registry.npmmirror.com`.
`docker compose up -d`, then tails the logs in the foreground. Four containers come up: `pg` (18082), `redis` (18089), `mirobody` (18080), and `mirobody_worker`.
The script ends on `docker compose logs -f`, so it does not return. Press `Ctrl-C` to detach (the containers keep running), or open a second terminal for the commands below.
## 3. Open the web client
Open [http://localhost:18080](http://localhost:18080). The engine serves a prebuilt web client from the `frontend/` directory next to the process; it lives outside the Python package on purpose, so a wheel ships the engine rather than 8 MB of JavaScript.
## 4. Sign in
`deploy.sh` writes three predefined accounts into `config.localdb.yaml`. Use any of them, with the code as the verification code:
```text
demo1@mirobody.ai
777777
```
Which accounts work depends on **how you started the server**, and the two paths disagree. `config.{env}.yaml` replaces a top-level key rather than merging into it, so the block `deploy.sh` generates overrides the template's:
| Started with | Accounts | Code |
| --- | --- | --- |
| `./deploy.sh` (this page) | `demo1@mirobody.ai` · `demo2@` · `demo3@` | `777777` |
| your own config, no `EMAIL_PREDEFINE_CODES` override | `exp1@mirobody.ai` · `exp2@` · `exp3@` | `111111` |
The server prints the accounts it actually accepted at startup; trust that over any document.
These codes are public and are meant for a local run. Before you expose the server to a network, configure real email or OAuth sign-in and turn the predefined codes off.
Deleting the block from `config.localdb.yaml` is not enough: the `config.yaml` template carries its own predefined accounts (`exp1@` … `exp3@`), and it loads first. Override the key with an empty value instead:
```yaml config.localdb.yaml
EMAIL_PREDEFINE_CODES:
```
## 5. Add an LLM key
You are signed in, but the agent has no model to call yet. Edit `config.localdb.yaml` and set at least one key:
```yaml config.localdb.yaml
# Pick whichever provider you have a key for. One is enough to start.
OPENROUTER_API_KEY: 'sk-or-...'
# GOOGLE_API_KEY: 'AIza...'
# OPENAI_API_KEY: 'sk-...'
```
Then restart the two application containers:
```bash
docker compose restart mirobody mirobody_worker
```
On the next startup the engine rewrites that value in place as ciphertext: any key whose **name** matches `_KEY`, `_PASSWORD`, `_PASS`, `_PWD`, `_SECRET`, `_SK` or `_TOKEN` is encrypted with the `CONFIG_ENCRYPTION_KEY` from `.env`. That is expected; do not paste the plaintext back. See [Configuration](/en/configuration).
Which models each agent may use is set per agent under `PROVIDERS_DEEP` and `PROVIDERS_BASE`; the defaults in `config.yaml` already reference the key names above.
### The embedding key and zero indicators
An LLM key is not enough. The worker's indicator sync **embeds** indicator names to standardize them, and that is a different key:
```yaml config.localdb.yaml
EMBEDDING_PROVIDER: gemini # the default
GOOGLE_API_KEY: 'AIza...' # what `gemini` needs
# EMBEDDING_PROVIDER: qwen # the other supported provider
# DASHSCOPE_API_KEY: 'sk-...'
```
With only an OpenRouter key, chat works but **Health indicators stays 0**: the embedding call fails in the worker, and it fails quietly. `docker compose logs mirobody_worker` is where it says so.
## 6. Verify the install
```bash
# All four containers up?
docker compose ps
# Liveness — returns JSON with the service name, version, and counts of
# tools, resources and agents that were discovered at startup.
curl http://localhost:18080/api/health
# MCP discovery — JSON-RPC 2.0 over POST, lists the registered tools.
curl -X POST http://localhost:18080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
If `/api/health` reports `"tools": 0`, the tool directories were not scanned; check `MCP_TOOL_DIRS` and the startup log (`docker compose logs mirobody`).
## Next steps
The three config layers, the key groups, and what is encrypted.
Local Python development, the PyPI package, and the repository layout.
How the server, worker, agents and Pulse fit together.
Point Claude Desktop, Cursor or any MCP client at this server.
---
# Installation
https://docs.mirobody.ai/en/installation
Three ways to install the Mirobody Python engine: Docker Compose, a local Python environment, or the PyPI package.
Mirobody is a Python application: a Starlette/FastAPI HTTP server plus a background worker, on PostgreSQL (with pgvector) and Redis. There are three ways to install it.
One command, four containers. The path the [Quickstart](/en/quickstart) takes.
Run the engine from source, keep the database and cache in Docker.
`pip install mirobody` and embed it in your own service.
## Prerequisites
| Requirement | Needed for | Notes |
| ----------- | ---------- | ----- |
| **Python ≥ 3.12** | Local development, PyPI package | `requires-python = ">=3.12"`. The Docker image brings its own interpreter. |
| **Docker** + Docker Compose | All three paths | Even the local-Python path uses Docker for PostgreSQL and Redis. |
| **Git** + **Git LFS** | Cloning the repository | `mirobody/res/` holds the terminology bundles and indicator resources as LFS objects. Run `git lfs install` once before cloning. |
## Docker Compose
```bash
git lfs install
git clone https://github.com/thetahealth/mirobody.git
cd mirobody
./deploy.sh
```
[`deploy.sh`](https://github.com/thetahealth/mirobody/blob/main/deploy.sh) does four things, all idempotent (existing files and an unchanged image are left alone):
`ENV` (defaults to `localdb`) and a generated 32-character `CONFIG_ENCRYPTION_KEY`.
Seeded with a random `JWT_KEY`, the demo login codes, and commented placeholders for the LLM keys and `MCP_PUBLIC_URL`.
An inline Dockerfile on `ubuntu:24.04` with a Python virtualenv (there is no `Dockerfile` in the repository, and no Node.js — the engine has no JavaScript dependency at runtime). If `hub.docker.com` is unreachable, images come from the `docker.1ms.run` mirror.
Brings the previous stack down, frees ports 18080 / 18082 / 18089, then `docker compose up -d --remove-orphans` and tails the logs.
Four services come up, defined in [`compose.yaml`](https://github.com/thetahealth/mirobody/blob/main/compose.yaml):
| Service | Image | Host port | Role |
| ------- | ----- | --------- | ---- |
| `pg` | `pgvector/pgvector:pg17-trixie` | **18082** → 5432 | PostgreSQL with pgvector |
| `redis` | `redis:7.0-alpine` | **18089** → 6379 | Cache and task queues |
| `mirobody` | built locally | **18080** | HTTP server: `python -m mirobody serve` |
| `mirobody_worker` | same image | — | Background tasks: `python -m mirobody worker` |
The containers sit on a fixed bridge network, `10.108.0.0/24`, which is why `config.yaml` ships `PG_HOST: 10.108.0.2` and `REDIS_HOST: 10.108.0.9`. The repository is bind-mounted into `/app`, so editing a `.py` file or `config.{env}.yaml` on the host and restarting the container is enough; no rebuild.
```bash
docker compose ps # what is running
docker compose logs -f mirobody # server log
docker compose restart mirobody mirobody_worker # pick up a config change
docker compose down # stop everything
```
On the first start against an empty database the server creates the schema and applies the SQL under `mirobody/schema/` itself; there is no separate migration step.
## Local Python development
Run the engine from source while PostgreSQL and Redis stay in Docker.
```bash
docker compose up -d pg redis
```
```bash
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install --upgrade pip
pip install -e '.[agents]' # engine only: pip install -e .
```
`pip install -e .` gets the engine: ① Collect and ② Standardize as a library. The chat server, the MCP endpoint and the agents live in the `[agents]` extra, which pulls `[server]` in with it. See [The Engine as a Library](/en/engine).
`ENV` must be set; the server reads it on startup to pick the config file.
```bash
echo "ENV=localdb" > .env
echo "CONFIG_ENCRYPTION_KEY=$(openssl rand -hex 16)" >> .env
```
The defaults in `config.yaml` are the container addresses on the compose network. A process on the host reaches the same services through the published ports, and needs a port of its own: outside Docker, `HTTP_PORT` falls back to `80`.
```yaml config.localdb.yaml
HTTP_PORT: 18080
PG_HOST: 127.0.0.1
PG_PORT: 18082
REDIS_HOST: 127.0.0.1
REDIS_PORT: 18089
```
```bash
mirobody serve # HTTP server
mirobody worker # background worker, in a second terminal
```
`python -m mirobody serve` is the same thing, and is what the containers run.
Both commands take config filenames as arguments; with none, they fall back to `config.yaml` plus `config.{env}.yaml` from the working directory. Without the `[agents]` extra they exit with a one-line message naming what to install, rather than a `ModuleNotFoundError` from deep inside an import chain.
### Optional extras
| Extra | Install | Pulls in |
| ----- | ------- | -------- |
| `server` | `pip install -e ".[server]"` | FastAPI, uvicorn, `psycopg`, SQLAlchemy, Redis, aioboto3, WebAuthn, email — the HTTP surface |
| `agents` | `pip install -e ".[agents]"` | `[server]` plus LangChain, deepagents, `langchain-quickjs`, the LangGraph Postgres checkpointer |
| `cn` | `pip install -e ".[cn]"` | Aliyun OSS (`oss2`) and the Volcengine Ark SDK |
| `test` | `pip install -e ".[test]"` | `pytest`, `pytest-asyncio`, `pytest-sugar`, `import-linter`; see [Development Setup](/en/development/setup) |
| `indicator-build` | `pip install -e ".[indicator-build]"` | Rebuilding the terminology bundles themselves; consumers of the bundles need none of it |
## PyPI package
The engine is published to PyPI as **`mirobody`**:
```bash
pip install mirobody
```
This gives you the importable package, not the repository. `compose.yaml` and the `config.yaml` template live at the repository root and are not part of the wheel, so you supply your own config files in the working directory. The wheel does carry a CLI (`mirobody serve`), and `Server.start` is there when you want to mount your own routers alongside the built-in ones:
```python app.py
import asyncio
from mirobody.server import Server
async def main():
# Your own FastAPI routers can be mounted alongside the built-in ones.
await Server.start(yaml_files=["config.yaml"], fastapi_routers=[])
asyncio.run(main())
```
The worker has the same shape, `from mirobody.server import Worker` and `Worker.start(...)`. `ENV` still has to be in the environment before either one starts.
## Repository layout
The package layout is the three stages, plus the infrastructure they stand on.
The tool, agent, provider, skill and resource directories are read at startup from the paths named by `MCP_TOOL_DIRS`, `AGENT_DIRS`, `PROVIDER_DIRS`, `SKILL_DIRS` and `MCP_RESOURCE_DIRS`. Adding one is adding a file, then restarting; see [Configuration](/en/configuration).
## Verify the installation
```bash
curl http://localhost:18080/api/health
```
Returns JSON with the service name and version plus the number of tools, resources and agents discovered at startup.
```bash
curl -X POST http://localhost:18080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
Lists the registered tools with their JSON schemas.
Under Docker, `docker compose logs -f mirobody`. Running from source, logs go to the console; set `LOG_NAME` and `LOG_DIR` to write files instead.
## Troubleshooting
Git LFS was missing or not initialised when you cloned, so those files are text pointers. Run `git lfs install` and then `git lfs pull` in the repository.
`deploy.sh` stops containers publishing those ports before it starts, but a non-Docker process holding one is not touched. Free the port, or change the mapping in `compose.yaml` and `HTTP_PORT`.
`HTTP_PORT` is commented out in `config.yaml`, and the fallback is `80`. The Docker path sets it through the container environment; from source, set `HTTP_PORT` in your `config.{env}.yaml`.
`PG_HOST: 10.108.0.2` and `REDIS_HOST: 10.108.0.9` are addresses on the compose bridge network. From the host, use `127.0.0.1` with the published ports 18082 and 18089.
The server reads `ENV` on startup to choose `config.{env}.yaml`. `deploy.sh` writes it into `.env`; if you are running from source or from the PyPI package, create that file or export the variable yourself.
## Next steps
The three config layers, what gets encrypted, and the key groups.
The short path from clone to signed-in.
How the pieces above fit together at runtime.
Working on the engine itself.
---
# Configuration
https://docs.mirobody.ai/en/configuration
How the Mirobody engine resolves configuration: the three layers, automatic secret encryption, and every key in config.yaml grouped by what it does.
Every setting the engine has is a **flat, upper-case key**. Keys come from YAML files, from a remote config service, or from the process environment, and the loader merges them into one namespace at startup. There is no per-module config file and nothing is reloaded while the process runs — a config change means a restart.
## Minimum configuration
`./deploy.sh` generates `.env` and `config.{env}.yaml` for you; what you fill in by hand is only this. Everything else has a working default — come back to the [key reference](/en/configuration#key-reference) when you actually need to change one.
| What you set | Where | What happens without it |
| --- | --- | --- |
| `ENV` · `CONFIG_ENCRYPTION_KEY` | `.env` | It won't start: no override file found, or encrypted values can't be opened |
| One **LLM key** (e.g. `OPENROUTER_API_KEY`) | `config.{env}.yaml` | Login works, but a chat turn has no model |
| One **embedding key** (`GOOGLE_API_KEY` or `DASHSCOPE_API_KEY`) | `config.{env}.yaml` | Chat works, but **health indicators stay at 0** — failing silently in the worker log |
| `JWT_KEY` | `config.{env}.yaml` | The auth layer is not installed and everyone is anonymous |
| Database and Redis connection keys | already in `compose.yaml` | Nothing to do under Compose; only needed for external instances |
**The LLM key and the embedding key are two different keys.** With only the first, chat works while indicators stay at 0, and nothing surfaces an error. See [Health Indicators](/en/concepts/indicators).
## The three layers
```text
.env -> which environment, and the encryption key
config.{env}.yaml -> your overrides
config.yaml -> the committed template
```
Precedence runs from the top down — the topmost source that defines a key wins:
A lookup consults the process environment first: the key as given, then upper-cased. Only then does it fall back to the merged YAML map, which is a plain overwrite in load order — `config.yaml`, then remote config, then `config.{env}.yaml`. The last file to define a key is the one that survives.
| File | Tracked in git? | Written by | Role |
| ---- | --------------- | ---------- | ---- |
| `config.yaml` | yes | upstream | Defaults for every key. Its own header says *do not edit this file*. |
| `config.{env}.yaml` | no (`.gitignore` matches `*.*.yaml`) | you, or `deploy.sh` on first run | Everything you change. With `ENV=localdb` that is `config.localdb.yaml`. |
| `.env` | no | you, or `deploy.sh` | `ENV` picks the file above; `CONFIG_ENCRYPTION_KEY` unlocks encrypted values. |
`.env` is loaded first and each line becomes an environment variable, set with `setdefault`, so a variable already exported in the shell beats the file. Worth remembering: **anything you put in `.env` outranks both YAML layers**, not just `ENV` and `CONFIG_ENCRYPTION_KEY`.
```bash .env
# 'localdb', 'test', 'gray', 'prod', or a name you invent.
ENV=localdb
# Up to 32 characters. Encrypts sensitive values in config.{env}.yaml.
CONFIG_ENCRYPTION_KEY=Xk3pQ7mZ2vB9nR4tY6wL8sD1fG5hJ0aC
```
Under Docker Compose the `mirobody` service sets `HTTP_HOST=0.0.0.0` and `HTTP_PORT=18080` as container **environment variables**, which by the rule above outrank YAML. Setting `HTTP_PORT` in `config.{env}.yaml` has no effect there. Change the port in `compose.yaml` instead.
Beside each YAML file the loader also looks for a `.key.yaml` sibling: `config.key.yaml` and `config.{env}.key.yaml`. Nothing creates them for you, and they're optional, but they load last — a convenient place to keep credentials separate from the rest of your overrides.
## Remote configuration
Set three environment variables and the engine fetches a resolved YAML document over HTTP before it reads your local override file:
```bash
CONFIG_SERVER=https://config.example.com
CONFIG_TOKEN=
ENV=prod
```
The request is `GET {CONFIG_SERVER}/api/v1/config/environments/{ENV}/configs/resolved?is_yaml=true`. If any of the three is empty, or the request fails, the engine logs the reason and carries on with local files only. Because remote config loads *before* `config.{env}.yaml`, a local override still wins, which is useful for pinning one key on one machine. `compose.yaml` passes `CONFIG_SERVER` and `CONFIG_TOKEN` through to the container, so putting them in `.env` is enough.
## Automatic encryption
Secrets in your override file are encrypted in place, on the first load that sees them in plaintext. The rule is purely by **key name**: a top-level string value is encrypted when its name matches
```text
_KEY _PASSWORD _PASS _PWD _SECRET _SK _TOKEN
```
with two exceptions: names ending in `_URL` are skipped (so `GARMIN_TOKEN_URL` stays readable), and the literal `REPLACE_THIS_VALUE_IN_PRODUCTION` is left alone. That's why the template's placeholders never turn into ciphertext.
So you write this:
```yaml config.localdb.yaml
JWT_KEY: 7f2Ka9LmQ4xRt6Zv1Bn8Cs3Wd5Yh0Pj2
OPENROUTER_API_KEY: sk-or-v1-abcdef
GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_token
```
and after one start the file on disk reads:
```yaml config.localdb.yaml
JWT_KEY: gAAAAABm9x...truncated...Q3w==
OPENROUTER_API_KEY: gAAAAABm9x...truncated...7Yk=
GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_token
```
The rewrite is a round-trip through `ruamel.yaml`, so your comments, key order and quoting style survive. On every later load, values beginning with `gAAAA` are recognised as Fernet tokens and decrypted into memory; the file is not touched again. In the startup summary, `*_API_KEY` values are printed masked as `abc******xyz`.
Only **top-level scalar strings** are considered; values nested inside a mapping are never rewritten. That's exactly right for the `api_key:` fields under `PROVIDERS_*`, because those hold the *name* of a config key, not a secret.
### The encryption key
`CONFIG_ENCRYPTION_KEY` is a raw secret, not a Fernet key. The loader trims it, keeps **at most the first 32 characters**, right-pads shorter values with `0` to 32 bytes, and base64-encodes the result to derive the Fernet key. `deploy.sh` generates 32 random alphanumerics; any equivalent works:
```bash
openssl rand -hex 16 # 32 characters
```
Change or lose this key and every `gAAAA...` value in your override file becomes undecryptable: decryption failures are logged, and the ciphertext is passed through as if it were the value. Keep a copy of the key with your other secrets, and if you must rotate it, replace the ciphertext with plaintext first and let the next start re-encrypt.
## Key reference
Unless noted otherwise, every key below appears in `config.yaml`. Keys shown commented out there are inactive defaults you can copy into your own file.
### Logging and timezone
| Key | Default | Notes |
| --- | ------- | ----- |
| `LOG_LEVEL` | `DEBUG` | Case-insensitive. An unrecognised name falls back to `INFO`. |
| `LOG_NAME`, `LOG_DIR` | unset | Both unset means console only. Files are named `{date}_{name}_{time}.log`. |
| `DEFAULT_TIMEZONE` | `America/Los_Angeles` | Used for users who have not chosen one. |
### HTTP server
```yaml config.localdb.yaml
HTTP_HOST: 0.0.0.0
HTTP_PORT: 18080
HTTP_ROOT: frontend
HTTP_HEADERS:
Access-Control-Allow-Origin: 'http://localhost:18080'
Access-Control-Allow-Credentials: 'true'
Access-Control-Allow-Methods: 'GET, POST, PUT, DELETE, OPTIONS'
Access-Control-Allow-Headers: 'Authorization, Content-Type'
Access-Control-Max-Age: '600'
REQUEST_RATE_LIMITER:
/api/chat: 6
/api/session: 6
```
| Key | Default | Notes |
| --- | ------- | ----- |
| `HTTP_SERVER_NAME` | `mirobody` | Also emitted as the `Server:` response header, with the version appended. |
| `HTTP_HOST` | `0.0.0.0` | |
| `HTTP_PORT` | `80` | Commented out in the template, so a source run listens on `80` unless you set it. |
| `HTTP_URI_PREFIX` | empty | Mounts every route under a sub-path; leading and trailing slashes are normalised. |
| `HTTP_ROOT` | `frontend` | The prebuilt web client, resolved next to the running process. It sits outside the Python package, so a wheel ships the engine rather than the JavaScript; a path that does not exist simply serves no client. |
| `HTTP_HEADERS` | unset | Verbatim response headers — this is where CORS goes. The template's own example pairs a fixed origin with `Allow-Credentials`, the combination browsers require; `*` and credentials together are rejected. |
| `REQUEST_RATE_LIMITER` | `/api/chat: 6`, `/api/session: 6` | A map of `{ "path": requests-per-minute }`. Present and non-empty adds the rate-limit middleware, which counts per user in Redis; see [Architecture Overview](/en/concepts/architecture). |
| `USER_INFO_UPDATER` | unset | A list of paths whose requests also refresh the caller's profile. |
### Public URLs
`MCP_PUBLIC_URL` is your externally reachable base URL (an ngrok domain, for instance): remote MCP clients need it, files on the local filesystem are served from `{MCP_PUBLIC_URL}/files`, and the startup banner uses it as the address to open. See [Mirobody MCP Server](/en/tools/mcp-integration). The template also carries `MCP_FRONTEND_URL`, `DATA_PUBLIC_URL` and `QR_LOGIN_URL` as placeholders; no live code path reads them in this version.
### Redis
Required — the cache, the task queues and the rate limiter all use it.
```yaml config.localdb.yaml
REDIS_HOST: 127.0.0.1
REDIS_PORT: 18089
REDIS_DB: 0
REDIS_PASSWORD: ''
REDIS_SSL: false
REDIS_SSL_CHECK_HOSTNAME: false
REDIS_SSL_CERT_REQS: none
```
The template ships `REDIS_HOST: 10.108.0.9`, the container's address on the Compose bridge network; from the host use `127.0.0.1` with the published port. Appending a suffix to each name declares a **second, independent connection**: `REDIS_HOST_LOG`, `REDIS_PORT_LOG` and so on are picked up by the code that asks for the `LOG` connection.
### PostgreSQL
```yaml config.localdb.yaml
PG_HOST: 127.0.0.1
PG_PORT: 18082
PG_USER: holistic_user
PG_PASSWORD: ''
PG_DBNAME: holistic_db
PG_SCHEMA: theta_ai
PG_ENCRYPTION_KEY: ''
PG_MIN_CONNECTION: 5
PG_MAX_CONNECTION: 20
```
Defaults are `PG_HOST: 10.108.0.2`, user `holistic_user`, database `holistic_db`, schema `theta_ai`, pool 5–20. `PG_ENCRYPTION_KEY` encrypts sensitive columns, so it has to be resolvable before the first query. The same `_SUFFIX` trick as Redis gives you a second connection, with one caveat: the encryption key is read without the suffix, so a suffixed connection reuses the primary `PG_ENCRYPTION_KEY`.
### Object storage
`S3_KEY`, `S3_TOKEN`, `S3_REGION`, `S3_BUCKET`, `S3_PREFIX` and `S3_CDN` all ship commented out, and that is itself a working configuration: the storage factory tries cloud backends in turn, the S3 backend refuses to construct without an access key, secret, region and bucket, and the factory then **falls back to the local filesystem**. Fill the four mandatory keys to switch to S3 or any S3-compatible store; `S3_PREFIX` namespaces the keys and `S3_CDN` is the public base URL used when building links. A name suffix selects a second bucket.
### Email and sign-in
```yaml config.localdb.yaml
EMAIL_PREDEFINE_CODES:
exp1@mirobody.ai: '111111'
exp2@mirobody.ai: '111111'
exp3@mirobody.ai: '111111'
```
`EMAIL_PREDEFINE_CODES` maps an address to a fixed verification code: those accounts sign in with it and no mail is sent. The template enables three demo accounts and prints them as a table at startup, so a fresh checkout is usable immediately. To actually send codes, configure `EMAIL_SMTP_HOST` / `EMAIL_SMTP_PORT` / `EMAIL_SMTP_USER` / `EMAIL_SMTP_PASS` along with `EMAIL_FROM` and `EMAIL_FROM_NAME`.
Third-party sign-in is configured per vendor: `GOOGLE_CLIENT_ID` + `FIREBASE_PROJECT_ID` for Google, and `APPLE_TEAM_ID` / `APPLE_KEY_ID` / `APPLE_PRIVATE_KEY` / `APPLE_CLIENT_ID` for Apple (plus `APPLE_CLIENT_ID_APP` and `APPLE_AUTH_CLIENT_ID` when the app and web client ids differ). All commented out by default.
Remove `EMAIL_PREDEFINE_CODES` before exposing an instance to anyone else. Every address listed there is a password-free account.
### JWT and OAuth
`JWT_KEY` is the HS256 secret, and `deploy.sh` writes a random value into your override file on first run. It is also **the switch for the whole auth layer**: the middleware that resolves a bearer token into a caller identity is only installed when `JWT_KEY` is non-empty. `JWT_PRIVATE_KEY` is the RS256 alternative. `JWT_ISS`, `JWT_AUD`, `JWT_CLIENT_ID` and `JWT_SCOPE` become the matching claims in the tokens the engine issues to MCP clients.
### Web client configuration
`MIROBODY_WEB_CONFIG` is a nested map handed to the bundled web client at runtime: feature toggles named `__IS_*_ON__` plus the browser-side Firebase values. The whole block is commented out in the template.
```yaml config.localdb.yaml
MIROBODY_WEB_CONFIG:
__IS_API_CONFIG_ON__: true
__IS_QR_LOGIN_ON__: false
__IS_GOOGLE_LOGIN_ON__: true
__IS_APPLE_LOGIN_ON__: true
__IS_NEW_FEATURES_ON__:
- MCP
__IS_MOBILE_SOURCE_ON__: true
__FIREBASE_API_KEY__: ""
__FIREBASE_AUTH_DOMAIN__: ""
__FIREBASE_PROJECT_ID__: ""
```
### Extension directories
Five keys, each a list of directories, tell the engine where to discover your own code at startup. Each ships with exactly one entry, the packaged one. **Add your own directory and list it first**; it is scanned ahead of the built-in one, which is what lets a deployment override a packaged tool or skill without editing the tree.
| Key | Default | What goes there |
| --- | ------- | --------------- |
| `MCP_TOOL_DIRS` | `mirobody/agent/tools` | Python modules whose top-level functions and `*Service` classes become tools; see [Tools & Agent Overview](/en/tools/overview) |
| `MCP_RESOURCE_DIRS` | `mirobody/agent/resources` | MCP UI resources |
| `AGENT_DIRS` | `mirobody/agent` | Agent implementations; see [Agent Types](/en/tools/agents) |
| `PROVIDER_DIRS` | `mirobody/pulse/providers` | Health-data providers; see [Building a Provider](/en/development/provider-integration) |
| `SKILL_DIRS` | `mirobody/agent/skills` | Skill directories, one `SKILL.md` each; see [Agent Skills](/en/tools/skills) |
Discovery happens at startup, so adding any of them means adding a file and restarting. Forward slashes are translated to the platform separator, and an empty list falls back to the default.
### LLM API keys
| Key | Where to get one | Read by |
| --- | ---------------- | ------- |
| `GOOGLE_API_KEY` | `aistudio.google.com/apikey` | Google GenAI clients, file analysis, embeddings |
| `OPENAI_API_KEY` | `platform.openai.com/api-keys` | The OpenAI-compatible client |
| `OPENROUTER_API_KEY` | `openrouter.ai/keys` | One key, many models: what the template's providers use |
| `DASHSCOPE_API_KEY` | `dashscope.console.aliyun.com/apiKey` | DashScope's OpenAI-compatible endpoint; also audio transcription |
| `ANTHROPIC_API_KEY` | Anthropic console | Direct Claude access |
Every key ending in `_API_KEY` is collected at startup and exported into the process environment with `setdefault`, so vendor SDKs that look for their own variable find it without extra wiring. `ANTHROPIC_API_KEY` and `JINA_API_KEY` are commented placeholders in the template; nothing in this release reads the latter.
### Per-agent models and tools
Four key families are suffixed with the agent's name in upper case. Two agents ship, so the suffixes that matter are **`DEEP`** and **`BASE`**; the `*_RTC` keys in the template are empty leftovers of a removed agent, and there is no `*_MIX`. A minimal `PROVIDERS_DEEP` looks like this:
```yaml config.localdb.yaml
PROVIDERS_DEEP:
gemini-3.5-flash:
llm_type: google-genai
api_key: GOOGLE_API_KEY
model: gemini-3.5-flash
temperature: 1.0
claude-sonnet:
llm_type: openai
api_key: OPENROUTER_API_KEY
base_url: https://openrouter.ai/api/v1
model: anthropic/claude-sonnet-4.6
temperature: 0.1
DISALLOWED_TOOLS_DEEP:
- task
```
| Family | Shape | Notes |
| ------ | ----- | ----- |
| `PROVIDERS_{NAME}` | map of display name to provider block | The names users pick from. `api_key` holds the **name of a config key**, resolved at load time; one that resolves to nothing leaves a placeholder that reports the missing name instead of failing at import. |
| `ALLOWED_TOOLS_{NAME}` | list of tool names | An allow-list. It takes precedence over the deny-list; both empty means every discovered tool is available. |
| `DISALLOWED_TOOLS_{NAME}` | list of tool names | A deny-list. |
| `PROMPTS_{NAME}` | list of `.jinja` paths | Resolved from the filesystem or from inside the installed package. A `path@key` suffix names the template explicitly, otherwise the filename is the name. |
Which agent reads which suffix, and what the prompt names mean for that agent, is covered in [Agent Types](/en/tools/agents).
### Third-party health platforms
```yaml config.localdb.yaml
GARMIN_CLIENT_ID: ''
GARMIN_CLIENT_SECRET: ''
GARMIN_REDIRECT_URL: ''
WHOOP_CLIENT_ID: ''
WHOOP_CLIENT_SECRET: ''
WHOOP_REDIRECT_URL: ''
OAUTH_TEMP_TTL_SECONDS: 900
```
Garmin, Whoop and Oura are the shipped providers with OAuth credentials of their own. Note that the Oura keys (`OURA_CLIENT_ID`, `OURA_CLIENT_SECRET`, `OURA_REDIRECT_URL`) are read by its provider but are *not* in the template, so you add them yourself. Alongside the client ID, secret and redirect URL, the template pins their endpoints: `GARMIN_TOKEN_URL`, `GARMIN_AUTH_URL`, `GARMIN_ACCESS_TOKEN_URL`, `GARMIN_API_BASE_URL`, and `WHOOP_TOKEN_URL`, `WHOOP_AUTH_URL`, `WHOOP_API_BASE_URL`, `WHOOP_SCOPES`, so you normally only fill in the credentials. `OAUTH_TEMP_TTL_SECONDS` bounds how long a pending authorization stays valid. Connecting an account is described in [Using Providers](/en/providers/using-providers).
A provider with no credentials does not fail: it declines to start and logs `declined to start (not configured)`, the honest state rather than an error. The one you can try immediately is the PostgreSQL provider: set `ENABLE_PGSQL_DEVICE: 1` and the platform logs `loaded 1 providers` on the next boot.
The template also carries `VITAL_API_KEY` and `VITAL_ENVIRONMENT`, the `RENPHO_*` block, and commented `FRONTIERX_CLIENT_ID` / `FRONTIERX_USER_POOL_ID`. There is no provider package behind any of them in this release; treat them as reserved.
### File processing and indicators
| Key | Default | Notes |
| --- | ------- | ----- |
| `EMBEDDING_PROVIDER` | `gemini` | `gemini` (needs `GOOGLE_API_KEY`) or `qwen` (needs `DASHSCOPE_API_KEY`). Selects both the embedding model and the vector column used for indicator search; an unknown value raises. **This is a different key from the chat model's**: without a working one, the worker's indicator sync fails quietly and Health indicators stays 0. See [Health Indicators](/en/concepts/indicators). |
| `_VISION_MODEL` | the provider's own | Overrides the vision model for image and PDF parsing; the key name follows the provider. |
`config.yaml` also still carries three keys **no code reads**: `ENABLE_INDICATOR_EXTRACTION` (setting it to `0` does not turn extraction off — leave the vision providers unconfigured instead), `FILE_ANALYSIS_PROVIDER` and `DIM_EMBEDDING_PROVIDER`. The live key is `EMBEDDING_PROVIDER`.
`DATABASE_DECRYPTION_KEY` is marked deprecated in the template and may be removed: the live column-encryption key is `PG_ENCRYPTION_KEY`.
## Inspecting the resolved configuration
At startup the engine prints a summary of the resolved configuration: the files it read, the environment name, log and HTTP settings, the data stores, the discovered directories, and the API keys it found, masked. That summary is the fastest way to confirm a layer landed the way you expected.
```bash
docker compose logs mirobody | head -40 # under Compose
curl http://localhost:18080/api/health # counts of tools, resources and agents
```
If a value looks stale, check in this order: an environment variable shadowing it (including one from `.env`), then whether your file is really named `config.{ENV}.yaml` for the `ENV` you set, then whether the key is spelled exactly as the loader expects. Lookups are upper-cased, so case in the file does not matter, but nothing else about the name is forgiving.
## Next steps
Where these files come from, and the ports they describe
Running from source against Postgres and Redis in Docker
Which middleware and services each key switches on
Secrets, TLS and hardening beyond a local run
---
# The Engine as a Library
https://docs.mirobody.ai/en/engine
pip install mirobody: offline indicator resolution, one-call document parsing, and the four install layers between a 77 MB library and the full chat server.
The chat server is built on top of the engine. The engine itself — **① Collect** and **② Standardize** — is an ordinary Python package: no framework, no database, and no network calls. `pip install mirobody` is enough to use it.
The reason for that split: standardizing health data normally requires sending it to an external service, whereas here the vocabulary ships with the wheel and the process needs no network access.
## The public API
`mirobody/engine.py` is the whole public surface of the engine layer: two functions and two result types.
```python
from mirobody.engine import resolve, parse_file
r = resolve("血红蛋白")
r.loinc # '718-7'
r.canonical # 'Hemoglobin [Mass/volume] in Blood'
r.resolved # True
r.candidates # 72 — how many aliases pointed at this name
readings = await parse_file("physical-2026.pdf") # needs one model key
```
`resolve()` is offline and deterministic. `parse_file()` is the one call that reads a document: it needs a model key for the *extraction* half only, and the standardization that follows runs offline, which is why the function still does something useful without one.
The first `resolve()` call pays a few seconds to load the shipped bundles (a 921k-entry alias index). Reuse the resolver (`get_resolver()` returns it) rather than calling the module-level convenience in a loop.
## The CLI
Installed as a console script, so a plain `pip install mirobody` gets a runnable command. Deployments use `python -m mirobody`.
| Command | What it does | Needs |
| --- | --- | --- |
| `mirobody resolve ` | Indicator names → standard codes | nothing: no key, no config, no network |
| `mirobody parse ` | Lab report in, standardized LOINC table out | one LLM key |
| `mirobody serve` | The HTTP server (chat, MCP, API) | the `[agents]` extra + Postgres/Redis |
| `mirobody worker` | The background task worker (indicator sync, profile refresh) | same as `serve` |
`serve` and `worker` check for the agent layer up front and print what to install, instead of dying several modules deep with `ModuleNotFoundError: langchain`.
```bash
mirobody resolve "LDL cholesterol" "血红蛋白" "ヘモグロビン" "血圧"
```
```text
LDL cholesterol LOINC 13457-7 Cholesterol in LDL [Mass/volume] in Serum or Plasma by calculation [63 candidates]
血红蛋白 LOINC 718-7 Hemoglobin [Mass/volume] in Blood [72 candidates]
ヘモグロビン LOINC 718-7 Hemoglobin [Mass/volume] in Blood [72 candidates]
血圧 unresolved — not in the lexical index (the full semantic pipeline may still resolve it)
```
Three languages, two of them landing on the same code. The fourth line matters: `血圧` names a *panel*, not an observation, and the lexical index reports an honest miss rather than selecting one of its two components.
## Install extras and capabilities
| Install | What works | Footprint |
| --- | --- | --- |
| the wheel + numpy only | `from mirobody.engine import resolve`: the offline resolver | **77 MB**, 2 packages |
| `pip install mirobody` | + `mirobody parse` (one LLM key) · file parsing (PDF/Excel/audio) · FHIR output | 233 MB, 90 packages |
| `pip install 'mirobody[server]'` | + the HTTP API and the MCP endpoint | needs Postgres + Redis |
| `pip install 'mirobody[agents]'` | + DeepAgent / BaseAgent and `mirobody serve` (includes `[server]`) | + the LangChain stack |
| `pip install 'mirobody[indicator-build]'` | rebuilding the terminology bundles themselves | needs LOINC / UMLS sources |
Of the 77 MB floor, 51 MB is the shipped LOINC/SNOMED data — the resolver itself, and the reason standardization works without network access. Two more extras exist: `[cn]` (Aliyun OSS + Volcengine Ark) and `[test]` (pytest + import-linter).
The database driver, HTTP server, S3 and email clients live in `[server]`, which `[agents]` pulls in, so a library user does not pay for a Postgres driver.
The bundles are Git LFS objects. Working from a clone, run `git lfs install` and `git lfs pull` before anything else, or `resolve()` raises with a message telling you exactly that.
## The engine / agent dependency boundary
**The engine must import with no agent framework installed.** `langchain*`, `deepagents` and `langgraph` are allowed only under `agent/` and `server/`, the same layering langchain itself uses for `langchain-core`. Two import-linter contracts in `pyproject.toml` fail the build on violation, function-local imports included:
```bash
pip install -e '.[test]' && lint-imports
```
That contract is why `mirobody.engine` can resolve an indicator with numpy as the only third-party package present.
## Runnable examples
Five scripts in [`examples/`](https://github.com/thetahealth/mirobody/blob/main/examples/README.md), in order of how much they need, and each one can be run directly.
| | Example | Needs | Shows |
| --- | --- | --- | --- |
| 01 | `01_resolve_offline.py` | `pip install mirobody` | ② name → LOINC, any language, fully offline |
| 02 | `02_standardize_a_reading.py` | `pip install mirobody` | ① unit conversion + the indicator catalogue |
| 03 | `03_parse_a_lab_report.py` | + one model key | a document → standardized readings in one call |
| 04 | `04_mcp_tool_surface.py` | `pip install mirobody` | ③ exactly what an external MCP client receives |
| 05 | `05_agent_server_preflight.py` | `pip install 'mirobody[agents]'` | whether this machine can run the full server, and what is missing |
Examples 01, 02 and 04 require only the package. Example 05 reports every prerequisite of the full server in a single run, which is the quickest way to find out what a machine is still missing.
## Next steps
Docker Compose, a demo login, and the two keys you need.
The concept graph, the alias index, and how correctness is scored.
Where each stage lives, and what the shared infrastructure is.
Editable install and the test suite.
---
# Architecture Overview
https://docs.mirobody.ai/en/concepts/architecture
What the Mirobody Python engine is made of: three steps side by side over one shared infrastructure layer and one thin HTTP layer, running as two processes.
Mirobody is a **Python engine** (3.12 and up). It is made of three parts, one per [step](/en/self-host#the-three-stages-in-the-engine): **Collect** takes data in, **Standardize** resolves readings to canonical codes, and **Answers** is the agent working on top of them. All three share one infrastructure layer (accounts, task queue, MCP, configuration), with a thin HTTP layer above.
It runs as two processes over one PostgreSQL database and one Redis: `mirobody serve` serves HTTP, `mirobody worker` consumes background queues. All the code lives in one importable package, so a deployment is a config file and one `docker compose up` — there is nothing to build.
## Layering rule
LangChain and its related dependencies are allowed **only** inside the Answers part, enforced at build time by an import contract. That is why the Collect and Standardize halves installed by `pip install mirobody` bring numpy as their only third-party package — and why they work as an ordinary library. See [The Engine as a Library](/en/engine).
## Two processes
The split is deliberate: an embedding sweep that runs for minutes has no business running in the process that serves HTTP requests.
| Process | Command | What it does |
|---|---|---|
| **HTTP service** | `mirobody serve` | Loads configuration, bootstraps the database schema, assembles routes and middleware, and serves on `HTTP_HOST:HTTP_PORT`. |
| **Background worker** | `mirobody worker` | Reads the same configuration and consumes background tasks off Redis: backfilling indicator codes and embeddings, rebuilding the user profile. It listens on no port. |
Both need the `[agents]` extra; without it the command prints one line about what to install and exits. On `SIGINT` / `SIGTERM` the worker finishes what it holds before exiting.
In `compose.yaml` these are two containers: one publishes `18080` and runs the HTTP service, the other publishes no port and runs the worker.
Only the HTTP process registers routes. If you skip the worker and deploy one container, the API still answers, but indicator sync and profile refresh never run, so freshly ingested indicators stay stale in semantic search.
## Middleware a request passes through
In the order a request crosses them, with the behaviour and the config key each one reads:
Rate limiting applies only to authenticated callers and requires Redis to be configured.
At startup, unless `ENV` is one of `TEST` / `GRAY` / `PROD` / `TEST-INLOCAL`, the engine creates the schemas named in the configuration and runs the bundled table scripts. That is the local bootstrap path; production migrations are done by hand.
## The three steps and shared infrastructure
| Part | Directory | What is in it |
|---|---|---|
| **① Collect** | `pulse/` | Device providers, on-device batch import, file parsing, the normalized write path, daily rollups and the insight engine. See [Data Flow](/en/concepts/data-flow). |
| **② Standardize** | `indicator/` | The concept graph, embedding-based resolution, UCUM unit families and clinical taxonomies. See [Health Indicators](/en/concepts/indicators). |
| **③ Answers** | `agent/` | The two agents, the `/api/*` chat surface, the MCP tool surface, Agent Skills and the prompts. See [Agent Types](/en/tools/agents). |
| Shared infrastructure | `server/` · `mcp/` · `task/` · `user/` · `utils/` | HTTP assembly, the MCP service, background tasks, accounts and auth, configuration and database access. |
Five config keys are **directory-driven**: `MCP_TOOL_DIRS`, `AGENT_DIRS`, `PROVIDER_DIRS`, `MCP_RESOURCE_DIRS` and `SKILL_DIRS` each name directories scanned at startup. Adding a tool, an agent, a provider or a skill means dropping a file in and restarting — nothing has to be registered anywhere. Each key defaults to the one directory inside the package; to use your own, list it first.
## HTTP routes
The two route families follow different prefix rules, which matters before you put the engine behind a path-routing proxy:
- **Chat, MCP, login and OAuth** routes take the prefix when `HTTP_URI_PREFIX` is set.
- **Pulse, sharing, files and indicators** declare their prefixes themselves and are **not** affected by `HTTP_URI_PREFIX`.
The OAuth discovery documents, the SPA fallback, `/charts` and `/mirobody.json` are unprefixed either way.
| Area | Routes |
|---|---|
| **Health check** | `GET /api/health` |
| **Chat** | `GET /api/models` · `/api/agents` · `/api/providers` · `/api/prompts`, `POST /api/session`, `GET /api/history` · `/api/history_by_person`, `POST /api/history/delete` · `/api/rating` · `/api/chat`, `GET /api/beneficiary-users` |
| **Per-user configuration** | `GET` / `POST /api/user/mcp`, `POST /api/user/mcp/set` · `/api/user/mcp/delete`, `GET` / `POST /api/user/prompt`, `POST /api/user/prompt/set` · `/api/user/prompt/delete` |
| **MCP** | `POST` / `GET /mcp`, `/mcp/{secret}`, `POST /personal/mcp` |
| **OAuth** | `/.well-known/oauth-authorization-server`, `/.well-known/oauth-authorization-server/mcp`, `/.well-known/mcp-configuration`, `/oauth/register` · `/oauth/authorize` · `/oauth/token` · `/oauth/introspect`, `/oauth2/authorize`, `/oauth2/check_state/{state}` |
| **Login** | `POST /email/login` · `/email/verify` · `/email/bind`, `POST /apple/verify` · `/google/verify`, `POST /user/del` · `/user/update_name` |
| **WebAuthn** | `/auth/webauthn/{register,login,upgrade}/{options,verify}`, `/auth/session/renew`, `/auth/session/reauth/{options,verify}` |
| **Files** | `POST /files/upload`, `GET /files/{file_path}`, `GET /api/v1/data/uploaded-files` · `/api/v1/data/data-distribution`, `POST /api/v1/data/delete-files`, WebSocket `/ws/upload-health-report` |
| **Indicators** | `GET /api/v1/health-indicators`, `POST /api/v1/health-indicators/reading` |
| **Pulse (public)** | `GET /api/v1/pulse/providers` · `/user/providers` · `/user-data-sources` · `/user/insights` · `/indicators` · `/units`, `POST /api/v1/pulse/user/providers/link` · `/unlink`, `POST /api/v1/pulse/{platform}/webhook` · `/{platform}/token`, `GET /api/v1/pulse/{platform}/{provider}/callback` |
| **On-device batches** | `POST /apple/health` · `/apple/statistics` · `/apple/cda` — also mounted under `/api/v1/pulse/apple/*`, since an uploader may point at either |
| **Pulse (management)** | `/api/v1/manage/pulse/*` (indicators, units, user-health-data, monitor, insight, data-quality), `/api/v1/manage/theta/pull/*`, `POST /api/v1/manage/aggregate/recalculate-range` |
| **Sharing** | `POST /api/share/create`, `GET /api/share/{share_session_id}`, `/invitation/shared-by-me/*`, `/invitation/shared-with-me/*`, `/invitation/permissions/list` |
| **User settings** | `GET` / `POST /api/user/settings`, `POST /api/user/virtual` |
| **Web client** | Anything unmatched above and outside the backend's own prefixes falls back to the SPA shell; `/mirobody.json`, `/__/auth/init.json` and `/charts` are registered explicitly |
The SPA fallback **returns 404** for unmatched `GET`s under `/api`, `/mcp`, `/oauth`, `/oauth2`, `/invitation`, `/apple`, `/google`, `/email`, `/personal`, `/auth/session`, `/auth/webauthn` and `/.well-known` rather than the shell: a mistyped backend path is an error, not a client deep link.
This engine has **no FHIR REST endpoints** and no inbound OpenAI-compatible `/v1` surface. The FHIR vocabularies are used for *coding and retrieval* (see [Health Indicators](/en/concepts/indicators)), not as an API. `/v1` is the hosted form — see the [Cloud](/en/api-reference) tab.
## Infrastructure
- **Database**: PostgreSQL with the `vector`, `pg_trgm` and `pgcrypto` extensions, default schema `theta_ai`. The compose file pins `pgvector/pgvector:pg17-trixie` and publishes `18082:5432`. Connection keys are `PG_HOST` / `PG_PORT` / `PG_USER` / `PG_PASSWORD` / `PG_DBNAME` / `PG_SCHEMA` / `PG_MIN_CONNECTION` / `PG_MAX_CONNECTION`.
- **Cache and queues**: Redis (`redis:7.0-alpine`, published on `18089:6379`). It backs rate limiting, the background task queues and the locks provider pulls take.
- **Object storage**: a configured cloud backend when there is one, otherwise local disk. The S3 backend reads `S3_KEY` / `S3_TOKEN` / `S3_REGION` / `S3_BUCKET` / `S3_PREFIX` / `S3_CDN`.
- **Configuration**: three layers — the repository template `config.yaml`, your own `config.{env}.yaml`, and `.env` (`ENV` + `CONFIG_ENCRYPTION_KEY`). Environment variables win over both YAML files. `CONFIG_SERVER` + `CONFIG_TOKEN` point at a remote config service. See [Configuration](/en/configuration).
- **Auth**: multi-user JWT, an OAuth 2.0 authorization server with discovery documents for MCP clients, email one-time codes, Apple and Google sign-in, and WebAuthn.
- **Web client**: a prebuilt SPA served from `HTTP_ROOT`, read from disk per request, so replacing the build needs no restart. It sits outside the Python package on purpose: the wheel installs the engine, not the JavaScript. Its pages are `/data` (documents and readings) and `/ask` (the agent), plus Care Circle sharing.
## Next steps
The three intake paths health data takes, and where it lands
The registry, unit conversion and semantic search
How runtime tool discovery and the MCP surface fit together
`deploy.sh`, `compose.yaml` and the two containers
---
# Pulse Provider System
https://docs.mirobody.ai/en/concepts/providers
How Pulse plugs data sources in: the platform/provider split, the BasePullProvider contract, link types, scheduled pulls, and normalisation to StandardPulseData.
## Platforms and providers
Health data reaches Mirobody through **Pulse**, which is split in two layers. A **platform** owns a family of sources plus the machinery they share; a **provider** is one concrete source inside a platform. Two platforms are registered at startup: **`theta`** is the pluggable one and owns every provider on disk, and **`apple`** is the built-in on-device batch importer, which accepts no plugins.
## The provider base class
A theta provider subclasses **`BasePullProvider`**. The base class already implements credential storage, unlinking, timezone resolution and the per-user pull loop, so a subclass only supplies what is specific to its source.
Only two methods must be implemented: `save_raw_data_to_db` (keep the raw payload) and `is_data_already_processed` (idempotency). The rest have working defaults, or raise a clear error naming the method you were supposed to write.
`format_data` is the older entry point and still works — the base class forwards between the two, so a provider implements **either** `format_data_v2(fmt_input)` **or** `format_data(raw_data)`, never both.
## Provider metadata
`info` returns a **`ProviderInfo`**. It is pure metadata: listing every provider costs no network call and no credentials, which is what makes `GET /api/v1/pulse/providers` cheap.
| `ProviderInfo` field | Meaning |
| --- | --- |
| `slug` | Unique identifier, e.g. `theta_garmin`. |
| `name` · `description` · `logo` | What the client shows. |
| `supported` · `status` | Whether the source is offered, and its availability. |
| `auth_type` | A `LinkType`, which decides the connection code path. |
| `platform` | The platform the provider belongs to. |
| `connect_info_fields` | The form to collect, when the source needs credentials rather than OAuth. |
`connect_info_fields` is how a provider declares a form instead of hard-coding one in the UI. Each entry is a `ConnectInfoField` with `field_name`, `field_type` (`string` / `number` / `select` / `password`), `required`, `label`, and optional `placeholder`, `default_value`, `options`. The PostgreSQL provider uses five of them to ask for host, port, database, username and password.
`status` is one of `available` · `connected` · `disconnected` · `reconnect` · `error` · `maintenance`. A provider declares `available`; the router overwrites it per user from what is actually linked.
## Link types
`auth_type` decides which flow a connection takes. The enum carries eleven values, but only a few are live in the shipped providers:
| `LinkType` | Flow | Used by |
|---|---|---|
| `OAUTH1` | Browser redirect, then `GET /api/v1/pulse/{platform}/{provider}/callback` with `oauth_token` + `oauth_verifier` | `theta_garmin` |
| `OAUTH2` | Browser redirect, then the same callback with `code` + `state` | `theta_whoop`, `theta_oura` |
| `PASSWORD` | Direct link with `username` + `password` — no browser | — |
| `CUSTOMIZED` | Direct link with a `connect_info` object matching `connect_info_fields` | `theta_pgsql` |
| `NONE` | No connection step at all | `apple_health` |
A `PASSWORD` or `CUSTOMIZED` provider gets validated and stored in one request, while an OAuth provider returns a `link_web_url` first and completes on the callback. Both end in the same place: credentials saved encrypted.
## Scheduled pulls
A source that has to be polled gets a scheduled task — the provider declares whether it wants one. The cadence is per-slug, and each task takes a distributed lock, so several server instances can run the same schedule without pulling twice:
| Slug | Execution interval | Lock duration |
|---|---|---|
| `theta_oura` | 5 minutes | 4 minutes |
| `theta_whoop` | 24 hours | 23.5 hours |
| `theta_renpho` | 24 hours | 23.5 hours |
| `theta_vital` | 6 hours | 5.5 hours |
| `theta_cgm` | 1 hour | 30 minutes |
| anything else | 1 hour | 30 minutes |
When a task fires, the provider loads every linked user's credentials, fetches from the vendor per user, skips whatever it recognises as already processed, and hands the rest to the write path.
A push-based source should return `False` from `register_pull_task` and let its webhook do the work — that is exactly what the Garmin provider does. Nothing then polls it on a timer.
## Normalizing to StandardPulseData
Whether a payload arrived by webhook or by scheduled pull, every provider is bound by the same constraint: convert your source's shape into **`StandardPulseData`**.
Identity and timezone are resolved **before** formatting, so the `format_data_v2` you write is pure: it maps fields and does no I/O. Each entry of `healthData` is a `StandardPulseRecord`:
| `StandardPulseRecord` field | Meaning |
| --- | --- |
| `source` | Where the reading came from, e.g. `vital.garmin`. |
| `type` | The **registered indicator name**, not the vendor's field name. |
| `timestamp` | Milliseconds. `startTime` / `endTime` carry a period instead of a point. |
| `value` · `unit` | The reading itself, and the unit as the source reported it. |
| `timezone` | Defaults to `UTC`. |
| `source_id` · `task_id` | Optional provenance, used for idempotency and tracing. |
`type` must be a registered indicator name rather than the source's own field name — that is what makes readings from a Garmin watch and an Oura ring comparable. See [Health Indicators](/en/concepts/indicators) for the registry, and [Data Flow](/en/concepts/data-flow) for what happens to the records afterwards.
## Provider discovery
Providers are loaded from disk at startup, so adding one is adding a directory — no registry edit, no rebuild. The directories scanned come from the `PROVIDER_DIRS` config key, which ships as:
```yaml config.yaml
PROVIDER_DIRS:
- mirobody/pulse/providers
- providers
```
Inside each directory the loader matches **`mirobody_*/provider_*.py`**, imports each match, looks for a class that subclasses `BasePullProvider`, then calls `create_provider(config)` on it and registers whatever comes back.
Four rules fall out of that, and breaking any one of them makes a provider silently absent:
`mirobody_/` — the glob only matches this prefix. Keep your own providers in the root `providers/` directory so upgrades don't touch them.
`provider_.py` — a differently named module in the same directory is never imported.
A name ending in `Provider`, subclassing `BasePullProvider`. The first match in the module wins.
`create_provider(config)` returning `None` is the supported way to stay disabled — that is how a provider with no credentials configured drops out.
Loading is best-effort: a provider that raises on import or in its factory is logged as a warning and skipped, and the server starts anyway. If a provider is missing from `GET /api/v1/pulse/providers`, read the startup log before suspecting the route.
## Next steps
The sources that actually ship, and what each one needs
Configure credentials, connect an account, watch data arrive
A full implementation read end to end
Write your own against this contract
The providers that ship live in [`mirobody/pulse/providers/`](https://github.com/thetahealth/mirobody/tree/main/mirobody/pulse/providers).
---
# Provider Overview
https://docs.mirobody.ai/en/providers/overview
The data sources that actually ship with the engine: four theta providers, the Apple Health importer, what switches each one on, and which config keys have no provider behind them.
## What ships with the repository
Two platforms are registered at startup — `theta` and `apple`. The `theta` platform loads its providers from disk, one directory per source; the `apple` platform is a built-in importer that accepts no plugins. The list below is what the repository contains — no other source is wired in.
| Source | Slug | Platform | Link type | Scheduled pull |
|---|---|---|---|---|
| Garmin Connect | `theta_garmin` | `theta` | `OAUTH1` | No — push only |
| Whoop | `theta_whoop` | `theta` | `OAUTH2` | Every 24 hours |
| Oura | `theta_oura` | `theta` | `OAUTH2` | Every 5 minutes |
| PostgreSQL | `theta_pgsql` | `theta` | `CUSTOMIZED` | No — validation only |
| Apple Health | `apple_health` | `apple` | `NONE` | No — the client uploads |
For the contract these implement — `BasePullProvider`, `ProviderInfo`, `LinkType`, the pull scheduler — see [Pulse Provider System](/en/concepts/providers). To connect one, see [Using Providers](/en/providers/using-providers).
## The theta providers
Each lives in its own `mirobody_/` directory under [`mirobody/pulse/providers/`](https://github.com/thetahealth/mirobody/blob/main/mirobody/pulse/providers/README.md), holds exactly one `provider_*.py`, and is instantiated by its own `create_provider(config)` factory. A factory that returns `None` leaves the provider out of the registry entirely — which is how an unconfigured source disables itself.
| Directory | Class | Switched on by | Pull task |
|---|---|---|---|
| `mirobody_garmin_connect/` | `GarminProvider` | `GARMIN_CLIENT_ID` + `GARMIN_CLIENT_SECRET` | None — webhook only |
| `mirobody_whoop/` | `WhoopProvider` | `WHOOP_CLIENT_ID` + `WHOOP_CLIENT_SECRET` | inherits the base default — `True` |
| `mirobody_oura/` | `OuraProvider` | `OURA_CLIENT_ID` + `OURA_CLIENT_SECRET` | Scheduled |
| `mirobody_pgsql/` | `PgsqlProvider` | `ENABLE_PGSQL_DEVICE` — any non-empty value | None — validation only |
**Behavior when credentials are absent.** A provider whose credentials are absent declines to start and the platform logs
```text
Provider OuraProvider declined to start (not configured)
```
The module loaded; it simply has nothing to authenticate with. The one you can try immediately is the PostgreSQL provider: set `ENABLE_PGSQL_DEVICE: 1` and the next boot logs `loaded 1 providers`.
Each of the other three is an OAuth client of that vendor, so it stays dormant until you supply credentials **you** obtained from the vendor's developer program. The exact callback URLs, the config keys, and how to tell "not configured" apart from "broken" in the boot log are in the repository's [provider-setup guide](https://github.com/thetahealth/mirobody/blob/main/docs/provider-setup.md).
The metadata each one advertises is a plain `ProviderInfo`, built without touching the network.
The PostgreSQL provider is the odd one out in two ways. It is the only `CUSTOMIZED` source — it declares five `connect_info_fields` (username, password, host, port, database) rather than sending the user through a browser — and it is the only one gated by an explicit feature flag instead of by whether credentials exist.
It also stops at validation: it opens a connection, runs `SELECT version()`, closes it, and stores the credentials. Fetching and formatting are deliberate no-ops, so linking it produces no records — it exists so a deployment can record a database connection, not to import from one.
Garmin and Whoop ship with their auth URLs, token URLs and API base URLs already filled in by `config.yaml`; only the client id, client secret and redirect URL are blank. Oura is the exception — none of its keys appear in the template at all, so `OURA_CLIENT_ID` / `OURA_CLIENT_SECRET` / `OURA_REDIRECT_URL` have to be added to your own `config.{env}.yaml` before the provider loads. Same for `ENABLE_PGSQL_DEVICE`.
## Apple Health
Apple Health is not a plugin: the `apple` platform registers its provider itself, so nothing from `PROVIDER_DIRS` can be added to it.
Both of its providers declare `auth_type` `NONE` and report as permanently connected — there is no account to link, because the client pushes data in rather than the server pulling it out. Three routes accept those pushes:
| Route | Body | Notes |
|---|---|---|
| `POST /api/v1/pulse/apple/health` | `metaInfo` + `healthData[]` | The main import; `Content-Encoding: gzip` accepted |
| `POST /api/v1/pulse/apple/statistics` | `metaInfo` + `statistics[]` | Client-computed aggregates (sum / average / minimum / maximum / mostRecent) written through as summary indicators |
| `POST /api/v1/pulse/apple/cda` | `metaInfo` + `cdaData[]` | Clinical Document Architecture documents |
All three require a JWT, and both `/apple/*` and `/api/v1/pulse/apple/*` answer, because an uploader may point at either mount. The type vocabulary this channel accepts is cross-platform rather than specific to Apple's health store — see [Data Flow](/en/concepts/data-flow).
This surface has two gaps you need to know about before building on it. `apple_health` never appears in `GET /api/v1/pulse/providers` — the channel is reachable but not discoverable. And the CDA provider exists but is not installed in the registry, so the CDA route finds nothing to dispatch to: an upload is accepted and nothing is written.
## Configuration keys without a provider
`config.yaml` and the pull scheduler both name sources this checkout does not contain. They are leftovers from a wider deployment, and setting the keys will not make a source appear:
| Leftover | Where it appears | Why nothing happens |
|---|---|---|
| `VITAL_API_KEY`, `VITAL_ENVIRONMENT` | `config.yaml` | No `vital` platform is registered, so `POST /api/v1/pulse/vital/generate-sign-in-token` answers `503 Vital platform not available` |
| `RENPHO_API_BASE_URL`, `RENPHO_APP_VERSION`, `RENPHO_PLATFORM`, `RENPHO_ENCRYPTION_KEY` | `config.yaml` | There is no `mirobody_renpho/` directory and no Renpho provider class anywhere in the tree |
| `FRONTIERX_CLIENT_ID`, `FRONTIERX_USER_POOL_ID` | `config.yaml`, commented out | Not read by any provider |
| `theta_renpho`, `theta_vital`, `theta_cgm` | the cadence tables in `pull_task.py` | Intervals and lock durations for slugs no shipped provider claims |
The providers that ship are the four theta providers above plus the apple platform's own two. Anything else is not in this branch.
`GET /api/v1/pulse/providers` is the authoritative list for a running instance: it walks the registry, so it shows exactly what loaded. If a source you configured is missing from it, read the startup log — provider loading is best-effort, and a failing factory is only a warning.
## Next steps
Fill in the keys, connect an account, watch records arrive
One shipped implementation read end to end
The contract all of these implement
Add a fifth source of your own
---
# Using Providers
https://docs.mirobody.ai/en/providers/using-providers
The real /api/v1/pulse routes: fill in a provider's OAuth keys, connect an account by browser or by form, see what a user has linked, and let webhooks or the pull scheduler bring the data in.
## The HTTP routes
Everything a user or a vendor calls lives under `/api/v1/pulse`. Operator endpoints live under `/api/v1/manage` and are authenticated by an `sk` query parameter instead of a JWT.
| Route | Auth | Purpose |
|---|---|---|
| `GET /api/v1/pulse/providers` | JWT optional | Every registered provider; per-user status merged in when a token is present |
| `GET /api/v1/pulse/user/providers` | JWT | Only what this user has linked |
| `POST /api/v1/pulse/user/providers/link` | JWT | Start or complete a connection |
| `GET /api/v1/pulse/{platform}/{provider}/callback` | none | Where the vendor sends the browser back |
| `POST /api/v1/pulse/user/providers/unlink` | JWT | Drop a connection |
| `POST /api/v1/pulse/user/providers/update-llm-access` | JWT | Toggle whether the agent may read this source |
| `POST /api/v1/pulse/{platform}/{provider}/webhook` | none | Vendor pushes, provider named in the path |
| `POST /api/v1/pulse/{platform}/webhook` | none | Vendor pushes, provider inferred from the body |
| `GET /api/v1/pulse/providers/indicators` | none | The indicator names and units a device maker may send |
| `POST /api/v1/pulse/{platform}/token` | none | Exchange a device maker's own user id + certification for a Mirobody token |
Every `/api/v1/pulse` response uses the same envelope — `{"code": 0, "msg": "ok", "data": {…}}` on success, `{"code": …, "msg": "…"}` on failure. Note that failures come back with **HTTP 200** and a non-zero `code`; check the body, not the status line. The management routes share the success shape but report errors as `{"code": …, "detail": "…"}`.
## Configure the provider's credentials
Providers read their keys through `safe_read_cfg`, so the usual three-layer resolution applies: environment variable, then `config.{env}.yaml`, then `config.yaml`. See [Configuration](/en/configuration) for how that layering and the automatic encryption of `_SECRET` / `_KEY`-suffixed values work.
Garmin and Whoop already have their endpoints filled in by the shipped template; only the three client-specific values are blank:
```yaml config.yaml
# Garmin Platform Configuration.
GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_token
GARMIN_AUTH_URL: https://connect.garmin.com/oauthConfirm/
GARMIN_ACCESS_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/access_token
GARMIN_API_BASE_URL: https://apis.garmin.com/wellness-api/rest
OAUTH_TEMP_TTL_SECONDS: 900
GARMIN_CLIENT_ID: ""
GARMIN_CLIENT_SECRET: ""
GARMIN_REDIRECT_URL: ""
# Whoop Platform Configuration.
WHOOP_SCOPES:
offline read:recovery read:sleep read:cycles read:profile read:workout
read:body_measurement
WHOOP_TOKEN_URL: https://api.prod.whoop.com/oauth/oauth2/token
WHOOP_AUTH_URL: https://api.prod.whoop.com/oauth/oauth2/auth
WHOOP_API_BASE_URL: https://api.prod.whoop.com/developer/v2
WHOOP_CLIENT_ID: ""
WHOOP_CLIENT_SECRET: ""
WHOOP_REDIRECT_URL: ""
```
Put your own values in `config.{env}.yaml` rather than editing the template. Oura has no block in the template at all, and the PostgreSQL provider needs its feature flag, so those go in the same file:
```yaml config.localdb.yaml
GARMIN_CLIENT_ID: your_garmin_consumer_key
GARMIN_CLIENT_SECRET: your_garmin_consumer_secret
GARMIN_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_garmin/callback
WHOOP_CLIENT_ID: your_whoop_client_id
WHOOP_CLIENT_SECRET: your_whoop_client_secret
WHOOP_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_whoop/callback
OURA_CLIENT_ID: your_oura_client_id
OURA_CLIENT_SECRET: your_oura_client_secret
OURA_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_oura/callback
ENABLE_PGSQL_DEVICE: "1"
BACKEND_SERVER_SK: a_long_random_string
```
Three things about that block are worth spelling out:
- **The redirect URL must be the callback route for that exact provider.** Nothing derives it for you: Garmin hands `GARMIN_REDIRECT_URL` straight to Garmin as the `oauth_callback`, and the source comments that the callback is served by `/api/v1/pulse/{platform}/{provider}/callback`. Register the same URL on the vendor's developer portal.
- **`OAUTH_TEMP_TTL_SECONDS` (default 900) bounds the handoff.** Between "we generated the authorization URL" and "the browser came back", the token secret and the user id live in Redis under that TTL. A user who leaves the vendor's consent screen open longer than 15 minutes has to start again.
- **`BACKEND_SERVER_SK` is what the `sk` query parameter is compared against**, and it is not in the shipped template. Without it every `/api/v1/manage/*` route answers `500 Server configuration error: management key not configured`.
User credentials are stored AES-GCM-encrypted, and `encrypt_string_aes_gcm` takes its key from `DATABASE_DECRYPTION_KEY` — a key `config.yaml` ships as the literal `REPLACE_THIS_VALUE_IN_PRODUCTION` and labels deprecated. Deprecated or not, it is the key in the encryption path today, and its value is used as the raw AES key, so a replacement has to keep a valid AES length (the placeholder is 32 characters). Change it *before* the first user links anything: rotating it later makes every stored token undecryptable.
## List the connectable sources
`GET /api/v1/pulse/providers` walks the registry, so it reflects exactly what loaded at startup. The JWT is optional: without one you get static metadata, with one the router overwrites `status` and fills in the per-user counters.
```bash
curl "http://localhost:18080/api/v1/pulse/providers" \
-H "Authorization: Bearer $JWT"
```
Connected providers sort first, then unconnected, then unsupported; within the unconnected group a fixed priority list puts Whoop and Garmin near the top. Each entry looks like this:
```json
{
"slug": "theta_garmin",
"name": "Garmin Connect",
"description": "Garmin fitness and health data integration via OAuth",
"logo": "https://static.thetahealth.ai/res/garmin.png",
"supported": true,
"auth_type": "oauth1",
"status": "available",
"platform": "theta",
"connected_at": null,
"last_sync_at": null,
"record_count": 0,
"allow_llm_access": false,
"connect_info_fields": null
}
```
Two optional query parameters narrow it: `platform` (only that platform's providers) and `status` (`connected` / `unconnected` / `unsupported`). A `nocache` flag is forwarded to each platform's `get_providers`, and `owner_user_id` returns another user's providers — refused unless a sharing permission check passes.
`connect_info_fields` is the field that tells a client which flow to render: `null` means send the user to a browser, a list means draw that form. Only `theta_pgsql` returns a list.
## Connect an account
One route starts every connection — `POST /api/v1/pulse/user/providers/link` — but it behaves in two quite different ways depending on the provider. Its `auth_type` field is an enum with four accepted values: `password`, `oauth2`, `token`, `customized`.
Garmin, Whoop and Oura all return a URL and finish on the callback. Send `oauth2` as the `auth_type` — the three OAuth providers override `link()` themselves and build the authorization URL without inspecting that field, so there is no `oauth1` value to send for Garmin:
```bash
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/link" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"provider_slug": "theta_garmin",
"platform": "theta",
"auth_type": "oauth2",
"return_url": "https://your-app/settings/devices"
}'
```
The response carries `data.link_web_url`. Open it, let the user consent, and the vendor redirects to your configured callback.
A `CUSTOMIZED` provider is validated and stored in this one request — no browser, no callback. Send the `connect_info` object matching the `connect_info_fields` the provider advertised:
```bash
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/link" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"provider_slug": "theta_pgsql",
"platform": "theta",
"auth_type": "customized",
"connect_info": {
"username": "readonly",
"password": "secret",
"host": "pg",
"port": "5432",
"database": "analytics"
}
}'
```
The provider's `_validate_credentials_v2` runs first, so a wrong password or an unreachable host fails the request rather than storing a broken connection. A `PASSWORD` provider would use `auth_type: "password"` with top-level `username` / `password` instead — no shipped provider uses that path.
You never have to pass the right `platform`: any `provider_slug` beginning with `theta_` is rerouted to the `theta` platform before dispatch.
`POST /api/v1/pulse/user/providers/link`. The provider stores whatever it needs for the round trip in Redis, keyed by the OAuth token (OAuth 1.0a) or by the `state` value (OAuth 2.0), and returns `link_web_url`.
Open `link_web_url` in a browser or popup. The user authenticates with the vendor, not with you.
`GET /api/v1/pulse/{platform}/{provider}/callback`. It is unauthenticated by design: the caller is the vendor's redirect, not the user. Identity is recovered from Redis rather than the query string, so a forged `user_id` cannot be used.
If a `return_url` survived the round trip, it 302s there with `code`, `success`, `platform`, `provider` and `provider_slug` appended. Otherwise it returns a small HTML page that `postMessage`s a `_OAUTH_COMPLETE` message (for example `GARMIN_OAUTH_COMPLETE`) to `window.opener` and closes itself — which is what makes the popup pattern work without a redirect target.
## Where credentials are stored
Both flows end in the same place: one row per user and provider in `health_user_provider`. Which columns get filled depends on the link type — `username` + `password` for `PASSWORD`, `access_token` + `access_token_secret` for `OAUTH1`, `access_token` + `refresh_token` + `expires_at` for `OAUTH2`, a `connect_info` JSON object for `CUSTOMIZED`. Secrets are encrypted on the way in and decrypted only when a pull needs them.
Relinking is atomic: a single data-modifying CTE soft-deletes the previous active row and inserts the new one in the same statement, so a failure cannot leave a user with the old credentials deleted and no new ones. The write also forces `reconnect = 0`, clearing any earlier "needs reconnect" flag.
That flag is how a broken connection surfaces. `get_all_user_credentials_for_provider` only returns rows with `reconnect = 0`, so a user marked for reconnection is skipped by the scheduler instead of being retried forever, and `GET /api/v1/pulse/user/providers` reports their status as `reconnect` rather than `connected`.
## Inspect and gate what is connected
`GET /api/v1/pulse/user/providers` returns just this user's connections — slug, status, and the connection timestamps. It is the cheaper call when you only need to know whether something is linked; unlike `GET /api/v1/pulse/providers` it does not run the statistics pass, so `record_count` stays `0` and `last_sync_at` stays `null` there.
Whether the agent may read a source is a separate, per-connection switch:
```bash
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/update-llm-access" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"provider_slug": "theta_garmin", "platform": "theta", "llm_access": false}'
```
Linking sets `llm_access` to 1, so a newly connected source is readable by default; this route is how a user turns that off. It surfaces in the provider list as `allow_llm_access`.
## Data delivery
There are exactly two ways in, and which one a provider uses is its own decision — see the cadence table in [Pulse Provider System](/en/concepts/providers).
**Webhooks.** A push-based vendor posts to `POST /api/v1/pulse/{platform}/{provider}/webhook`; for Garmin that is `/api/v1/pulse/providers/theta_garmin/webhook`. Naming the provider in the path is the reliable form. The shorter `POST /api/v1/pulse/{platform}/webhook` infers the provider from the body instead — a top-level `source` string, or `data.source.slug`. Both routes are unauthenticated, and both read a `Svix-Id` request header as the idempotency key, falling back to a timestamp string when it is absent.
**Scheduled pulls.** A provider that asks for a scheduled task gets one, and it loads every linked user's credentials and fetches from the vendor per user. Whoop and Oura work this way; Garmin does not.
Either way the payload lands in the same place: the provider's `save_raw_data_to_db`, then `format_data_v2`, then the record writer. The `type` field of each produced record must be a registered indicator name — see [Health Indicators](/en/concepts/indicators) — and [Data Flow](/en/concepts/data-flow) covers what happens after the write.
## Disconnect
```bash
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/unlink" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"provider_slug": "theta_garmin", "platform": "theta"}'
```
The base implementation soft-deletes the row. A provider may override `unlink` to tell the vendor as well, which Garmin does — and it treats a failed vendor call as an error even though it always removes the local row, so a `500` here can still mean "locally disconnected, vendor not informed".
Already-stored records are not deleted by unlinking. Unlinking stops new data; it is not a data-erasure call.
## Operator endpoints
The management routes take `?sk=` and no JWT. The ones that matter while wiring a provider up:
```bash
MANAGE="http://localhost:18080/api/v1/manage"
# what loaded, and what the scheduler thinks it is doing
curl "$MANAGE/pulse/providers/providers?sk=$SK"
curl "$MANAGE/theta/pull/status?sk=$SK"
curl "$MANAGE/theta/pull/config?sk=$SK"
# stop waiting for the timer
curl -X POST "$MANAGE/theta/pull/trigger?sk=$SK" \
-H "Content-Type: application/json" \
-d '{"provider_slug": "theta_oura", "force": true}'
# what arrived, and what one payload formats into
curl "$MANAGE/pulse/providers/webhooks?sk=$SK&provider=theta_garmin&page=1&page_size=20"
curl "$MANAGE/pulse/providers/check_format?sk=$SK&provider=theta_garmin&id=123"
# what a user ended up with
curl "$MANAGE/pulse/user-data-sources?sk=$SK&user_id=505"
curl "$MANAGE/pulse/user-indicators?sk=$SK&user_id=505"
curl "$MANAGE/pulse/user-health-data?sk=$SK&user_id=505&start_date=2026-08-01&end_date=2026-08-05"
```
`check_format` is the one to reach for first when records are missing: it reloads a stored raw payload by id, runs the provider's `format_data_v2` over it, and returns the original next to the normalised result — so you can see whether the problem is ingestion or mapping without redoing the vendor round trip. `POST /api/v1/manage/theta/pull/start` and `/stop` control the scheduler itself, and `user-health-data` refuses ranges longer than seven days.
## Next steps
Which sources exist and what each one needs
A full implementation of the flows above
Write a source of your own
Check your mapping against fixtures
---
# File Processing
https://docs.mirobody.ai/en/concepts/file-processing
How an uploaded document becomes text, a summary and health readings: the supported formats, tiered vision-LLM extraction, content-hash deduplication, and the keys that control it.
Uploading a lab report is one of the [three intake paths](/en/concepts/data-flow) health data takes into the engine, and the only one whose source is natural-language text rather than a schema. The engine stores the file, pulls text out of it, has a model read that text against a JSON schema, and writes the indicators the model finds as readings. All of it is Python, with no build-time switches.
## Supported formats
An upload is dispatched by content type, and the checks are **ordered** for two reasons: a genotype export is also `text/plain`, so it has to be recognised before the text branch can claim it; and the text branch is a **whitelist** (`text/plain` or `text/markdown`) rather than `text/*`, because `text/csv` has to fall through to the CSV branch.
| Format | Recognised by | Where text comes from | Extracts indicators |
|---|---|---|---|
| Genotype export | `text/plain` **and** a WeGene header marker | No text extraction — genotype parsing handles it | No |
| Images | `content_type` starting with `image/` | Vision LLM | Yes |
| PDF | `application/pdf` | The embedded text layer, else a vision LLM | Yes |
| Audio | `content_type` starting with `audio/` | Speech to text | No |
| Text / Markdown | `text/plain` or `text/markdown` | Read directly | Yes |
| Excel | Extension **or** MIME: `.xlsx` `.xls` `.xlsm` `.xlsb` | The workbook rendered as text | Yes |
| CSV | Extension **or** MIME: `.csv` | An injected processor | Depends on that processor |
There is no archive row: `.zip` is not a supported upload format.
Two of these are conditional, and both fail silently rather than loudly. **CSV** needs an external `csv_processor` injected through `file_parser/config.py`; without one, uploading a `.csv` only logs that a CSV was detected with no processor available, and the file is not processed. **Excel** degrades instead: it tries the injected processor and falls back to built-in spreadsheet extraction, so a workbook is always handled — sometimes more coarsely.
The genotype check is deliberately narrow: one marker string and one MIME type, nothing else. It reads only the first 100 bytes, so it costs almost nothing.
## Upload channels
| Channel | Route | Shape | Used for |
|---|---|---|---|
| REST | `POST /files/upload` | multipart `files[]` plus an optional `folder` query parameter | Batch uploads. Stores the files and returns key / URL / size / type for each. |
| WebSocket | `/ws/upload-health-report?token=…` | `upload_start` → many `upload_chunk` → `upload_end` | Chunked uploads with live progress. An optional `connectionId` lets a client reconnect to its own session. |
The socket authenticates from the `token` query parameter rather than a header, because a browser cannot set headers on a WebSocket handshake. It disconnects after 5 idle minutes, relaxed to 30 while an upload is active. Stored files are read back through `GET /files/{file_path}`, which proxies object storage so one URL works both in a browser and inside a container, and rejects `..` outright.
`POST /files/upload` only stores the file and returns its key; it does not run extraction. The chat upload path and the two WebSocket paths are what drive it. Listing and deletion go through `GET /api/v1/data/uploaded-files` and `POST /api/v1/data/delete-files`, over the `th_files` table, and deletion is soft.
Three behaviours matter directly to whoever writes the client:
- **The upload response does not wait for extraction.** It returns once the summary is ready; indicator extraction finishes in the background and then writes its results and an indicator count back to `th_files`.
- **A failed upload still returns its `file_key`.** A retry can point at the same object instead of orphaning it.
- **Only formats that produce text extract indicators.** Audio and genotype exports deliberately opt out.
## Text extraction
Pulling text out is where the money goes, so it is tiered: the cheap route first, and a model is paid for only when the cheap route comes back empty.
The text layer is read first. More than 100 characters after stripping whitespace means this is a born-digital document and no model is called at all. A scan yields roughly zero characters here and falls through.
The file goes to a vision provider along with the extraction prompt. Priority is `gemini > openrouter > qwen > doubao`, decided by which API key is configured; with none configured it raises rather than guessing.
A workbook is rendered as text and a text file is read directly. Neither needs a model.
A second, cheap model call turns the original text into a summary of at most 150 characters and a descriptive filename shaped like `Date_Content_Description.ext`, using only the first 8,000 characters. This step is synchronous: the upload does not return until the summary lands, with a fallback summary if it fails.
A long PDF is not sent as one block: it is split into single-page files and processed concurrently — parallel from two pages up, at most five pages at a time — pages with obviously no numbers are skipped, and the temporary page files are cleaned up afterwards.
PDF reading is done entirely by pure-Python libraries, and that is the point: there is no native PDF library to compile and no compile-time "is PDF supported" switch. Every format in the table above is available in every install.
## Reading extraction
The original text goes to the model with a JSON schema, `temperature=0.1`, and a prompt generated in the caller's language. The model returns three things: an array of indicators, the report date used as the readings' timestamp, and a file summary.
Results are deduplicated and then written into `th_series_data`: `source_table` is `'th_files'`, `source_table_id` carries the file key, and the unit, reference range and detection method are serialised as JSON into the encrypted `comment`. When the report date is missing, the reading is stamped with the user's current local time rather than dropped.
The indicator names the model returns are **free text** — whatever the report called that row, in whatever language it was printed in. They are stored as-is rather than forced into the registry, which is exactly why the read side uses semantic search instead of equality matching. See [Health Indicators](/en/concepts/indicators).
## Deduplication by content hash
The same PDF often gets uploaded twice: one person on two devices, or two family members sharing one report. Pulling text out is the expensive step, so the key is the content rather than the identity: the bytes are hashed with SHA-256, a cache hit returns the already-extracted text with no model call, and a miss extracts and then inserts skip-on-conflict, so two concurrent uploads of the same bytes cannot collide.
The cache is global and keyed only by hash, so the second upload of a known document is nearly instant. Indicator extraction still runs per user: what is shared is the *text*, not the *readings*.
## Configuration
Two config keys govern this part, both overridable by environment variable, and neither changes what was compiled in:
```yaml config.yaml
# Vision model for image/PDF parsing. Key name = _VISION_MODEL.
GEMINI_VISION_MODEL: gemini-3.5-flash
EMBEDDING_PROVIDER: gemini
```
| Key | Default | What it does |
|---|---|---|
| `_VISION_MODEL` | The provider's own default | Overrides the vision model. The key name follows the provider, so switching provider means reading a differently named key. |
| `EMBEDDING_PROVIDER` | `gemini` | `gemini` or `qwen`. Decides which embedding model makes extracted indicators findable afterwards. |
Extraction needs at least one model key. With no vision provider configured (`GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `DASHSCOPE_API_KEY`, `VOLCENGINE_API_KEY`), text extraction raises an error naming all four rather than quietly producing empty text. Spreadsheets and text files still work with no key at all: their text needs no model.
`config.yaml` still carries an `ENABLE_INDICATOR_EXTRACTION` key that **no code reads** — setting it to `0` does not turn extraction off. To turn it off, leave the vision providers unconfigured.
## Next steps
Where extracted readings land, and the other two intake paths
How a report's free-text indicators become searchable
The tools the agent reads files and health data back with
The three config layers these keys live in
The source for this part lives in [`mirobody/pulse/file_parser/`](https://github.com/thetahealth/mirobody/tree/main/mirobody/pulse/file_parser).
---
# Health Indicators
https://docs.mirobody.ai/en/concepts/indicators
② Standardize: the offline resolver, the concept graph, the 300-member indicator registry, unit conversion at ingest, and how resolver coverage is measured.
An **indicator** is the name of a measured quantity — `heartRates`, `bloodGlucoses`, `dailyTotalSteps`. It is the key every reading is stored under and the only handle a question has on the data, which makes it the one place where a Garmin watch, an Oura ring and a scanned lab report have to agree. This page is about how that agreement is arranged, and about what happens when it cannot be: a report row named `空腹血糖` in one file and `Fasting Glucose (FPG)` in another is still the same measurement, and no registry is ever going to contain both spellings.
Two mechanisms answer those two cases, and they are worth keeping apart from the start.
## Two mechanisms: the registry and the resolver
An indicator name is only queryable if every spelling of the same test ends up with the same identity. `LDL cholesterol`, `低密度脂蛋白胆固醇` and `LDL-C` all name one measurement, and no fixed list can enumerate every way it is written. The engine handles the two cases separately:
| Mechanism | Covers |
|---|---|
| **The registry** | names the engine defines itself: device streams, derived aggregates |
| **The resolver** | names written elsewhere: a row on a report, a foreign-language printout |
The resolver can be used on its own: after `pip install mirobody`, `resolve("血红蛋白").loinc` needs no database, key or network. See [The Engine as a Library](/en/engine).
### Standardization resources
The terminology resources that ship with the package, distributed through Git LFS:
| Resource | Size |
|---|---|
| Concept-graph nodes (440,961 of them carry cross-vocabulary edges) | 745,620 |
| Cross-vocabulary edges (LOINC ↔ SNOMED CT ↔ RxNorm) | 22,044,110 |
| Sibling groups (covering 595,746 nodes) | 199,959 |
| Multilingual aliases (中文 22,578 · 日本語 16,809 · ru · es · fr · ko · de) | 49,253 |
| UCUM unit families | ~310 |
| Registry indicators | 300, in 13 categories |
The bundles are row-aligned; a loader aborts rather than half-aligning if the row counts disagree.
If an indicator name does not resolve, or resolves to the wrong analyte, an issue or a pull request is welcome — refining this vocabulary is where outside contributions help most, and one alias mapping plus one test case is a complete contribution. See [Contributing](/en/development/contributing).
### Coverage criteria
`mirobody/test_engine_coverage.py` scores the offline resolver against the panels a physical routinely orders: lipid, CBC, metabolic, liver, thyroid, hormones, tumour markers, urinalysis and vitals, written the way a report prints them, in English, 中文 and 日本語.
```bash
pytest mirobody/test_engine_coverage.py -s
# offline resolver coverage: 116/116 = 100%
```
Scoring is on clinical correctness rather than resolution rate:
- Resolving `血红蛋白` to the code for HbA1c counts as a failure, with no partial credit.
- `血圧` is a panel name rather than a single observation, so the correct result is nothing at all, not one of its components.
## The registry
`StandardIndicator` is an enum of 300 members. Each carries an `IndicatorInfo` declaring its category, standard unit, data type, English and Chinese names, and the aggregations available to it. The 300 split 167 summary / 111 series / 22 mixed across 13 categories (vital signs, body composition, activity, metabolic, sleep, performance, medical, device-specific, nutrition, lifestyle, health, mental, reproductive).
Three conventions matter when using them:
- **Names are `lowerCamelCase` and usually plural** (`heartRates`, `bloodGlucoses`), because they name a stream of readings rather than one reading. Derived aggregates follow `daily{Method}{Indicator}`, so `dailyAvgHeartRates` is derived and `heartRates` is raw.
- **An unrecognised name passes through unchanged.** `normalize_indicator_name()` only canonicalises case; it does not reject unknown names, which is how a free-text indicator reaches the write path and is then handled by the resolver.
- **Every registry indicator has a target unit.** Asking for the standard unit of an unregistered indicator raises `ValueError`, because a conversion with no target is an error rather than something to fall back from.
## Unit conversion
A value is converted once, on write, so every reader downstream can assume it is in the indicator's standard unit. Conversion tries indicator-specific rules, then a general conversion table, then nothing at all.
The behaviour of that last step is the one to know: **when no rule applies, the value is stored with the unit it arrived in rather than being relabelled.** A reading therefore cannot go silently wrong — a `mg/dL` value never sits in a row claiming `mmol/L`, and the caller sees the unconverted unit and can act on it.
Some conversions are clinical estimates rather than unit arithmetic (`PaO2` → `SpO2%`, for instance), and some cannot be generalised because the molar masses differ — the `mg/dL` ↔ `mmol/L` factor for glucose is not the one for cholesterol. Both exist as indicator-specific rules.
A **second, unrelated** unit layer also exists, and it does not convert values. It parses a free-text unit string into canonical UCUM and reports the matching LOINC PROPERTY family, which answers a different question: a document says "毫摩尔每升" — what unit is that?
Units in the same family convert into one another; converting across families is a category error. This layer is exposed through the `normalize_unit` tool — see [Built-in Tools](/en/tools/built-in).
## The role of FHIR
FHIR appears throughout this engine as a **vocabulary layer**: code systems used to identify what a measurement is, and to retrieve it. It is **not** an API. There are no `/fhir/*` routes, no resource table, and nothing that reads or writes a FHIR `Resource` — see [Architecture Overview](/en/concepts/architecture) for the full route list. This engine does not implement a FHIR server.
What does exist is a coding map: the coding table is loaded into memory at startup, and the write path answers "which `fhir_id` does this name have" from that cache with no database call.
Two config keys gate it. `FHIR_TABLE_AUTO_R` must be `"true"` for the mapping to load at all — otherwise the `fhir_id` column simply stays empty. `FHIR_TABLE_AUTO_W` enables auto-registration, in which case a name that was not in the table is registered by a later aggregation pass. Mirobody's own indicators are registered under the vocabulary name `THETA`, alongside the standard ones.
## Free-text indicators
The [file intake path](/en/concepts/file-processing) produces indicator names nobody registered: whatever a lab report called that row, in whatever language it was printed in. Forcing them into the registry at write time would mean guessing, and a wrong guess is unrecoverable — so they are stored verbatim and resolved at *read* time instead.
The worker keeps assigning codes to those names, cheapest and most confident judgement first: a deterministic registry match, then a name whose already-mapped rows all point at one code, and only then a ≥99% historical majority. A genuinely ambiguous name — "pain", spread across body sites — never reaches 99% and is deliberately left unmapped; those are found by vector search instead, which is why every unmapped name gets an embedding.
Embeddings are **1024-dimensional** either way: `gemini` requests `output_dimensionality: 1024`, `qwen` uses `text-embedding-v4` with `dimensions: 1024`. One key, `EMBEDDING_PROVIDER`, chooses between them, and the column name follows the provider — which is why switching providers means re-embedding rather than flipping a flag.
A search runs in four steps:
1. **Embed.** The keywords, plus their concatenation when there is more than one — so "MCHC" and "Mean Corpuscular Hemoglobin Concentration" contribute both separately and together.
2. **Vector recall.** One recall over coded concepts and one over this user's free-text indicators. Both are scoped to the user — an indicator they have no readings for cannot come back.
3. **Graph expansion.** One step out along the concept graph: across vocabularies (SNOMED CT ↔ LOINC ↔ RxNorm) and to near neighbours within one (LOINC codes sharing a component, drugs in the same ATC subgroup). A query that hits one spelling of a concept therefore also finds the user's rows filed under a related code.
4. **Merge and threshold.** Coded and free-text hits are merged, free-text hits filtered at a floor of `0.6` or the weakest coded score — whichever is higher — then sorted by score.
`search` is per-user and is what the agent's `query_health_indicators` tool calls. `resolve` / `resolve_many` run the *global* direction instead — free text to canonical codes, for ETL and terminology mapping. `resolve_many` is 20–30× faster than looping `resolve`, since it batches one embedding call and one matrix multiply. Both are on the CLI: `python -m mirobody.indicator search ` and `python -m mirobody.indicator resolve "blood glucose" --systems LOINC`.
## Vocabulary distribution
The concept graph and the indexes are build artifacts rather than source; they are tracked with Git LFS. Two things matter when deploying:
- **The offline resolver's vocabulary ships with the package.** It is small, which is why `resolve()` needs nothing external.
- **The embedding index does not ship** (~1.4 GB). Container deployments mount it and point `FHIR_INDICATORS_DIR` at that directory. Without it, semantic search falls back to pgvector in the database and the lexical resolver is unaffected.
Cloning without Git LFS installed leaves pointer files behind, and the failure is not obvious: search still answers, just more slowly and without graph expansion, rather than raising an error. Install Git LFS before cloning.
## Indicators across the API surface
| Endpoint | Returns |
|---|---|
| `GET /api/v1/pulse/theta/indicators` | The indicators available to a caller. |
| `GET /api/v1/health-indicators` | A user's own readings, with the drawer's source-file link; `POST /api/v1/health-indicators/reading` edits or deletes one, owner only. |
| `GET /api/v1/manage/pulse/indicators` | The full registry grouped by category. |
| `GET /api/v1/manage/pulse/units` | The standard-unit set and the conversion table. |
| `GET /api/v1/manage/pulse/indicators-and-units` | Both in one response. |
| `GET /api/v1/manage/pulse/user-indicators` | Which indicators one user actually has readings for. |
| `GET /api/v1/manage/pulse/std-indicators/status` · `POST …/trigger` | The standard-indicator sync state, and a manual kick. |
The `/api/v1/manage/*` entries are guarded by a management key, not by a user's bearer token — they are operator endpoints, not part of the app-facing surface.
Inside a chat turn, none of these are what the agent uses — it gets the single `query_health_indicators` tool, described in [Built-in Tools](/en/tools/built-in). That tool asks for **both** the abbreviation and the expansion (`["MCHC", "Mean Corpuscular Hemoglobin Concentration"]`), because an abbreviation alone embeds poorly against a corpus written in full names. And when nothing matches it returns the user's **catalog** rather than an empty list, so the model picks from what exists instead of re-guessing keywords.
## Next steps
Where readings come from and how the agent reads them back
The intake path that produces free-text indicator names
Why a provider must map its fields onto registered indicator names
Choosing indicators and units for a new source
The source for this step lives in [`mirobody/indicator/`](https://github.com/thetahealth/mirobody/tree/main/mirobody/indicator).
---
# Data Flow
https://docs.mirobody.ai/en/concepts/data-flow
The three intake paths health data enters Mirobody through, the two tables it lands in, the scheduled aggregation and insight passes over it, and the single tool the agent reads it back with.
Health data reaches the engine through **three intake paths**: a provider that Mirobody pulls or that pushes a webhook, a batch of samples the client app reads on the device and uploads, and a document you upload and an LLM reads. They differ in almost everything except the destination — every reading ends up in the same PostgreSQL tables, keyed by user and by **indicator** name. From there a scheduled pass turns raw readings into daily summaries, a second pass mines them for insights, and during a chat turn the agent reads them back through a single tool.
## Path 1 — provider pulls and webhooks
A source with a server API is polled on a schedule; a source that can push is left alone until its webhook arrives. Which route applies is the provider's own declaration: one that needs polling gets a scheduled task at startup, one that only pushes gets none. The polling interval and the distributed-lock duration are configured per provider, defaulting to hourly with a thirty-minute lock — see [Pulse Provider System](/en/concepts/providers) for the per-provider values.
Webhooks enter at `POST /api/v1/pulse/{platform}/webhook` (or `/{platform}/{provider}/webhook`). Pulls and webhooks converge on the same normalized write, and **the pull path takes no HTTP round trip** — it hands the data in in-process.
On a poll, the provider loads every linked user's credentials, fetches from the vendor per user, drops whatever it recognises as already processed, and hands the rest to the write path.
## Path 2 — on-device batch import
An on-device health store has no server API to pull, so the client app reads the samples locally and POSTs them in batches. Apple Health is this channel's main case, but the channel is not Apple-specific: the type vocabulary is the Flutter `health` plugin's **cross-platform** names (the same names hold on iOS HealthKit and Android Health Connect), and it also carries twelve body-composition scale fields. Three endpoints take them, and both `/apple/*` and `/api/v1/pulse/apple/*` answer, because an uploader may point at either mount:
| Endpoint | Body | What it does |
|---|---|---|
| `POST /apple/health` | `{ request_id, metaInfo, healthData[] }` | The main import. Accepts `Content-Encoding: gzip` — a gzipped JSON body, not Apple's export archive. |
| `POST /apple/statistics` | `{ metaInfo, statistics[] }` | Client-side pre-aggregated `sum` / `average` / `minimum` / `maximum` / `mostRecent` per grouping, written straight to `th_series_data`. |
| `POST /apple/cda` | `{ request_id, metaInfo, cdaData[] }` | Clinical Document Architecture documents. |
Samples are mapped by type onto standard indicator names. Two behaviours are silent, and a client author needs to know both:
- A sample whose type is **not in the mapping is discarded**, with a warning naming the type, the UUID and the value. Nothing is stored under a guessed name.
- A sleep-stage sample is **duplicated** into a second total-sleep record, so total sleep exists as a first-class reading rather than being recomputed by every reader.
A successful write triggers an incremental aggregation immediately, so the client sees fresh summaries without waiting for the scheduled pass; a failure there is only logged, because the scheduled pass is the safety net.
The CDA endpoint is wired end to end, but it produces no readings yet: a CDA upload is accepted and nothing is written. Don't build on it.
## Path 3 — file extraction
Uploading a lab report is not a data-source integration; it is an LLM reading a document. A PDF or an image is turned into text, the text is handed to a model with a JSON schema, and the indicators the model returns are written as readings. The mechanics of the extraction are on [File Processing](/en/concepts/file-processing); what matters here is where it lands.
Each extracted indicator is written straight into `th_series_data`, with `source_table` set to `'th_files'` and `source_table_id` carrying the file key — which is how a reading stays traceable back to the page it came from. The unit and the reference range do not get their own columns: they are written as JSON into the encrypted `comment`. The worker then materialises any newly seen indicator names and backfills their embeddings, so semantic search becomes available after the write rather than during it.
This path is the one that produces **free-text indicator names** — whatever the report called the row, in whatever language. They are stored as-is rather than forced into the registry, and made findable afterwards by semantic search. See [Health Indicators](/en/concepts/indicators).
## One normalized model
Paths 1 and 2 converge on the same normalized write, which does five things per record before anything reaches the database:
That last step is a fork rather than an either/or: an indicator can be both a summary and a series indicator, in which case the same reading is written to both tables.
## Two tables
| Table | Holds | Uniqueness | Delete |
|---|---|---|---|
| `series_data` | Raw timestamped readings — the minute-by-minute stream | `(user_id, indicator, source, time)` | Physical. There is no `deleted` column. |
| `th_series_data` | Summary and mixed readings, plus everything aggregation and file extraction produce | `(user_id, indicator, start_time, end_time)` | Soft, via `deleted = 1`. |
The two writes on paths 1 and 2 are upserts, so replaying the same batch is a no-op rather than a duplicate; path 3 skips on conflict, so re-uploading the same file neither duplicates nor updates the rows already there. `th_series_data` also carries `fhir_id` (looked up from the coding registry — see [Health Indicators](/en/concepts/indicators)), `fhir_mapping_info` holding the unit as JSON, and a `comment` encrypted inside the database.
There is a third mode for the case an upsert cannot express: a client that re-uploads a corrected window. When a batch's `metaInfo.taskId` looks like `repair-` and carries `windowFrom` / `windowTo`, the engine sweeps in-window rows the batch did **not** re-confirm — hard-deleting from `series_data`, soft-deleting from `th_series_data`. Rows written by an earlier batch of the same repair are protected, so a repair split across several uploads converges instead of eating itself. An incomplete window skips the sweep and only the upsert applies.
## Aggregation
Raw readings are not what a question like "how did I sleep last month" wants. A scheduled pass refreshes daily summaries incrementally **every few minutes**: it remembers where it stopped and aggregates only what changed since, so freshly written data becomes queryable by day almost immediately (with a cold-start fallback of the last 24 hours when there is no cursor).
The rules are not a hand-maintained list; they are generated from the registry. Every series indicator that declares aggregation methods yields one rule per method, naming the target `daily{Method}{Indicator}` — so `heartRates` with `['avg', 'max', 'min']` yields `dailyAvgHeartRates`, `dailyMaxHeartRates`, `dailyMinHeartRates`. Adding an aggregate means adding a method to an indicator definition. The supported methods are `avg`, `max`, `min`, `total`, `count`, `last`, `first`, `stddev`, `variance`, `median` and `p95`.
One rule breaks the day boundary, and it has to. Sleep does not fit inside `00:00–24:00`, so sleep indicators are grouped on an **18:00-to-18:00** window in the user's own timezone: a night that starts at 23:00 on the 1st and ends at 07:00 on the 2nd belongs to one day, not two halves. The grouping and the query use the same definition.
Derived indicators that need more than one source get a second, slower pass (every 6 hours). A bounded backfill is available at `POST /api/v1/manage/aggregate/recalculate-range` — capped at 30 days when no `user_id` is given, because an all-users range grows with the active user count.
## Insights
The insight engine runs **every 6 hours**. Per user it computes a baseline, builds a profile of which indicator categories have enough density, picks the recipes that profile can support, and runs only those. Each recipe declares which indicator categories it needs, how many days of density and overlap, and how long it must wait before reporting again — so a user with two weeks of heart rate and no glucose never runs the glucose recipe.
Six recipes ship:
| Recipe | Category | Requires | Density | Cooldown |
|---|---|---|---|---|
| `multi_signal_deterioration` | anomaly | `heartRate` | 14 days, 10 overlapping | 3 days |
| `single_sustained_anomaly` | anomaly | — (any of seven optional) | 14 days | 7 days |
| `long_term_trend` | trend | — | 21 days | 14 days |
| `recovery_trend` | recovery | `heartRate` | 14 days | 7 days |
| `weekday_weekend_pattern` | pattern | `steps` | 21 days | 30 days |
| `glucose_control` | anomaly | `bloodGlucose` | 14 days | 7 days |
Results are persisted and exposed at `GET /api/v1/pulse/user/insights`, with feedback at `POST /api/v1/pulse/user/insights/{insight_id}/feedback`. The cooldown is what keeps the same observation from being reported every six hours.
## Reading data back
During a chat turn the agent does not query SQL. It gets **one** tool, `query_health_indicators`, with search, read and aggregate collapsed into a single call:
Rows that came from a file carry a `file_key` back in the result, so the agent can open the source document. Every result also carries its `system` / `code` identity: **the same code means the same test, whatever the names** — which is what makes a Garmin reading and a lab row comparable.
The tool runs as the injected caller identity and refuses to run without a user, which is what makes every health read user-scoped by construction rather than by remembering a `WHERE` clause.
It is discovered at runtime like any other tool, so the allow/deny lists apply: `DISALLOWED_TOOLS_DEEP: [query_health_indicators]` removes the read path from that agent without touching the ingest side. On the MCP surface it is also **data-gated** — an account with no health rows is not offered it at all. See [Built-in Tools](/en/tools/built-in).
## Scope of the FHIR layer
FHIR is used here as a **vocabulary layer**, for *coding and retrieval*: a `fhir_id` column and an embedding index over LOINC / SNOMED CT / RxNorm concepts, described on [Health Indicators](/en/concepts/indicators). The engine exposes no FHIR REST store — there are no `/fhir/*` routes and no resource table. Readings are rows in `series_data` and `th_series_data`, and the only way in is the three intake paths above.
## Mapping to Mirobody Cloud
[Mirobody Cloud](/en/api-reference) organises writes by **data shape**; self-hosting organises them by **mechanism**. The two sides are separate codebases and readings do not land in the same columns, so use the table rather than assuming a shared name:
| Cloud `/v1` | Equivalent in the self-hosted engine |
|---|---|
| [`POST /v1/data`](/en/api-reference/data) (structured readings you already hold) | **Paths 1 and 2** above. Cloud does not host device OAuth, so the provider-platform half has no Cloud equivalent: vendor authorisation and the webhook pipeline stay in your own product, and you write to `/v1/data` once you hold the samples. |
| [`POST /v1/files`](/en/api-reference/files) | The file parsing of **path 3**. |
| [`POST /v1/standardize`](/en/api-reference/extract) | The library surface's `parse_file()` and `resolve()`, or the `mirobody parse` / `mirobody resolve` CLI — see [The Engine as a Library](/en/engine). |
| Cloud only | The four `retention` tiers, Subject (`user`) multi-tenancy, the 500-record request cap, and `source` values `api` / `extract` / `upload` / `consolidation`. |
| Self-host only | The minute-by-minute raw stream in `series_data`, the `daily{Method}{Indicator}` aggregates, the six insight recipes, and the repair window. |
The field names differ too: a Cloud reading carries `parsed_value`, `parsed_unit`, `loinc_code`, `canonical_name` and `fhir_resource_id`, while this engine converts to a standard unit and carries `fhir_id` and `fhir_mapping_info`.
One behaviour differs between the two sides. On Cloud, all four writers (`/v1/data`, `/v1/files`, `/v1/standardize`, and readings consolidated from stored conversations) converge on one standardization pipeline, so readings extracted from a file also carry a code and a UCUM unit. In this engine only paths 1 and 2 take the normalized write; **path 3 does not**: it keeps the report's own free-text indicator names and relies on semantic search to reconcile them afterwards. Standardization is therefore identical on both sides for structured readings, and not identical for file extraction.
## Next steps
How path 1 is built: the base class, link types, scheduled pulls
How path 3 turns a PDF into readings
The registry, unit conversion, and semantic search
Where the Pulse data plane sits in the whole engine
The source for this step lives in [`mirobody/pulse/`](https://github.com/thetahealth/mirobody/tree/main/mirobody/pulse).
---
# Agent Types
https://docs.mirobody.ai/en/tools/agents
DeepAgent and BaseAgent — who runs the tool loop, what each does with a turn, and how to configure their providers, prompts and tools
Two agent runtimes ship, and the difference between them is not size — it is **who runs the
tool loop**. Both read the same [tool registry](/en/tools/overview) and answer on the same
`POST /api/chat`.
## Choosing between them
| | **DeepAgent** — you run the engine | **BaseAgent** — your model consumes ours |
|---|---|---|
| Tool loop runs | here, in your deployment | in the LLM provider, against `/mcp` over HTTP |
| Harness | LangChain `create_agent` + the `deepagents` middleware stack | none, on purpose |
| Virtual filesystem | yes, PostgreSQL-backed | no |
| Code execution | `eval` — in-process JavaScript REPL | no |
| Agent Skills | yes, through `SkillsMiddleware` | no |
| Charts | a fenced `vis-chart` block the client renders | none — the consuming client brings its own |
| Providers key | `PROVIDERS_DEEP` | `PROVIDERS_BASE` |
| Use it when | this is the default — pick it unless you have a reason not to | you are targeting Claude Desktop, Cursor, ChatGPT Apps or any MCP client |
DeepAgent is the workhorse: file reading, computation and multi-step tool use all live
there. BaseAgent is deliberately the thinnest possible derivation over the MCP tool
surface, so its capability matches exactly what an external MCP client gets: **anything
BaseAgent cannot do unaided, an outside MCP client cannot do either.**
## Agent selection per turn
Agents are discovered the same way tools are — by scanning directories at startup.
```bash
curl http://localhost:18080/api/models
```
```json response
{"success":true,"code":0,"msg":"ok","data":["Base/gemini-2.5-flash","Deep/claude-sonnet","Deep/gemini-3.5-flash"]}
```
The client therefore chooses both halves. Omit `provider` and DeepAgent falls back to
`DEFAULT_PROVIDER_DEEP`, or to `gemini-3.5-flash` when that is unset; BaseAgent falls back
to `gemini-2.5-flash`. An agent with zero usable clients is not offered at all, so emptying
`PROVIDERS_BASE` is how you switch BaseAgent off.
## DeepAgent
DeepAgent builds a `deepagents` agent per turn out of four things: the discovered tools,
a system prompt, a PostgreSQL-backed filesystem and a middleware stack.
### The virtual filesystem
With an authenticated user, the backend is a `CompositeBackend` of five mounts. The
`deepagents`-native file tools — `ls`, `read_file`, `write_file`, `edit_file`, `glob`,
`grep` — operate on it as if it were a disk:
| Mount | Scope | Access | Holds |
|---|---|---|---|
| *(default)* | this session | read/write | scratch space for the turn |
| `/memories/` | cross-session | read/write | notes the agent chooses to keep |
| `/uploads/` | this session | read-only | the files attached to this request |
| `/library/` | cross-session | read-only | the user's earlier parsed files |
| `/skills/` | packaged | read-only | the Agent Skills from `SKILL_DIRS` — the agent must never edit its own skills |
`/uploads/` and `/library/` are mirrored from the `th_files` table as pointers — no bytes
are copied into PostgreSQL. Parsed text is inlined so `grep` works; the raw bytes are
surfaced multimodally when the model reads the file. An anonymous call gets an in-memory
`StateBackend` instead, so nothing persists.
### The middleware stack
Six middlewares wrap a DeepAgent turn, in the order they are applied:
Two things `deepagents` would otherwise contribute are deliberately **not** present:
- **No `task` subagent.** Streaming a subagent run delays every event until it finishes, and
here `task` would only ever be a no-op self-clone. It is disabled through a registered
harness profile rather than through `DISALLOWED_TOOLS_DEEP`.
- **No `write_todos`.** `deepagents` 0.7 dropped `TodoListMiddleware` from its default stack,
and DeepAgent does not add it back.
The `delete` file tool is also excluded by name: the PostgreSQL filesystem backend does not
implement it, so it is not offered.
## BaseAgent
BaseAgent has no LangChain agent loop, no middleware and no virtual filesystem. It renders a
prompt, hands **our MCP server** to the provider, and streams the result — the tool loop
belongs to the provider.
**The admission rule:** BaseAgent clients speak Responses-style APIs only, because the whole
point is handing our MCP server to someone else's agent loop. A provider that offers only
Chat Completions is consumed through DeepAgent's LangChain stack instead — a local
function-call loop here would just duplicate it.
| Provider | Class | Default model | Server-side MCP | Stateful |
|---|---|---|---|---|
| OpenAI Responses | `OpenAIResponsesClient` | `gpt-5-nano` | yes | yes |
| Gemini Interactions | `GeminiClient` | `gemini-2.5-flash` | local function fallback | yes |
| DeepSeek Responses | `DeepSeekResponsesClient` | `deepseek-v4-flash` | no — `function` and server-side `web_search` only | no |
| DashScope Responses | `DashScopeClient` | `qwen3.5-flash` | no — its MCP accepts `server_protocol: "sse"`, ours is streamable HTTP | yes |
Two more consequences worth knowing before you choose it:
- It trims history to the **last five user turns** by default (`user_message_threshold`).
- Its provider entries carry no `llm_type`. The client class is picked from the **prefix of
the provider key** — `gemini…`, `gpt…`, `deepseek…`, `qwen…`/`dashscope…`. A key matching no
known prefix silently gets no client, and the provider then disappears from `/api/models`.
`PROMPTS_BASE` is real: left empty, BaseAgent uses its packaged
`mirobody/agent/prompts/base.jinja`; set a path and that path wins. Unlike `PROMPTS_DEEP` it
is not selectable per request — the provider runs the tool loop, so there is nowhere to
branch — and a deployment listing several templates gets the first. `GET /api/prompts?agent=base`
advertises exactly what it resolved.
## Charting
The same question produces a different artefact depending on which agent answered.
| | **DeepAgent** | **BaseAgent** |
|---|---|---|
| Produced by | the model writing a fenced `vis-chart` block of pure-data JSON inline | nothing |
| Rendered by | the web client, interactively, from the JSON in that block | the consuming MCP client's own visualization |
| Server round-trip | none — no tool call, no PNG | n/a |
The tool surface does not include chart-rendering tools. Charts are produced by DeepAgent
writing a `vis-chart` data block, which the client renders directly; BaseAgent's prompt
directs the model to use text and tables instead. Historic chart PNGs are still served
from the `/charts` volume.
The tools themselves are documented in [Built-in Tools](/en/tools/built-in).
## Configuring providers
Each agent reads its own `PROVIDERS_` key, suffixed with the agent name in upper case:
```yaml config.localdb.yaml
PROVIDERS_DEEP:
gemini-3.5-flash:
llm_type: google-genai
api_key: GOOGLE_API_KEY
model: gemini-3.5-flash
temperature: 1.0
claude-sonnet:
llm_type: openai
api_key: OPENROUTER_API_KEY
base_url: https://openrouter.ai/api/v1
model: anthropic/claude-sonnet-4.6
temperature: 0.1
qwen-vl:
llm_type: openai
api_key: DASHSCOPE_API_KEY
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-vl-max
supports_pdf: true
supports_image: true
```
The key of each entry is the provider name the client asks for. Inside it:
Which LangChain integration to initialise — `google-genai` or `openai`. Anything reached
over an OpenAI-compatible endpoint (OpenRouter, DashScope, Volcengine) is `openai` plus a
`base_url`.
**The name of the key that holds the secret — not the secret.** See the warning below.
Endpoint for OpenAI-compatible providers. A literal URL works; a name that resolves to
one in the environment or config is also accepted.
The upstream model id. An entry without it is skipped at startup with a warning.
Passed straight through, as is any other key not listed here — `include_thoughts`,
`thinking_level` and friends reach the LangChain constructor unchanged.
Declare only for OpenAI-compatible endpoints, whose capabilities the engine cannot infer.
`true` sends PDFs as a native file block; unset serves them as pre-extracted text.
Same, for images.
**`api_key` holds a key *name*, not a key value.** The loader looks that name up in the
environment first and then in the config, so pasting the secret itself leaves the provider
broken — and it fails quietly: the entry still appears in `/api/models`, and the only
symptom is `Missing GOOGLE_API_KEY. Get API key from provider and set in .env or
environment` on the first turn that uses it. Put real secrets in your `config.{env}.yaml`,
where values under keys whose name contains `_KEY`, `_TOKEN`, `_SECRET` and friends are
encrypted at rest. See [Configuration](/en/configuration).
## Prompts
`PROMPTS_{NAME}` is a list of Jinja2 template paths. Each entry may pin the key the
template is registered under with a `path@key` suffix; without one, the key is the file
name minus `.jinja`:
```yaml config.yaml
PROMPTS_DEEP:
- agent/prompts/deep.jinja
```
DeepAgent selects a template by the `prompt_name` sent with the request, falling back to the
first one configured. A per-user prompt stored through the user-prompt API takes precedence
over both.
## Tool visibility
The same two keys as everywhere else, suffixed with the agent name in upper case:
| Key | Effect |
|---|---|
| `ALLOWED_TOOLS_{NAME}` | whitelist — when set, only these tools are offered |
| `DISALLOWED_TOOLS_{NAME}` | blacklist — applied after the whitelist, so it always wins |
The shipped `config.yaml` leaves every one of them empty, which means "all discovered
tools". It also carries `ALLOWED_TOOLS_RTC` / `DISALLOWED_TOOLS_RTC` / `PROMPTS_RTC`
placeholders left behind by a removed agent — harmless, and a reminder that the suffix is
just an agent name.
## Writing your own agent
`AGENT_DIRS` is scanned the same way `MCP_TOOL_DIRS` is. It ships with one entry — the
packaged `mirobody/agent` — so add your own directory and list it first:
```yaml config.{env}.yaml
AGENT_DIRS:
- agents # yours, scanned first
- mirobody/agent # the packaged default
```
The contract is small: one class per agent, an async generator called `generate_response`,
and — if the agent has providers — a `load_llm_clients` that turns its `PROVIDERS_` entry
into clients. Subclassing `DeepAgent` inherits both.
```python agents/triage_agent.py
from typing import Any, AsyncGenerator
from mirobody.agent.deep_agent import DeepAgent
class TriageAgent(DeepAgent):
"""Registers as agent name "Triage"; configured with PROVIDERS_TRIAGE,
ALLOWED_TOOLS_TRIAGE, DISALLOWED_TOOLS_TRIAGE and PROMPTS_TRIAGE."""
async def generate_response(
self, user_id: str, messages: list[Any], **kwargs
) -> AsyncGenerator[dict[str, Any], None]:
async for event in super().generate_response(user_id, messages, **kwargs):
yield event
```
Agents loaded from `PRIVATE_AGENT_DIRS` instead are registered but kept out of
`/api/agents` and `/api/models` — usable from your own code, invisible to clients.
## Next steps
How agents, tools, skills and `/mcp` fit together
What every agent can call, parameter by parameter
Teach an agent a procedure without writing code
Provider keys, prompts, and the three config layers
---
# Tools & Agent Overview
https://docs.mirobody.ai/en/tools/overview
How agents, runtime-discovered Python tools, skills, and the MCP endpoint fit together
Four moving parts do the work in a chat turn, and this page is the map between them.
Two runtimes — DeepAgent and BaseAgent — differing in who runs the tool loop.
Plain Python functions, found by scanning directories when the server starts.
Directories of instructions an agent reads on demand — prose, not code it calls.
`/mcp` — the same tools, over JSON-RPC 2.0, for outside clients.
## Two agents
Mirobody ships two agent runtimes and picks one per chat turn. Both read from the same
tool registry; what differs is **who runs the tool loop**.
| Agent | Shape of a turn |
|---|---|
| **DeepAgent** | LangChain `create_agent` plus the `deepagents` middleware stack, over a PostgreSQL-backed virtual filesystem, with an in-process JavaScript REPL. The loop runs here. |
| **BaseAgent** | No LangChain. It hands `/mcp` to the provider and streams the result — the loop runs in the provider. |
Which one answers, which providers each can use, and how their prompts are configured
is the subject of [Agent Types](/en/tools/agents).
## Runtime discovery
There is no registration macro and no build step. When the server starts, the engine walks
every directory named in `MCP_TOOL_DIRS`, imports each module it finds, and turns the
eligible callables into MCP tools.
The rules, in full:
- **Directories** come from the `MCP_TOOL_DIRS` config key. The shipped default is the
single packaged directory `mirobody/agent/tools`; add your own and list it first.
- **Files** must end in `.py`. A leading underscore means "not a tool module" — which is
how `__init__.py` and private helpers stay out of the registry.
- **Functions** at module level are registered as tools. A leading underscore excludes them.
- **Classes** are only considered when the class name ends in `Service`. Inside such a
class, public methods become tools; `_`-prefixed methods, inherited methods and methods
imported from another module are ignored.
- **A class may opt out** by defining a static `_enabled() -> bool`. Return `False` and the
whole class is skipped, so none of its tools exist. This is how optional integrations
disappear cleanly when their API key is not configured.
Because discovery happens at startup, adding a tool means dropping a `.py` file into one
of those directories and restarting the process — no rebuild, no code generation, no
schema written by hand. [Adding Custom Tools](/en/tools/adding-tools) walks a file from
empty to callable.
### Identity injection
A tool that needs to know who is asking declares a `user_info` parameter. It is stripped
from the schema the model sees, and filled in by the server from the authenticated
caller before the function runs:
```python
{"success": True, "user_id": "...", "session_id": "..."}
```
So the model never supplies — and cannot forge — an identity; a tool reads it with
`user_info.get("user_id")` and scopes its query to that user.
## Skills
Skills are the other half of "what an agent knows how to do", and they are not tools.
A skill is a directory containing one `SKILL.md`: its frontmatter says when to reach for it
and is injected into every prompt, its body is read only when a task calls for it. The
content becomes context, not a function call. See [Agent Skills](/en/tools/skills).
## The MCP endpoint
The same registry is served at `/mcp` over JSON-RPC 2.0,
so Claude Desktop, Cursor and any custom client can call exactly the tools your own
agents call. `tools/list` is unauthenticated — and honest per account: the two tools bound
to user data are listed only when that account actually holds that kind of data.
```bash
curl -X POST http://localhost:18080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
The endpoint faces both ways: it serves tools outward, and Mirobody also keeps a
per-user registry of *external* MCP servers. [Mirobody MCP Server](/en/tools/mcp-integration)
covers client setup, remote HTTPS access and OAuth.
## Tool visibility
Two config keys per agent narrow the registry, using the agent name in upper case as the
suffix — `DEEP` and `BASE` for the two shipped agents:
| Key | Effect |
|---|---|
| `ALLOWED_TOOLS_{NAME}` | Whitelist. When set, only these tools are offered. |
| `DISALLOWED_TOOLS_{NAME}` | Blacklist. Applied after the whitelist, so it always wins. |
Leave both empty — as the shipped `config.yaml` does — and every discovered tool is
available to that agent.
## Next steps
What ships in the box, parameter by parameter
Drop a `.py` file in and restart
Connect Claude, Cursor, and remote clients
`MCP_TOOL_DIRS`, provider keys, and everything else
---
# Built-in Tools
https://docs.mirobody.ai/en/tools/built-in
The four MCP tools the engine ships — terminology, health records and genetics — plus what DeepAgent gets from its harness.
Every tool below is an ordinary Python method that [runtime discovery](/en/tools/overview) picked up. Nothing here is special-cased by the engine, and nothing here is compiled in: the same rules that find these would find yours.
The MCP surface is small on purpose. Four tools ship, and the authoritative list is whatever your own server reports — ask it:
```bash
curl -X POST http://localhost:18080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
The four tools come in three groups: terminology resolution (`resolve_indicator`, `normalize_unit`), health-record reads (`query_health_indicators`) and genetic-data reads (`get_genetic_data`). Two of the four need **nothing** — no account, no network, no key — and two are bound to the caller's own records:
| Tool | What it does | Needs |
|---|---|---|
| `resolve_indicator` | any-language indicator name → canonical LOINC | nothing — offline, no user data |
| `normalize_unit` | free-text unit → canonical UCUM + comparability family | nothing — offline, no user data |
| `query_health_indicators` | the caller's own records: search, read and aggregate in **one** call | the account |
| `get_genetic_data` | the caller's variants by rsID | the account |
**`tools/list` is honest per account.** The two account-bound tools are listed only when that account actually holds that kind of data: no health rows, no `query_health_indicators`; no genotype rows, no `get_genetic_data`. A client is never offered a tool that can only answer "you have nothing".
## Terminology — the ② Standardize surface
These two tools give the model a way to put a name on the same footing as a code. "LDL cholesterol", "低密度脂蛋白胆固醇" and "LDL-C" are three strings for one measurement; both tools turn any of them into the same canonical identity. Neither reads user data, so they work for an anonymous caller and disclose nothing.
Resolution runs against the bundles shipped inside the package. A client can be air-gapped and these still answer, which is exactly the property health data deserves.
### resolve_indicator
Names exactly as printed. Pass the whole batch in one call.
Returns one result per input, in order: `name` unchanged, `resolved`, `loinc` (e.g. `718-7`, empty when unresolved), `canonical` (the LOINC long common name) and `candidates` — how many corpus rows matched. A large `candidates` count means genuine ambiguity, and one sensible default was chosen; surface that when precision matters.
Three properties the model is told about explicitly, because they change how an answer should be written:
- **Unresolved is an honest "no".** Report the name as unmatched; never invent a code.
- **Panel names deliberately do not resolve.** `blood pressure` and `血圧` name a panel, not an observation — the right response is to ask which measurement (systolic or diastolic).
- **The same code from two names means the same test.** That, not string equality, decides whether two readings are comparable.
### normalize_unit
Unit strings as printed, e.g. `["mg/dL", "毫摩尔每升", "次/分"]`. Up to 200 per call.
Returns `unit` unchanged, `ucum` (canonical form; empty means unrecognized) and `family` — the LOINC PROPERTY, e.g. `SCnc`.
The family is the point: **units in the same family are convertible, and converting across families is a category error, not arithmetic.**
## Health records
### query_health_indicators
This single tool combines search, read and aggregate in one call, and every result carries its canonical identity.
Fuzzy terms, any language. For shorthand, include both forms — `["MCHC", "Mean Corpuscular Hemoglobin Concentration"]`.
Exact names from a previous call. Use instead of `keywords`, not alongside guesses.
Inclusive start date, `YYYY-MM-DD`.
Inclusive end date, `YYYY-MM-DD`.
One of `none`, `stats`, `day`, `week`, `month` — declared as an enum in the schema, so an invalid value is rejected before any engine code runs. `stats` returns count/min/max/avg/first/last/change per indicator; a bucket returns one point per bucket. Trend questions should use one of those rather than pulling raw readings.
Maximum readings per indicator when `aggregate` is `none`. Bounded at 500 in the schema itself.
The response is shaped so the model can keep going without guessing:
| Field | What it is |
|---|---|
| `indicators` | Per match: `indicator` (the exact name, reusable as `indicators`), `system` / `code` (the canonical identity — same code means the same test, whatever the names), `count`, and `rows` as a pipe-delimited table. A leading `(constants: k=v)` line carries columns identical on every row, typically the unit. |
| `catalog` | Returned **instead of** `indicators` when no filter was given, and also when nothing matched — it lists what this user actually has, so the model picks from reality rather than re-guessing keywords. |
| `truncated` | Indicator → total available, when a series was cut by `limit`. The fix is a narrower window or an aggregate, not a bigger limit. |
The tool's own description tells the model: **absence of data is not absence of the condition.** The user may simply never have recorded it, and an answer must say so rather than concluding they are healthy.
## Genetics
### get_genetic_data
dbSNP identifiers — `"rs4988235"` or `["rs1801133", "rs429358"]`. A comma-separated string also works.
Maximum variants returned.
Also return variants within `nearby_range` of each hit, capped at 20 per hit. Set `false` for exact lookups only.
Half-window in base pairs.
This reads **the user's own uploaded genotype file**, not a reference database. Consumer arrays type a small fraction of the genome, so **absent ≠ negative**: an rsID missing from the result was not typed, and says nothing about the allele.
## Additional tools from the DeepAgent harness
Two tools on the list above are not the whole story for [DeepAgent](/en/tools/agents): its `deepagents` harness contributes more, and those extras are not MCP tools — an outside MCP client does not see them.
| From the harness | What it is |
|---|---|
| `ls` · `read_file` · `write_file` · `edit_file` · `glob` · `grep` | The native file tools, operating on the PostgreSQL-backed virtual filesystem. `read_file` on an uploaded PDF hands the model the *original document*, multimodally, instead of a lossy extraction. |
| `eval` | A persistent, in-process JavaScript REPL from `langchain-quickjs` — how the model does real computation over data it fetched. |
| Agent Skills | Not tools: `SKILL.md` procedures injected as frontmatter and read in full only when a task calls for it. See [Agent Skills](/en/tools/skills). |
`delete` is excluded by name because the PostgreSQL filesystem backend does not implement it. There is no `task` subagent and no `write_todos`.
**The current tool surface does not include chart-rendering or memory tools.** Charting is DeepAgent writing a `vis-chart` data block that the client renders, with no server round-trip. `generate_*_chart`, `search_user_memories` and `get_user_memory_profile` are not part of this tool surface.
## Next steps
Your own tool is one `.py` file — including the identity-injection rule
Which agent sees which tools, and who runs the loop
Reaching these tools from Claude Desktop, Cursor or your own client
What "canonical identity" means, and how the resolver is scored
---
# Mirobody MCP Server
https://docs.mirobody.ai/en/tools/mcp-integration
The /mcp endpoint — local clients, remote HTTPS access, personal URLs and OAuth
Mirobody serves its tools at **`/mcp`** over **JSON-RPC 2.0**. It is the same registry the engine's own agents read, so a client like Claude Desktop or
Cursor calls exactly the [tools](/en/tools/built-in) your agents call — including the
ones you [added yourself](/en/tools/adding-tools).
The server listens on `HTTP_HOST:HTTP_PORT`, which is **`0.0.0.0:18080`** in the shipped
`compose.yaml`. Every URL on this page assumes that port.
The Model Context Protocol is a standard interface for AI applications to reach tools,
resources and context. See [modelcontextprotocol.io](https://modelcontextprotocol.io).
## Server and client in one
Mirobody is an MCP **server**, and it also has an MCP **client** side — the two are
separate mechanisms:
| Direction | What it is |
|---|---|
| **Outbound to you** | Your discovered tools, served at `/mcp` |
| **Inbound to Mirobody** | A per-user registry of *external* MCP servers, fetched with `tools/list` and converted to function descriptors |
A user's external servers are stored per account and managed over three JSON endpoints —
`GET/POST /api/user/mcp` to read the map, `POST /api/user/mcp/set` to add or replace one
entry, `POST /api/user/mcp/delete` to remove it. An entry is keyed by name:
```json
{
"my-server": {
"url": "https://example.com/mcp",
"token": "…optional bearer token…",
"enabled": true,
"order": 0
}
}
```
The loader walks that map, skips entries with `enabled: false` or an empty `url`, POSTs
`tools/list` to each remaining one with `Authorization: Bearer ` (falling back to
the caller's own JWT when no per-server token is set), and turns every returned tool into
a function descriptor built from its `name`, `description` and `inputSchema`.
Treat the inbound side as the extension point rather than a finished feature: in this
revision nothing in the chat path calls the loader yet, so registering a server records
the configuration but does not add its tools to a turn. To give your agents a new
capability today, write a tool — see [Adding Custom Tools](/en/tools/adding-tools).
## The endpoints
Three routes are registered, and they differ only in how the caller is identified:
| Route | Identifies the caller by | Purpose |
|---|---|---|
| `POST /mcp` | `Authorization: Bearer ` | The plain endpoint. Anonymous for tools that need no identity. |
| `POST /mcp/{secret}` | the secret in the path | A personal URL, for clients that cannot send a header. |
| `POST /personal/mcp` | `Authorization: Bearer ` | Not MCP itself — it mints and returns your personal URL. |
`GET /mcp` is registered too, but it is a placeholder for a future WebSocket transport
and does nothing useful today — use `POST`. `OPTIONS` returns `200` so a browser
preflight succeeds. All three routes sit under `HTTP_URI_PREFIX` when you set one.
## Methods the server implements
| Method | Behaviour |
|---|---|
| `initialize` | Negotiates the protocol version (see below) and reports a `serverInfo` name taken from `HTTP_SERVER_NAME`. |
| `server/discover` | The 2026-07-28 stateless discovery method. Advertises exactly the same capabilities as `initialize` — they share one declaration so they cannot drift. |
| `notifications/initialized` | Accepted, empty `200`. |
| `ping` | Empty result. |
| `tools/list` | Every discovered tool, **minus the ones this account has no data for**. No credentials needed. |
| `tools/call` | Runs one tool by `name` with an `arguments` object. |
| `resources/list` / `resources/read` | The resources found in `MCP_RESOURCE_DIRS`. On read, the server substitutes the current server URL and the caller's token into the resource text. |
| `prompts/list` | Always an empty list — the engine serves no MCP prompts. |
### Protocol versions
The server speaks [MCP 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/) — the current stateless revision: per-request `_meta`, `server/discover`, a required `resultType`, deterministic tool ordering, and no session id. Under that revision there is no handshake at all, so a client's very first request may be `tools/list`.
It negotiates down for older clients, accepting `2026-07-28`, `2025-11-25`, `2025-06-18`, `2025-03-26` and `2024-11-05`. A version it does not know is answered with its newest, and an explicitly unsupported one returns the spec-defined `-32022`.
### Data-gated listing
`tools/list` is honest per account. Before listing, the server probes whether the resolved caller actually holds each kind of data: no health rows hides `query_health_indicators`, no genotype rows hides `get_genetic_data`. A tool that could only ever answer "you have no data" is not worth a schema in every client.
The probe **fails open** — a database hiccup hides nothing, because shrinking the tool surface of a user who *does* have data is the worse failure.
Anything else comes back as JSON-RPC `-32601` (method not found). A body that will not
parse is `-32700`, a request with no `method` is `-32600`, and a `tools/call` with no
`params`, no tool name, or a tool name the server does not have is `-32602`.
## Caller resolution
Only tools that declare a `user_info` parameter need an identity. For those,
`tools/call` tries three things in order before it gives up:
That third case is returned as a **successful** JSON-RPC result whose content carries
`authorization_url`, `auto_open_browser` and an `auto_polling` block — not as an error. A
client that only inspects the `error` field will show the login prompt to the model as if
it were tool output. Handle it explicitly.
When the URL's secret carries an agent name, `tools/list` is filtered through that
agent's `ALLOWED_TOOLS_{NAME}` whitelist and then its `DISALLOWED_TOOLS_{NAME}`
blacklist. Note that this filtered listing is **not** the same code path as the plain
one: with neither key configured it comes back empty, so an agent-scoped URL wants at
least one of the two set.
## Connect a local client
Claude Desktop and Cursor speak MCP over stdio, so bridge them to the HTTP endpoint with
a proxy. Put this in your client's MCP configuration file:
```json
{
"mcpServers": {
"mirobody_mcp": {
"command": "npx",
"args": [
"-y",
"universal-mcp-proxy"
],
"env": {
"UMCP_ENDPOINT": "http://localhost:18080/mcp"
}
}
}
}
```
```bash
curl -sX POST http://localhost:18080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
A JSON-RPC result with a `tools` array means the endpoint is reachable and the
registry loaded.
Write the JSON above into the client's MCP config file, then restart the client
completely — most clients read that file only at startup.
Mirobody should show up in the client's list of MCP servers, with the tool names from
`tools/list` under it.
"Search my health indicators for HbA1c." The first call that needs an identity
triggers the login flow described above; after that, every call resolves to the same
account.
## Remote access
`http://localhost:18080/mcp` only works for a client on the same machine. For a remote
client — a cloud deployment, a ChatGPT App, a colleague's laptop — set `MCP_PUBLIC_URL`
to a publicly reachable HTTPS origin in your `config.{env}.yaml`:
```yaml config.localdb.yaml
MCP_PUBLIC_URL: 'https://yourdomain.com'
```
The engine uses that value when it has to hand out an absolute URL to something outside
the process, and prints it as the address to open when the server starts.
No public domain yet? The config template suggests a tunnel: install
[ngrok](https://ngrok.com), run `ngrok http 18080`, and set `MCP_PUBLIC_URL` to the
`https://…ngrok-free.app` origin it gives you.
## Personal MCP URLs
A personal URL embeds the identity in the path, for clients that cannot attach an
`Authorization` header. Ask for yours with your JWT:
```bash
curl -sX POST http://localhost:18080/personal/mcp \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{}'
# → {"code":0,"data":{"url":"http://localhost:18080/mcp/"}}
```
The secret is 96 bytes of URL-safe randomness, cached in Redis for a year, and the same
URL comes back on later calls until it expires. Post `{"user_id": "…"}` instead of an
empty body to mint a URL scoped to **another** account — allowed only when that account
has shared chat access with you, which the server checks before it agrees.
The engine also mints short-lived variants internally: those expire in ten minutes and
carry a session id and an agent name, which is what narrows `tools/list` to one agent's
tool set.
A personal URL is a bearer credential written into a URL — anyone holding it can call
every auth-scoped tool as you. Keep it out of shared configs, screenshots and logs.
## OAuth
For clients that do a proper OAuth handshake, the server publishes one metadata document
at three well-known paths — `/.well-known/oauth-authorization-server`, the same path with
`/mcp` appended, and `/.well-known/mcp-configuration`. They return identical content and
are registered at the server root, so `HTTP_URI_PREFIX` does not apply to them.
The document advertises the endpoints and the shape of the flow:
| Field | Value |
|---|---|
| `authorization_endpoint` | `/oauth/authorize` |
| `token_endpoint` | `/oauth/token` |
| `registration_endpoint` | `/oauth/register` — dynamic client registration is open |
| `introspection_endpoint` | `/oauth/introspect` |
| `grant_types_supported` | `authorization_code`, `refresh_token`, `client_credentials` |
| `code_challenge_methods_supported` | `S256` — PKCE only |
| `scopes_supported` | `openid`, `profile`, `email`, `offline_access`, plus `mcp:read`, `mcp:write`, `mcp:tools`, `mcp:admin`, `mcp:connect` |
There is also an `/oauth2/authorize` alias and `/oauth2/check_state/{state}`, which is
how the polling loop from a device-style login checks whether the browser step finished.
## Calling it with curl
`tools/list` needs no credentials, so it is the quickest way to see what a server offers:
```bash
curl -sX POST http://localhost:18080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
`tools/call` on a tool that declares `user_info` needs an identity — a bearer JWT here,
or a personal URL instead of the plain path:
```bash
curl -sX POST http://localhost:18080/mcp \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "query_health_indicators",
"arguments": { "keywords": ["HbA1c", "Hemoglobin A1c"], "aggregate": "stats" }
}
}'
```
The result carries a `content` array with the JSON-encoded tool output, an `isError` flag
taken from the tool's own `success` field, and — when the output is structured — a
`structuredContent` copy.
## Next steps
What `tools/list` will show you
Put your own tool on this endpoint
How agents, tools and skills fit together
`MCP_PUBLIC_URL`, `HTTP_PORT` and the config layers
---
# Agent Skills
https://docs.mirobody.ai/en/tools/skills
A skill is a directory holding one SKILL.md — served to DeepAgent through deepagents' native SkillsMiddleware, with progressive disclosure.
A **skill** teaches an agent a procedure without writing code. It is a directory holding one Markdown file: YAML frontmatter says what the skill is for, and the body says how to do it.
Mirobody supports [Agent Skills](https://agentskills.io/) through [deepagents](https://docs.langchain.com/oss/python/deepagents/overview)' native `SkillsMiddleware` — the same machinery LangChain's own deep agents use, not a bespoke loader.
## Skills and tools
A tool extends what the agent *can do*. A skill changes *how it does it* — which order to work in, what to check against, what never to say. Nothing executes: the model reads the procedure and follows it, using the tools it already has.
## Skill loading
```yaml config.{env}.yaml
SKILL_DIRS:
- skills # yours, checked first
- mirobody/agent/skills
```
The `/skills/` mount is **write-denied**. An agent must never edit its own skills, so `write_file` and `edit_file` are refused on that path even though they work everywhere else in the filesystem.
Skills belong to DeepAgent. `SkillsMiddleware` is skipped for anonymous sessions — those get an in-memory filesystem with no `/skills/` mount to read from — and BaseAgent has no filesystem at all, so a skill has no effect there. See [Agent Types](/en/tools/agents).
## The required file
A skill is a directory with a `SKILL.md`. **Nothing else is required** — no manifest, no JSON sidecar, no scripts.
## SKILL.md
YAML frontmatter, then Markdown. Two keys carry the contract:
| Key | Required | What it does |
|---|---|---|
| `name` | yes | The skill's identity, matching the directory name. |
| `description` | yes | **The routing decision.** This is the part injected into every prompt, so it must say both what the skill does *and* when to use it. |
| `license`, `metadata` | no | Free-form; carried along, not interpreted. |
```markdown SKILL.md
---
name: lab-report-walkthrough
description: Walk a person through their lab report — read the original document, organize
results by panel, flag out-of-range values against the printed reference ranges, compare
with their history, and explain in plain language. Use when the user uploads a lab report
(PDF/image) or asks what their blood test results mean.
license: Apache-2.0
metadata:
author: thetahealth
---
# Lab Report Walkthrough
Turn a raw lab report into an explanation a person can act on, without ever
drifting into diagnosis.
## Workflow
1. **Read the original, not a summary.** …
```
Write the `description` for a reader deciding *whether to open the document* — because that is literally the decision it drives. "Lab reports" is not enough; "Use when the user uploads a lab report (PDF/image) or asks what their blood test results mean" is.
## Progressive disclosure
This is why skills scale where a bigger system prompt does not:
Ten skills cost ten descriptions of standing context, not ten procedures. The agent pays for the body only when it decides the skill applies.
## The packaged skill
One skill ships, and it is meant to be read as the reference shape for your own:
| Skill | What it encodes |
|---|---|
| [`lab-report-walkthrough`](https://github.com/thetahealth/mirobody/blob/main/mirobody/agent/skills/lab-report-walkthrough/SKILL.md) | Read the original document rather than an extraction; organize by clinical panel rather than document order; flag against the **printed** reference range rather than a remembered one; compare with history when it exists; explain in plain language; never drift into diagnosis. |
Three of those steps are worth stealing verbatim for any health skill. Ranges differ by lab, method, age and sex, so the range on the page beats any range the model remembers. A single value is a dot and two are a direction, so history is worth checking before saying anything. And a walkthrough sorted out-of-range first, normals summarized in one line, is the difference between a useful answer and fifteen paragraphs of "this is fine".
A skill requires only a `SKILL.md` file: no manifest, and no `metadata.json` sidecar. The loader is `deepagents`' own.
## Writing your own skill
```bash
mkdir -p skills/medication-review
```
Frontmatter with `name` and `description`, then the procedure. Number the steps; the model follows them in order.
```markdown skills/medication-review/SKILL.md
---
name: medication-review
description: Review the user's current medications for interactions and timing
conflicts. Use when the user asks about their medications, adds a new one, or
asks whether two things can be taken together.
---
# Medication Review
## Workflow
1. **Read what they actually take.** Call `query_health_indicators` for
medication records before assuming anything from the conversation.
2. **Group by mechanism, not by name.** Two brand names may be the same drug.
3. **Name interactions as possibilities, not verdicts.** Say what to ask a
pharmacist, and never tell someone to stop a prescribed medication.
```
```yaml config.{env}.yaml
SKILL_DIRS:
- skills
- mirobody/agent/skills
```
Only the first existing directory is mounted, so list yours ahead of the packaged one — and if you want both, keep your own skills alongside a copy of the packaged skill in the same directory.
Ask the agent what skills it has. The frontmatter is in its prompt, so it can answer without reading anything.
## Limits of a skill
- **It cannot run code.** A procedure that needs computation has to call a tool — see [Adding Custom Tools](/en/tools/adding-tools).
- **It cannot grant access.** A skill that tells the agent to read data the caller has no right to still fails at the tool boundary, where identity is enforced.
- **It cannot restrict tools.** Tool visibility is `ALLOWED_TOOLS_*` / `DISALLOWED_TOOLS_*` in config, not something a skill can narrow.
## Next steps
When the procedure needs code, not instructions
The middleware stack that serves skills, and which agent has it
What a skill has to work with
`SKILL_DIRS` and the rest of the directory keys
---
# Adding Custom Tools
https://docs.mirobody.ai/en/tools/adding-tools
Drop a .py file into a tool directory, restart, and the engine turns your function into an MCP tool
A tool is a plain Python function. You do not register it, you do not write its JSON
schema, and there is nothing to compile: the engine reads your type hints and your
docstring at startup and builds the schema the model sees.
[Tools & Agent Overview](/en/tools/overview) sketches that discovery pass. This page is
the working procedure — where the file goes, what each part of it turns into, and how to
tell whether it loaded.
## File location
Discovery walks the directories listed in the `MCP_TOOL_DIRS` config key. It ships with
exactly one entry — the engine's own packaged directory — so your own tools go into a
directory you add yourself. List yours **first**: directories are scanned in order, which
is what lets a deployment override a packaged tool without editing the package.
```yaml config.localdb.yaml
MCP_TOOL_DIRS:
- tools # yours, scanned first
- mirobody/agent/tools # the packaged default
```
Inside a listed directory the rules are narrow, and worth knowing before you name the
file:
- Only files ending in `.py` are read, and **subdirectories are not walked** — a nested
package is invisible to discovery.
- A **leading underscore excludes the file**. `__init__.py` and private helper modules
are skipped for free; that is also the way to park a shared module next to your tools.
- **Module-level functions** are registered as tools, unless their name starts with `_`
or they were imported from somewhere else.
- **Classes** are only inspected when the class name ends in `Service`. In such a class,
public methods become tools; `_`-prefixed methods, methods inherited from a base class
and methods imported from another module are all ignored.
- An import error is logged and that one module is skipped. The server still starts, and
every other tool still loads.
One `.py` file may hold several `Service` classes and any number of module-level
functions. Grouping the tools that share a helper in one file keeps the helper private
without a `_`-prefixed module.
## A tool from scratch
The example below is a complete, working tool: it takes the caller's identity, one
required argument and one optional argument, reads the engine's own time-series store,
and returns a structured result.
```bash
touch tools/goal_service.py
```
The name of the file does not matter. The class name does — it has to end in
`Service`.
```python tools/goal_service.py
from datetime import datetime, timedelta
from typing import Any
from mirobody.utils import execute_query
class GoalService:
"""Compare a user's recorded readings against a target value."""
def __init__(self):
self.name = "Goal Service"
self.version = "1.0.0"
async def count_readings_above_goal(
self,
user_info: dict[str, Any],
indicator: str,
goal: float,
days: int = 30,
) -> dict[str, Any]:
"""
Count how many of a user's recent readings cleared a target value.
Call query_health_indicators first to get the exact indicator name.
Args:
indicator: Exact indicator name, as returned by query_health_indicators.
goal: The value a reading has to reach, in the indicator's own unit.
days: How many days back to look.
"""
user_id = user_info.get("user_id")
if not user_id:
return {"success": False, "error": "Authorization required."}
rows = await execute_query(
"""
SELECT tsd.start_time, tsd.value
FROM th_series_data tsd
WHERE tsd.user_id = :user_id
AND tsd.indicator = :indicator
AND tsd.deleted = 0
AND tsd.start_time >= :since
ORDER BY tsd.start_time DESC
""",
{
"user_id": user_id,
"indicator": indicator,
"since": datetime.now() - timedelta(days=days),
},
)
hits = []
for row in rows or []:
try:
if float(row["value"]) >= goal:
hits.append(str(row["start_time"]))
except (TypeError, ValueError):
continue
return {
"success": True,
"data": {
"indicator": indicator,
"goal": goal,
"readings_examined": len(rows or []),
"readings_above_goal": len(hits),
"times": hits[:20],
},
}
```
Add `tools` to `MCP_TOOL_DIRS` in your `config.{env}.yaml`, as shown above. Skip this
step if you dropped the file into a directory that is already listed.
```bash
docker compose restart mirobody
```
Discovery only runs at startup, so a new or edited tool needs a restart. Running from
a checkout instead, restart `mirobody serve`.
```bash
curl -sX POST http://localhost:18080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep count_readings_above_goal
```
The tool name is the **method** name — `count_readings_above_goal`. The class name is
only a filter for discovery; it never appears in the schema the model reads.
## Schema generation
Each parameter's type hint becomes one JSON Schema type. The mapping is small and there
is no validation layer behind it:
A parameter with no default value is **required**; one with a default is optional and
its default is advertised in the schema. A `None` default is deliberately left out of
the schema, because some model APIs reject a null there.
**Annotate the return type.** The required/optional split is computed from the number of
annotations, and the return annotation is counted. Leave `-> dict[str, Any]` off and the
last required parameter is silently published as optional, with the wrong default
attached. Every shipped tool annotates its return type; so should yours.
For an optional list, write `Optional[list[str]]` rather than `list[str] | None`. The
union form is not unwrapped, so it falls through to the `string` fallback and the model
is told to pass a string. Optional scalars (`str | None`) and optional dicts are
unaffected.
## Description generation
The docstring is the only thing the model has to decide whether and how to call your
tool. It is read in three parts:
The section header is matched case-insensitively and several spellings are accepted:
`Args:`, `Arguments:`, `Params:` and `Parameters:` (plus their singular forms) all open
the argument block; `Returns:` and `Results:` (and their singular forms) close it.
A key in the `Args:` block that does not match a parameter name is ignored, which is why
`user_info` is conventionally left out of the docstring — it is not in the schema, so
there is nothing to describe.
Write the description for the model, not for a code reviewer. Say what the tool does
**and when to reach for it**, and put a concrete example value in each parameter's line
— `e.g. "AAPL"`, `("YYYY-MM-DD")`. This does more for whether your tool gets called
correctly than anything else on this page.
## Tools that act on behalf of a user
Declare a `user_info` parameter and the engine fills it in from the authenticated caller
before your function runs. It is stripped from the schema, so the model can neither
supply nor forge it:
```python
{"success": True, "user_id": "...", "session_id": "..."}
```
Read the caller out of it and refuse the call when there is none:
```python
user_id = user_info.get("user_id")
if not user_id:
return {"success": False, "error": "Authorization required."}
```
Declare it as the **first** parameter, as every shipped tool does, and scope every query to
that `user_id`.
**Do not take a `user_id` parameter.** A tool shaped this way is both broken and unsafe:
- `user_id` is not the injection hook, so the server never fills it — the tool sees nothing.
- It stays visible in the tool's JSON Schema, so the **model** supplies it — meaning any MCP
client can ask for another person's data by passing a different value.
`user_info: dict[str, Any]` is the only parameter the server fills and hides. Tool loading
warns when it sees the `user_id` shape.
Over the [MCP endpoint](/en/tools/mcp-integration), a tool that declares `user_info`
makes the server resolve a caller first — from a bearer JWT or a personal MCP secret. A
tool without `user_info` is callable with no credentials at all, so only omit it for
genuinely public work.
## Registering only when configured
A `Service` class can decline to exist. Define a static `_enabled()`; return `False` and
the whole class is skipped, so **none of its tools appear anywhere** — not in the
agent's tool set, not in `tools/list`. This is how an optional integration disappears
cleanly instead of failing at call time — the pattern an optional integration of your own
should copy:
```python tools/my_integration_service.py
@staticmethod
def _enabled() -> bool:
"""Only register when MY_INTEGRATION_API_KEY is configured."""
from mirobody.utils import global_config
return bool(global_config().get_str("MY_INTEGRATION_API_KEY"))
```
An exception raised inside `_enabled()` is treated as `False` — the class is skipped and
a warning is logged, so a broken check cannot take the server down with it.
## Return values and failures
Return a `dict`. The engine reads three keys from it when it hands the result to an MCP
client:
| Key | Effect |
|---|---|
| `success` | A `bool`. `False` marks the MCP result as an error. |
| `data` | On success, this is unwrapped and also sent as `structuredContent`. |
| `error` | On failure, this string is what the client is shown. |
Both a plain `def` and an `async def` work — the caller awaits the result only when the
function is a coroutine. Any exception escaping your function is caught, logged, and
returned as `{"success": False, "error": "..."}`, so a bad tool cannot crash a chat
turn. Catching it yourself is still better: you get to say something the model can act
on.
Keep results small. Return a URL or a key rather than a large blob, and cap list lengths
— everything you return is spent from the model's context window.
## Overriding the generated schema
The type hints only describe flat scalars, arrays and untyped objects. For a tool whose
arguments are nested, attach a hand-written JSON Schema to the function as an
`inputSchema` attribute and it is used verbatim:
```python tools/report_service.py
class ReportService:
async def build_report(self, sections: list[dict], user_info: dict) -> dict:
"""Build a report from a nested section tree."""
...
ReportService.build_report.inputSchema = {
"type": "object",
"properties": {
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"indicators": {"type": "array", "items": {"type": "string"}},
},
"required": ["title"],
},
}
},
"required": ["sections"],
}
```
One of the shipped tools uses this mechanism: after the class definition it attaches a
schema from `chart_schema/*.json` to each chart method after the class is defined. Once
the attribute is there the type hints are no longer consulted for the schema, so keep
them for your own sake and treat the attached schema as the contract.
With a custom `inputSchema`, only the **first line** of the docstring is used as the
description and the `Args:` block is ignored entirely — the parameter descriptions have
to live inside the schema you supplied. A second optional attribute, `meta`, is passed
through to the tool's `_meta` field.
## Tool visibility per agent
A newly discovered tool is offered to every agent, because the shipped `config.yaml`
sets neither list. Narrow it per agent with `ALLOWED_TOOLS_{NAME}` (whitelist) and
`DISALLOWED_TOOLS_{NAME}` (blacklist, applied last so it always wins), where `{NAME}` is
the agent name in upper case — see [Tools & Agent Overview](/en/tools/overview).
## Troubleshooting
`tools/list` is the ground truth. If your tool is missing from it, the startup log
answers why: every registered tool is logged as `Loaded tool: `, a module that
failed to import as `Error importing tool module `, and a class that opted out as
`Skipping disabled tool class: `.
```bash
docker compose logs mirobody | grep -E "Loaded tool|Error importing tool module|Skipping disabled"
```
Work down the list when none of those lines mentions your file: is the directory in
`MCP_TOOL_DIRS`, does the filename avoid a leading underscore, does the class name end
in `Service`, is the method public — and did you restart?
## Next steps
The shipped tools, read as worked examples
Call your tool from Claude or Cursor
When instructions beat writing code
`MCP_TOOL_DIRS` and the rest of the config layers
---
# Building a Provider
https://docs.mirobody.ai/en/development/provider-integration
Write a BasePullProvider subclass and drop it into providers/: the factory, ProviderInfo metadata, credential validation, the OAuth flows, scheduled pulls, and normalisation to StandardPulseData.
Adding a health data source to Mirobody means writing **one Python class** — a `BasePullProvider` subclass — in a directory the engine scans at startup. There is nothing to compile, no registry to edit, and no core file to touch: the loader finds your class, calls its factory, and registers whatever comes back.
This page is the how-to. The contract behind it — the platform/provider split, the base class, link types, and the discovery rules — is explained in [Pulse Provider System](/en/concepts/providers). For a finished implementation read end to end, see [Garmin Provider Example](/en/examples/garmin-provider).
## Prerequisites
A virtual environment with `pip install -e .`, plus Postgres and Redis from `docker compose up -d pg redis`. Redis is not optional if your source uses OAuth — the temporary authorization state lives there. See [Development Setup](/en/development/setup).
The base URL, the auth scheme, the endpoints for the data you want, and the response shapes. Everything you write is a transformation of those payloads, so capture a few real responses early — they become your test fixtures.
Client ids, secrets and base URLs are read from the config through `safe_read_cfg("YOUR_KEY")`, never from the environment directly. Any key whose name contains `_KEY`, `_PASSWORD`, `_PASS`, `_PWD`, `_SECRET`, `_SK` or `_TOKEN` is encrypted at rest. See [Configuration](/en/configuration).
## Lay out the directory
At startup the engine walks every entry of `PROVIDER_DIRS`, which ships as `mirobody/pulse/providers` plus the repository-root `providers/`. Put your own provider in `providers/` so upgrades never touch it:
`" },
{ depth: 2, name: "__init__.py" },
{ depth: 2, name: "provider_acme.py", note: "the module: `provider_.py`" },
]}
/>
Four rules decide whether your provider exists at all: the loader globs `mirobody_*/provider_*.py` and then looks for a class that **subclasses `BasePullProvider`** (the class name only pre-filters on `Provider`):
| Rule | Value |
|---|---|
| Directory | `mirobody_/` |
| Module | `provider_.py` |
| Class | `Provider(BasePullProvider)` |
| Factory | `create_provider(config)` returning an instance, or `None` to stay disabled |
Loading is best-effort: a provider that raises on import or in its factory is logged as a warning and **skipped**, and the server still starts. If your provider never appears, read the startup log before suspecting anything else.
The smallest complete provider in the tree is the PostgreSQL one — no vendor API, and it implements exactly the required surface. Read it as a template.
Only three methods are yours to implement: `info`, `save_raw_data_to_db` and `is_data_already_processed`. Everything else either has a working default on the base class or raises an error naming the method you left out.
## The factory
`create_provider(config)` is a classmethod, and its only job is a feasibility check: whether this provider can work in the current environment. Returning `None` is the supported way to stay out of the registry — that is how an unconfigured integration disappears instead of failing at request time.
Both patterns apply, depending on the integration: gate on a feature flag when it is opt-in, and gate on the credentials themselves when it requires them. `ENABLE_PGSQL_DEVICE` is not present in the shipped `config.yaml`, which is precisely why the PostgreSQL provider is off until you add the key.
```python mirobody_acme/provider_acme.py
class AcmeProvider(BasePullProvider):
@classmethod
def create_provider(cls, config: dict) -> Optional["AcmeProvider"]:
client_id = config.get("ACME_CLIENT_ID")
if not client_id: # not configured → this provider does not exist
return None
return cls(config)
```
## Declare the metadata
`info` returns a `ProviderInfo` and must not touch the network — listing providers is supposed to be free. The two interesting fields are `auth_type` and `connect_info_fields`, and they travel together.
For `LinkType.CUSTOMIZED`, you describe a form and the frontend renders it; the values arrive back under `credentials["connect_info"]`. The PostgreSQL provider declares five fields — username, password, host, port, database — of which two are shown here.
An OAuth provider declares no fields at all — there is no form, only a redirect.
Prefix your `slug` with `theta_`. `POST /api/v1/pulse/user/providers/link` reads the prefix and rewrites the platform to `theta` on its own, so a slug without it forces callers to name the platform correctly by hand.
## Validate credentials
For `PASSWORD` and `CUSTOMIZED` providers, `BasePullProvider.link()` calls `_validate_credentials_v2(credentials)` **before** it stores anything, and a raised exception is the whole failure protocol. Raise `ValueError` for "your input is wrong" and `RuntimeError` for "the source is unreachable" — the message reaches the caller.
`_validate_credentials(username, password)` is the v1 interface and still exists. Override `_validate_credentials_v2` instead — it receives the whole credentials dict, which is the only way to see `connect_info`.
## OAuth authorization
OAuth providers take a different path: `link()` returns a `link_web_url` for the browser, and the connection is finished later by a callback route. Both OAuth versions are represented in the tree — Whoop and Oura are OAuth 2.0, Garmin is OAuth 1.0a — and neither of them stores state in process memory. The temporary handshake state lives in **Redis**, so the browser can come back to a different instance than the one that started the flow.
### OAuth 2.0 with the shared client
Don't hand-roll the flow: the engine ships a reusable OAuth 2.0 client, and a provider wires it in **by composition rather than inheritance**.
`refresh_extra_params` is the escape hatch for sources that deviate from RFC 6749 on the refresh grant — Whoop insists on `scope` being resent, so it goes there instead of into a forked copy of the client.
Stage one builds the authorization URL and parks the handshake state in Redis, with a TTL from `OAUTH_TEMP_TTL_SECONDS` (default 900 seconds).
The `state` carries the caller's `return_url` so the browser can be sent home afterwards, and the `redirect_uri` is stored beside it because the token endpoint has to be given the *same* value it saw during authorization.
Stage two exchanges the code. `exchange_code_for_tokens` takes and clears that handshake state, exchanges the `authorization_code` grant, computes the expiry and stores the credentials encrypted. Your provider only wires the two ends together.
Refreshing is not your problem either. Call `get_valid_access_token(user_id, provider_slug, db_service)` whenever you need a token: it returns the stored one while more than **five minutes** of life remain, otherwise spends the refresh token, saves the new pair, and hands back the fresh access token. With no refresh token stored it returns `None`, which is the signal that the user must re-authorize.
### OAuth 1.0a (Garmin)
OAuth 1.0a has an extra round trip and signs every request, so it does not reuse the client above. There is an implementation to copy from: the [Garmin Provider Example](/en/examples/garmin-provider). What you owe is still one `callback` method, taking `(oauth_token, oauth_verifier)`.
The request signing here is for **outgoing** requests. It is not webhook verification — see below.
### The callback route
You do not add a route. One route serves every provider, and it dispatches on `info.auth_type`.
So your obligation is a `callback` method with the right arity: `(code, state)` for OAuth 2.0, `(oauth_token, oauth_verifier)` for OAuth 1.0a. The route is `GET /api/v1/pulse/{platform}/{provider}/callback`. When no `return_url` was supplied it answers with a small HTML page that notifies the opener and closes the popup. For the linking flow end to end, see [Using Providers](/en/providers/using-providers).
### Configuration keys
Per-provider keys are read with `safe_read_cfg` at construction time, and the endpoint URLs have defaults so only the three secrets are mandatory. What `config.yaml` ships:
```yaml config.yaml
GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_token
GARMIN_AUTH_URL: https://connect.garmin.com/oauthConfirm/
GARMIN_ACCESS_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/access_token
GARMIN_API_BASE_URL: https://apis.garmin.com/wellness-api/rest
OAUTH_TEMP_TTL_SECONDS: 900
GARMIN_CLIENT_ID: ""
GARMIN_CLIENT_SECRET: ""
GARMIN_REDIRECT_URL: ""
WHOOP_TOKEN_URL: https://api.prod.whoop.com/oauth/oauth2/token
WHOOP_AUTH_URL: https://api.prod.whoop.com/oauth/oauth2/auth
WHOOP_API_BASE_URL: https://api.prod.whoop.com/developer/v2
WHOOP_CLIENT_ID: ""
WHOOP_CLIENT_SECRET: ""
WHOOP_REDIRECT_URL: ""
```
Follow the same shape for your source: `_CLIENT_ID`, `_CLIENT_SECRET`, `_REDIRECT_URL`, and overridable `_AUTH_URL` / `_TOKEN_URL` / `_API_BASE_URL`. Because the name ends in `_SECRET`, the client secret is encrypted at rest automatically. Oura follows the convention (`OURA_CLIENT_ID`, `OURA_CLIENT_SECRET`, `OURA_REDIRECT_URL`) even though those three keys are not in the shipped template — reading a key that isn't there simply returns empty, and the factory then declines to build the provider.
### Webhooks
For push-based sources there is nothing to register either. Two routes accept payloads, and both dispatch to your provider:
```bash
# explicit provider — preferred
POST /api/v1/pulse/providers/theta_garmin/webhook
# universal: provider is sniffed out of the body's `source` field
POST /api/v1/pulse/providers/webhook
```
`msg_id` is taken from the `Svix-Id` request header, falling back to a formatted timestamp when the header is absent. It is what your `is_data_already_processed` and your storage table use to recognise a redelivery.
These endpoints perform **no signature verification** — there is no HMAC check anywhere on the Pulse webhook path. If your source signs its deliveries and you need that guarantee, the check has to be added, and until then the endpoint should be protected at the edge.
## Scheduled pulls
If your source has to be polled instead, say so and implement one method. `register_pull_task` returning `True` (the base class default) gets you a scheduled task with a distributed lock; returning `False` opts out, which is what both the Garmin and PostgreSQL providers do — Garmin because its webhook does the work, PostgreSQL because it only validates a connection.
The base class drives the loop for you: it loads every linked user's credentials, calls your `pull_from_vendor_api` per user, skips anything `is_data_already_processed` rejects, and pushes the rest through the write path. Return a **list of self-describing packages**, one per data kind, the way the Whoop provider does.
That `data_type` is the tag your `format_data_v2` switches on later, so choose the names once and use them in both places. Two hooks exist for sources whose credentials aren't a username and password: override `pull_from_vendor_api` with your own signature and override `_pull_and_push_for_user(credentials)` to call it — that is how Whoop passes an access token and a refresh token instead. Per-slug cadence and lock duration are listed on [Pulse Provider System](/en/concepts/providers); a slug with no entry polls hourly with a 30-minute lock.
## Data mapping
This is the part only you can write, and the only hard constraint in the whole provider contract: whatever shape your source speaks, `format_data_v2` must return a `StandardPulseData`. Everything downstream — aggregation, indicator search, the agent's health tools — reads that model and nothing else.
The input is a `FormatDataInput`, which is deliberately two separate things: a `context` the base class already resolved from the database (internal user id, vendor-side user id, timezone, message id) and the `payload`, untouched. Because identity and timezone arrive pre-resolved, `format_data_v2` does no I/O at all; it is a pure function that can therefore be tested offline.
Each record's `type` must be a **registered indicator name**, not your source's field name — that is what makes a reading from an Oura ring comparable with one from a Garmin watch. So the real work is a table from vendor field to `StandardIndicator`, kept as data rather than scattered through `if` branches. Oura's is the tersest form: a bare indicator when the source unit already matches the standard one, or a `(indicator, source_unit)` tuple when the write path should convert.
Whoop's table is the same idea with an explicit converter, because its payload speaks milliseconds and kilojoules: each entry is `(indicator_name, converter, unit)`. Pick the indicator names out of the registry — see [Health Indicators](/en/concepts/indicators) — and if nothing in the registry fits, extend the registry rather than inventing a `type` string here.
```python mirobody_acme/provider_acme.py
def format_data_v2(self, raw: dict, user_id: str) -> StandardPulseData:
records = [
StandardPulseRecord(
source="acme",
type="heartrate", # a registered indicator name, not Acme's field name
value=sample["bpm"],
unit="/min",
timestamp=sample["epoch_ms"],
)
for sample in raw["samples"]
]
return StandardPulseData(userId=user_id, healthData=records)
```
## Store the raw payload
Two abstract methods remain, and they are about durability rather than transformation. `save_raw_data_to_db` writes the payload untouched — so a formatting bug is a re-run, not lost data — and returns **one entry per user found in it**, which is how a batched webhook fans out into several `format_data_v2` calls. `is_data_already_processed` is the idempotency gate before pushing.
The column names are not arbitrary. `get_table_name()` defaults to `health_data_`, `get_user_id_column()` to `theta_user_id`, and `get_query_columns()` to `id`, the user id column, `external_user_id`, `msg_id`, `raw_data`, `create_at`, `update_at`, `is_del`. Match that layout and the management console can page through and re-format your stored payloads for free; deviate and override those three methods.
Where the vendor-side user id lives differs per source, so the base class exposes `_extract_theta_user_id`, `_extract_external_user_id` and `_extract_msg_id` as overridable hooks. Whoop overrides the middle one to read `data[0]["user_id"]`; the default reads a top-level `user_id`.
## Load and verify
There is no build step — restart the process and read the log. Every successful load prints a line, and `GET /api/v1/pulse/providers` needs no token, so it is the fastest confirmation that your class was found:
```bash
mirobody serve
# → Scanning for theta providers in: /path/to/providers
# → Loaded provider from /path/to/providers/mirobody_acme/provider_acme.py
# → ✅ Loaded provider: [theta_acme]
curl -s http://localhost:18080/api/v1/pulse/providers
```
If the slug isn't in that response, the cause is one of four things and the log says which: the directory or module name didn't match the glob, the class didn't subclass `BasePullProvider`, the import raised, or `create_provider` returned `None` because a config key was missing.
Then verify the transformation properly — fixtures, snapshots, and the live routes — in [Provider Testing](/en/development/provider-testing).
## Next steps
Gate tests, fixtures, and the live Pulse routes
A complete implementation, read end to end
What happens to your records after they land
Configure credentials and connect an account
The four providers that ship are worth reading alongside this: [`mirobody/pulse/providers/`](https://github.com/thetahealth/mirobody/tree/main/mirobody/pulse/providers).
---
# Garmin Provider Example
https://docs.mirobody.ai/en/examples/garmin-provider
The decisions behind the shipped Garmin provider: the two-stage OAuth 1.0a link, why it registers no pull task, how webhook payloads deduplicate, and mapping fields as data rather than branches.
The Garmin integration is the most complete of the four shipped providers, and worth reading before writing your own: it is the only `OAUTH1` source, the only one that deliberately turns scheduled pulling off, and the only one that calls the vendor back on unlink.
This page is about its **decisions**; the code is in the repository. For the contracts it implements (`BasePullProvider`, `ProviderInfo`, `LinkType`, pull scheduling, `StandardPulseData`), see [Pulse Provider System](/en/concepts/providers).
## Four metadata fields that carry behaviour
`info` is re-evaluated on every call and touches nothing external — listing providers must cost neither a round trip nor credentials. Four of its fields carry behaviour:
- `slug` (`theta_garmin`) is the routing key: it appears in the webhook path, the callback path, the credential row, and it is the pull task's identity.
- `auth_type` is `OAUTH1`, which is what makes the callback read `oauth_token` + `oauth_verifier` instead of `code` + `state`.
- `status` is only a default: the route replaces it per user with what is actually linked.
- `connect_info_fields` is **absent**, and that absence is how a client knows to open a browser rather than draw a form.
In configuration, the four endpoint URLs and the TTL ship filled in; what is blank is `GARMIN_CLIENT_ID`, `GARMIN_CLIENT_SECRET` and `GARMIN_REDIRECT_URL`. The factory gates the whole provider on the first two: without both it returns `None`, so a misconfigured deployment has *no* Garmin provider rather than a broken one.
## Linking: the two stages of OAuth 1.0a
OAuth 1.0a has to carry a secret between two HTTP round trips that share no session, so the handshake state is parked in Redis.
Stage two is where the security decision lives. **The callback route is unauthenticated**: the caller is Garmin's redirect, not your user. So nothing in the query string is trusted beyond the two OAuth parameters, the user identity is recovered from Redis, and the temporary state is deleted on read, which makes the handover single-use.
One more thing can only be done at this moment: during linking, ask Garmin once for this user's id on their side and store it on the row. **Every later webhook depends on it having been captured here.**
This provider has no token-refresh logic, and `OAUTH1` writes no `refresh_token` column: OAuth 1.0a access tokens are long-lived. The refresh path belongs to the two OAuth 2.0 providers (Whoop, Oura), which share the engine's own OAuth 2.0 client. Don't model a Garmin-style provider on them, or the reverse.
## Webhook push instead of a pull task
By default every provider gets a scheduled pull. Garmin turns it off explicitly, so nothing ever polls Garmin on a timer — the data arrives by vendor webhook. A pull happens exactly once: the backfill right after linking.
Vendor APIs with a bounded query window need chunking: Garmin caps a single query at 24 hours, so a multi-day backfill is split into one call per day and stitched back together.
## Storing raw payloads, and deduplication
Each webhook item carries Garmin's own user id, which means nothing to your database. So identity is resolved before anything is written:
Garmin's provider-level idempotency gate is a no-op: duplicate deliveries are absorbed entirely by the database's uniqueness on `msg_id`. That makes the `summaryId` → `msg_id` derivation load-bearing — a payload where no item carries a `summaryId` gets a timestamp-based key instead, and a retry will not deduplicate.
A payload carrying a deregistration takes another exit: it is stored as usual, the matching user's credential row is deleted — this is how Garmin tells you a user revoked consent on their side — and the item is then skipped rather than handed on to formatting.
## Mapping to StandardPulseData
Garmin's mapping is **data, not code**: one table keyed by data type describes where each payload's timestamp comes from, which flat fields become which indicator in which unit, which nested arrays are time series, and whether a derived handler runs afterwards. A very short generic interpreter executes the table, because the knowledge is in the table.
Three keys decide everything about a flat field: `indicator`, `converter` and `unit`. Indicator names are never string literals but references into the registry, so renaming something in the registry fails loudly instead of leaving a quietly wrong `type` in the database. `converter` is where units are aligned at the source: Garmin reports active duration in seconds and the written record is in minutes.
Missing fields are skipped silently; a field that is present but unconvertible counts as a skip and is logged. Nothing aborts the batch: failures are collected per data type and returned alongside the result, which is what `GET /api/v1/manage/pulse/providers/check_format` uses to diagnose a half-successful mapping.
Time series come in two shapes, named by the config: an array of samples each carrying a value and a time offset (heart-rate sampling), or one object that *is* `offset → value` (HRV, respiration). Either way a sample's timestamp is the item's base time plus the offset, so one daily-summary payload expands into hundreds of individually timestamped records.
## Derived indicators
Some indicators simply do not exist in Garmin's payloads and have to be computed — that is what the derived handlers are for: the sleep one derives three indicators from two raw fields, and the dailies one adds active and basal energy together.
The interesting part is what is **deliberately not mapped**. Garmin has a duration field with the same shape as a standard indicator but not the same meaning; mapping it directly would produce a plausible wrong number, so it is left out of the table. Two derived sleep indicators are also not computed here: they need heart-rate samples intersected with the sleep window, and because Garmin sends one webhook per data type, no single call holds both — so they are deferred to the aggregator rather than approximated from incomplete data.
## Unlink and vendor notification
The base class's unlink soft-deletes the credential row and stops. Garmin also requires a deregistration call, and its error semantics deserve attention: the local row is deleted on every path, including when the vendor call fails, but a vendor failure still raises. So an unlink error means "disconnected locally, Garmin may still be pushing", not "nothing happened". A credential row that was already absent is treated as already unlinked and reports success.
## What to take from it
- **Write the mapping as data, not branches.** A table keyed by data type plus a short interpreter means "support another data type" is a table entry, not a new method.
- **Reference the indicator registry, never a string.** That is what keeps the `type` field honest.
- **Choose between push and pull explicitly.** Scheduling off plus a one-time backfill after linking is the whole pattern for a push-based source.
- **Recover identity from your own storage, not from the redirect.** The callback is unauthenticated; treating the parked state as the only trustworthy source of the user's identity is what makes it safe.
- **Refuse to map a field whose meaning doesn't match.** Derive the indicator instead of emitting a plausible wrong value.
## Next steps
The contracts this provider implements
Start your own from this shape
`theta_garmin` has fixtures already — use them
The routes that drive all of the above
The source is under [`mirobody/pulse/providers/`](https://github.com/thetahealth/mirobody/tree/main/mirobody/pulse/providers), in `mirobody_garmin_connect/`.
---
# Provider Testing
https://docs.mirobody.ai/en/development/provider-testing
Verify a Pulse provider two ways: replay recorded payloads through the offline gate tests, and drive the live Pulse routes on a running server.
A provider is two things bolted together: a **transformation** (`format_data_v2`) and a **transport** (OAuth, webhooks, scheduled pulls). They fail differently, so they are verified differently. The transformation has an offline suite that replays recorded payloads and diffs the output against stored snapshots. The transport can only be checked against a running server.
Start with the gate tests. They need no server, no database and no network, so they are the loop you can run on every edit — and a mapping bug found there is a five-second fix instead of a puzzle in a webhook log.
## Two verification loops
## The gate test suite
[`mirobody/pulse/gate_tests/`](https://github.com/thetahealth/mirobody/blob/main/mirobody/pulse/gate_tests/README.md) is an acceptance suite for `format_data()` and `format_data_v2()`. It imports your provider class dynamically, mocks away its database and config dependencies, feeds it a recorded payload, and checks the resulting `StandardPulseData`.
Install the test extra once, then run from the repository root:
```bash
pip install -e ".[test]"
python -m pytest mirobody/pulse/gate_tests/test_format_data.py -v
```
Every fixture becomes one parametrized case named after its `test_id`, so a failure points straight at a file. The suite's own `pytest.ini` sets `asyncio_mode = auto`, which is why the async cases run without extra flags.
## Fixture anatomy
A fixture is one JSON file. Three keys are required — `test_id`, `provider_class`, `input` — and a file missing any of them is logged as a warning and **silently skipped**, so a typo shows up as a case that quietly stopped existing rather than as a failure.
```json mirobody/pulse/gate_tests/fixtures/theta_whoop/body_measurements.json
{
"test_id": "theta_whoop_body_measurements_001",
"description": "Whoop body_measurements data",
"provider_class": "mirobody.pulse.providers.mirobody_whoop.provider_whoop.WhoopProvider",
"platform": "theta",
"mock_context": {},
"patch_targets": [
"mirobody.pulse.providers.platform.base.ProviderDatabaseService",
"mirobody.pulse.providers.platform.base.PlatformUserService",
{ "target": "mirobody.utils.config.safe_read_cfg", "return_value": "" }
],
"context": {
"theta_user_id": "test_user_005",
"user_timezone": "Asia/Shanghai",
"msg_id": "test_msg_0009"
},
"input": {
"data": [
{ "height_meter": 1.7, "max_heart_rate": 182, "weight_kilogram": 70.0 }
],
"msg_id": "test_msg_0009",
"user_id": "test_user_005",
"data_type": "body_measurements",
"timestamp": 1768574416892
},
"expected": {
"success": true,
"health_data_count": 3,
"required_indicators": ["bodyMasss", "heights", "maxHeartRateProfile"],
"value_checks": [
{ "index": 0, "field": "type", "expected": "heights" },
{ "index": 0, "field": "value", "expected": 1.7 },
{ "index": 0, "field": "unit", "expected": "m" }
],
"snapshot": { }
}
}
```
What each key does (the recorded `snapshot` is elided above — it mirrors the entire output):
| Key | Effect |
|---|---|
| `provider_class` | Dotted path, so it has to be importable from wherever pytest runs. Every shipped fixture uses a packaged path under `mirobody.pulse.providers.…`. The class is instantiated directly — the provider loader is not involved. |
| `context` | Present → the runner builds a `FormatDataContext` and calls `format_data_v2`. Absent → it calls legacy `format_data(input)`. |
| `input` | The payload, exactly as your source would send it. |
| `patch_targets` | A string patches that name with `MagicMock`; an object `{target, return_value}` patches it to return that value. This is how the database and `safe_read_cfg` are neutralised during `__init__`. |
| `init_kwargs` | Constructor arguments; the literal `"__mock__"` becomes a `MagicMock()`. Apple Health's fixtures use `{"platform": "__mock__"}`. |
| `mock_context` | Method name → return value, applied to the **instance** as an `AsyncMock`. Use it for any coroutine you don't want to run. |
| `expected` | The assertions — see below. |
Because the patching is fully data-driven, the runner contains no per-provider branching: your provider is testable the moment you list which of its constructor dependencies to stub.
## Case validation
`expected` drives three independent layers, and each one is skipped when its key is absent — so a fixture can be as loose or as strict as you need.
`health_data_count` must match `len(healthData)` **exactly**, and every name in `required_indicators` must appear as some record's `type`. This is the layer that catches "the mapping stopped emitting HRV".
`value_checks` is a list of `{index, field, expected}`, compared against `healthData[index][field]`. Hand-calculate these: they are the only layer that proves a unit conversion is right rather than merely stable.
`snapshot` is diffed against the whole output recursively, reporting up to 20 differences with their paths. Fields that change every run are stripped from both sides first: `requestId`, `timestamp`, `start_time`, `end_time`, `processing_duration_ms`, `success_rate` from `metaInfo` and `processingInfo`, and `timestamp` from each `healthData` record.
Setting `"success": false` inverts the whole thing: the case passes if `format_data` raises, or if it returns with an empty `healthData`. That is the shape for asserting that a malformed payload is refused rather than half-ingested.
## Adding a case
Take an actual response or webhook body from your source. If it is already stored, `check_format` (below) will print it back for you. Trim it to the smallest sample that still exercises the branch you care about, and strip anything personal.
Create `mirobody/pulse/gate_tests/fixtures//.json` with `test_id`, `provider_class`, `context`, `patch_targets`, `input`, and `expected.snapshot` set to `null`:
```json mirobody/pulse/gate_tests/fixtures/theta_acme/sleep.json
{
"test_id": "theta_acme_sleep_001",
"description": "Acme sleep payload",
"provider_class": "mirobody.pulse.providers.mirobody_acme.provider_acme.AcmeProvider",
"platform": "theta",
"mock_context": {},
"patch_targets": [
"mirobody.pulse.providers.platform.base.ProviderDatabaseService",
"mirobody.pulse.providers.platform.base.PlatformUserService",
{ "target": "mirobody.utils.config.safe_read_cfg", "return_value": "" }
],
"context": {
"theta_user_id": "test_user_acme_001",
"user_timezone": "Asia/Shanghai",
"msg_id": "test_msg_acme_sleep"
},
"input": { "data_type": "sleep", "data": [] },
"expected": { "success": true, "snapshot": null }
}
```
Run with the flag `conftest.py` adds. The runner calls your provider, normalises the output, and writes it back into the fixture file:
```bash
python -m pytest mirobody/pulse/gate_tests/test_format_data.py --update-snapshots -v
```
This is the step people skip. `--update-snapshots` records whatever your code did, bugs included. Open the diff and check the indicator names, the units, and the timestamps against the source's documentation.
Fill in `health_data_count`, `required_indicators`, and a few hand-calculated `value_checks`, then run without the flag and confirm green:
```bash
python -m pytest mirobody/pulse/gate_tests/test_format_data.py -v
```
When you deliberately change `format_data_v2`, re-record with `--update-snapshots` and commit the fixture diff **in the same commit** as the code. The diff then documents exactly what the change did to real payloads, providing a lightweight review of the mapping change.
## Verifying against a running server
Transport is the other half. Start the server on `18080` and mint a token for a demo account — an address listed in `EMAIL_PREDEFINE_CODES` is accepted by the validator without any mail being sent, so you can skip `/email/login` and call `/email/verify` directly:
```bash
BASE=http://localhost:18080
# 1. provider list — no token needed, and the fastest check that your class loaded
curl -s "$BASE/api/v1/pulse/providers"
# 2. get a JWT for a predefined demo account
curl -s -X POST "$BASE/email/verify" \
-H 'Content-Type: application/json' \
-d '{"email": "exp1@mirobody.ai", "code": "111111"}'
# → { "success": true, "code": 0, "data": { "access_token": "...", ... } }
# 3. link — PASSWORD / CUSTOMIZED providers connect in this one call
curl -s -X POST "$BASE/api/v1/pulse/user/providers/link" \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"provider_slug": "theta_pgsql", "platform": "theta",
"auth_type": "customized",
"connect_info": {"host": "localhost", "port": "18082",
"database": "mirobody", "username": "...", "password": "..."}}'
# 4. link — an OAuth provider answers with a URL to open in a browser
curl -s -X POST "$BASE/api/v1/pulse/user/providers/link" \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"provider_slug": "theta_whoop", "platform": "theta", "auth_type": "oauth2"}'
# → { "data": { "link_web_url": "https://..." } }
# 5. webhook — replay a captured delivery through the real ingest path
curl -s -X POST "$BASE/api/v1/pulse/providers/theta_acme/webhook" \
-H 'Content-Type: application/json' -H 'Svix-Id: replay-001' \
--data-binary @captured_payload.json
```
Assert on behaviour, not just on 200s: a bad credential should come back as `code: 400` with your own `ValueError` message; the same webhook sent twice with the same `Svix-Id` should be recognised by `is_data_already_processed`; and a `link` for a slug the loader never registered answers with `Provider … not found in theta platform` rather than a crash.
Most sources reject a plain `http://localhost:18080` redirect URI, so a real consent flow can't complete against a bare local server. Terminate TLS in front of it — a local reverse proxy with a trusted certificate, or a tunnel — and register that exact HTTPS URL as `_REDIRECT_URL`. A tunnel also gives you an inbound address for real webhook deliveries.
## Replay a stored payload
The management routes are the closest thing to a debugger for a provider, because they read back what you actually stored and re-run your formatter over it. They are gated by a shared key rather than a JWT: `verify_manage_key` reads a `sk` **query parameter** and compares it with the `backend_server_sk` config value. That key is not in the shipped `config.yaml`, so add it to your `config..yaml` as `BACKEND_SERVER_SK` first — without it every management route answers 500.
```bash
SK=your-management-key
# what has arrived for one provider (paginated, from its own storage table)
curl -s "$BASE/api/v1/manage/pulse/providers/webhooks?provider=theta_acme&page=1&page_size=20&sk=$SK"
# re-run format_data_v2 over stored record #123 and see both sides
curl -s "$BASE/api/v1/manage/pulse/providers/check_format?id=123&provider=theta_acme&sk=$SK"
# force a scheduled pull now, ignoring the interval and the distributed lock
curl -s -X POST "$BASE/api/v1/manage/theta/pull/trigger?sk=$SK" \
-H 'Content-Type: application/json' \
-d '{"provider_slug": "theta_acme", "force": true}'
# and the scheduler's view of every registered task
curl -s "$BASE/api/v1/manage/theta/pull/status?sk=$SK"
# finally, read the normalised records back (max 7 days per call)
curl -s "$BASE/api/v1/manage/pulse/user-health-data?user_id=505&start_date=2026-08-01&end_date=2026-08-07&sk=$SK"
```
`check_format` is the one to reach for first. It returns `original_data` and `formatted_data` side by side, plus the resolved `theta_user_id`, `external_user_id` and `msg_id` — and when your formatter raises, it reports `success: false` with the exception message instead of swallowing it. A payload that reproduces a bug there is also a payload ready to be saved as a fixture.
## Coverage checklist
One fixture per `data_type` your provider handles, each with `health_data_count`, `required_indicators`, and hand-calculated `value_checks` for anything that gets converted. Unit conversions and epoch-millisecond timestamps are where the bugs live.
An empty payload, a missing `data_type`, a record the source marks unscored. The contract is that these produce an empty `healthData` rather than an exception, and a fixture with `"success": false` pins the behaviour down.
With the credential keys unset, `create_provider` must return `None` and the slug must be **absent** from `GET /api/v1/pulse/providers`. With them set, it must be present. This is a two-line check that catches a whole class of "works on my machine".
For OAuth sources: complete a real consent flow, then confirm the stored `expires_at` and force a refresh by pulling after expiry. `get_valid_access_token` refreshes when under five minutes remain and returns `None` when no refresh token is stored — the second case must surface as "reconnect required", not as an empty pull.
Deliver the same payload twice. The second one should be skipped by `is_data_already_processed`, and the record count read back from `user-health-data` must not double.
## Next steps
The class you are testing
The venv, the containers, and the wider test suite
What a pull request needs before review
The sources that already ship
---
# Development Setup
https://docs.mirobody.ai/en/development/setup
Run the Mirobody Python engine from a source checkout: venv, editable install, Postgres and Redis in Docker, and the test suites.
Mirobody is a **Python** service (`requires-python = ">=3.12"`). Developing on it means creating a virtual environment, installing the package in editable mode, starting Postgres and Redis with Compose, and running `mirobody serve`. There is no compile step.
## Prerequisites
`pyproject.toml` declares `requires-python = ">=3.12"`
For the Postgres (`pgvector/pgvector:pg17-trixie`) and Redis (`redis:7.0-alpine`) containers
`mirobody/res/*.bin`, `*.npz`, `*.npy` and `*.gz` are LFS objects
Install **Git LFS before cloning** (`apt install git-lfs` / `brew install git-lfs`, then `git lfs install` once). Without it the FHIR resource files under `mirobody/res/` arrive as text pointers and indicator search fails at import time.
## Set up a working tree
```bash
git clone https://github.com/thetahealth/mirobody.git
cd mirobody
```
`compose.yaml` defines four services — `pg`, `redis`, `mirobody` and `mirobody_worker`. For local development you only want the two backing stores; the application runs on your host:
```bash
docker compose up -d pg redis
```
They publish **`18082` → Postgres** and **`18089` → Redis** on the host.
```bash
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install --upgrade pip
pip install -e .
```
`pip install -e .` gets the **engine** — ① Collect and ② Standardize as a library, with no database driver and no HTTP server. Working on the chat server or the agents means the `[agents]` extra, which pulls `[server]` in with it:
```bash
pip install -e '.[agents,test]' # what you want for development
pip install -e '.[cn]' # Aliyun OSS + Volcengine Ark
pip install -e '.[indicator-build]' # only to regenerate the terminology bundles
```
There is no Node.js dependency anywhere in the repository.
`.env` carries exactly two variables: which config file to load, and the key used to encrypt sensitive values at rest.
```bash
echo "ENV=localdb" > .env
echo "CONFIG_ENCRYPTION_KEY=$(openssl rand -hex 16)" >> .env
```
`config.yaml` is the read-only template shipped with the repository. Your edits go into `config..yaml` — with `ENV=localdb`, that is `config.localdb.yaml`:
```yaml config.localdb.yaml
JWT_KEY: 'a 32-byte random string'
EMAIL_PREDEFINE_CODES:
exp1@mirobody.ai: '111111'
OPENROUTER_API_KEY: 'sk-or-...'
```
Any key whose name contains `_KEY`, `_PASSWORD`, `_PASS`, `_PWD`, `_SECRET`, `_SK` or `_TOKEN` is encrypted automatically on first load, using `CONFIG_ENCRYPTION_KEY`. The full key list is in [Configuration](/en/configuration).
`config.yaml` ships the **container-network** addresses (`PG_HOST: 10.108.0.2`, `REDIS_HOST: 10.108.0.9`) because that is where the app container finds them. A process running on your host reaches the same containers through the published ports instead, so override them:
```yaml config.localdb.yaml
PG_HOST: localhost
PG_PORT: 18082
REDIS_HOST: localhost
REDIS_PORT: 18089
```
```bash
mirobody serve
```
The server listens on `http://localhost:18080`. Log in with `exp1@mirobody.ai` and the code you put in `EMAIL_PREDEFINE_CODES`.
Background work — the `IndicatorSync` and `ProfileRefresh` task queues — runs in a **second process**, not in the web server:
```bash
mirobody worker
```
Both entry points accept config filenames as positional arguments (`mirobody serve config.localdb.yaml`); with no arguments they resolve the file from `ENV`.
## Extension directories
Five config keys tell the engine where to look for your own code. Each is a list of directories shipping exactly one entry — the packaged one. Add your own and **list it first**, so it is scanned ahead of the built-in one:
| Key | Default |
| --- | --- |
| `MCP_TOOL_DIRS` | `mirobody/agent/tools` |
| `MCP_RESOURCE_DIRS` | `mirobody/agent/resources` |
| `AGENT_DIRS` | `mirobody/agent` |
| `PROVIDER_DIRS` | `mirobody/pulse/providers` |
| `SKILL_DIRS` | `mirobody/agent/skills` |
Because everything is discovered at **runtime**, adding a tool, agent or provider means adding a file and restarting the process — there is no registry to edit and nothing to rebuild.
## Running tests
Tests live **beside the code they cover** — the test for `mirobody/mcp/service.py` is `mirobody/mcp/test_protocol.py` — and `testpaths` is set, so the whole suite is bare `pytest`:
```bash
pip install -e '.[agents,test]'
pytest
# passes with no failures
```
No database, no network, no API key. `'.[test]'` without `[agents]` is a supported smaller install: it runs the engine tests and prints a header saying the agent-layer tests were skipped.
There is **no top-level `tests/` directory**, and the markers `mcp` / `e2b` / `chat` do not exist. Two markers are defined, both meaning "this test wants something the sandbox does not have":
```bash
pytest -m "not needs_db" # skip anything wanting a live PostgreSQL
pytest -m "not needs_llm" # skip anything that would call a paid model
```
Two suites carry the project's public claims, and they are the ones to run after touching either half of ② Standardize:
| Suite | Covers |
|---|---|
| `mirobody/test_engine.py` | golden LOINC codes — pins the whole chain: alias index → commonness prior → axis table |
| `mirobody/test_engine_coverage.py` | **the published accuracy number**, 116/116, with a coverage floor that fails the build if it drops |
| `mirobody/mcp/test_protocol.py` | MCP wire behaviour: version negotiation, `resultType`, `server/discover` |
| `mirobody/pulse/gate_tests/` | one snapshot per vendor payload → `StandardPulseData` |
| `mirobody/pulse/aggregate/` | daily rollups, CGM indicators, source priority — the only suite that touches config |
### Additional checks
```bash
lint-imports # the two import-linter contracts
```
`lint-imports` enforces the two import-linter contracts that keep `langchain*`, `deepagents` and `langgraph` out of everything except `agent/` and `server/` — see [The Engine as a Library](/en/engine).
### Pulse gate tests
The provider pipeline's acceptance suite at [`mirobody/pulse/gate_tests/`](https://github.com/thetahealth/mirobody/blob/main/mirobody/pulse/gate_tests/README.md) replays recorded vendor payloads through `format_data()` and compares the resulting `StandardPulseData` against stored snapshots — no server, no database, no network:
```bash
pytest mirobody/pulse/gate_tests -v
pytest mirobody/pulse/gate_tests --update-snapshots # after an intentional shape change
```
A failing snapshot means either the vendor payload shape changed intentionally — update the snapshot — or the parser regressed. Treat a failure as something to diagnose, not something to dismiss as noise.
Details, including how to add a case, are in [Provider Testing](/en/development/provider-testing).
## Repository layout
The annotated tree — packages, the extension directories, and the deployment files — is in [Installation](/en/installation). It is the single copy, so this page doesn't repeat it.
## Next steps
Branches, style, and what a PR needs
Add a new health data source
Gate tests and the live Pulse routes
Run the whole stack in containers
---
# Contributing
https://docs.mirobody.ai/en/development/contributing
How to report issues, propose features, and land a change in the Mirobody Python engine.
Mirobody is a **Python** project, and contributions are welcome. This page follows the repository's own [`CONTRIBUTING.md`](https://github.com/thetahealth/mirobody/blob/main/CONTRIBUTING.md); where the two differ, the file in the repository wins.
## Three areas to contribute
Contributions are organized around the engine's three stages, and they differ enormously in size — start with ② if you want a first PR that lands.
| Area | What to contribute | Typical size |
|---|---|---|
| **① Collect** | A new device provider: implement `BasePullProvider` in one `mirobody_/` directory and the platform discovers it at startup. `mirobody_pgsql/` is the smallest reference, `mirobody_whoop/` the OAuth2 one. Or a new file format for the parser. See [Building a Provider](/en/development/provider-integration). | medium |
| **② Standardize** | **Make a term resolve.** Find one that comes back wrong or empty — `mirobody resolve ""` — then add one row to [`resolver_overrides.tsv`](https://github.com/thetahealth/mirobody/blob/main/mirobody/res/resolver_overrides.tsv) and one case to `test_engine_coverage.py`. Any language. Also: unit mappings, taxonomy fixes. | tiny |
| **③ Answers** | An Agent Skill (a `SKILL.md` directory — copy `lab-report-walkthrough`), an MCP tool, a chart schema. See [Agent Skills](/en/tools/skills) and [Adding Custom Tools](/en/tools/adding-tools). | medium |
Area ② is the lowest-barrier useful pull request in the repository, and it can move the resolver's published coverage score, currently 116/116. One TSV row plus one test case is a complete contribution.
A lab report that parses incorrectly, or an indicator name that fails to resolve, makes a good issue — attach a de-identified sample.
## Before you write code
Open a GitHub issue with a clear title and description, the steps to reproduce, and your environment details (OS, Docker version, Python version).
Open an issue to discuss the idea **before** implementing it. That keeps your time well spent and the feature aligned with the project's direction.
## Development workflow
```bash
git clone https://github.com/YOUR_USERNAME/mirobody.git
cd mirobody
```
```bash
git checkout -b feature/my-new-feature
# or
git checkout -b fix/bug-fix-name
```
Follow the surrounding code style. Everything user-extensible — tools, agents, skills, providers — is discovered at runtime from a configured directory, so adding a capability means adding a file, not editing a registry.
```bash
pip install -e '.[agents,test]'
pytest # the whole suite; tests live next to the code
lint-imports # the engine/agent boundary, machine-checked
```
`./deploy.sh` builds and starts the Docker stack; it does not run the test suite. Run the commands above to verify a change before opening a pull request.
`'.[test]'` alone is enough to work on the engine: the agent-layer tests are skipped at collection rather than aborting the run. `lint-imports` runs against the repository source. Details in [Development Setup](/en/development/setup).
```bash
git push origin feature/my-new-feature
```
Then open a PR against the `main` branch of the upstream repository.
## Coding style
- **Python** — follow PEP 8, and match the conventions of the file you're editing.
- **Documentation** — update the README (or the module README next to your code) whenever you change how something works.
- **Commits** — write descriptive messages; one concern per commit.
- **Config keys** — read them through `safe_read_cfg("YOUR_KEY")` rather than reaching into the environment, so overrides and automatic encryption keep working. Anything whose name contains `_KEY`, `_PASSWORD`, `_PASS`, `_PWD`, `_SECRET`, `_SK` or `_TOKEN` is encrypted at rest.
- **New optional dependencies** belong in an extra in `pyproject.toml` (`server`, `agents`, `cn`, `test`, `indicator-build`), not in the base requirement list — a feature nobody enabled shouldn't be able to break `pip install -e .`.
- **Never import an agent framework outside `agent/` or `server/`.** `lint-imports` fails the build on it, function-local imports included. The engine must import with numpy as its only third-party package.
## Pull request guidelines
A short, descriptive summary of the change. Conventional prefixes (`feat:`, `fix:`, `docs:`, `chore:`) appear in parts of the history; what the project asks for is simply that the message says what changed.
- What the PR does and why
- How to run / test it
- Linked issues
- [ ] `mirobody serve` starts against a local `docker compose up -d pg redis`
- [ ] `pytest` passes with no database or network access, and `lint-imports` is clean
- [ ] Touched `format_data()`? The Pulse gate tests pass, and new behaviour has a fixture
- [ ] New optional dependencies are behind an extra in `pyproject.toml`
- [ ] Docs updated if behaviour changed
## License
By contributing, you agree that your contributions are licensed under the project's [LICENSE](https://github.com/thetahealth/mirobody/blob/main/LICENSE).
## Getting help
Bug reports and feature requests
Direct technical support
Thank you for contributing!
---
# Docker Deployment
https://docs.mirobody.ai/en/deployment/docker
How deploy.sh builds the image and what compose.yaml actually declares: four services on a fixed subnet, five named volumes, and dependencies installed at container start.
The reference deployment is two files at the repository root: [`deploy.sh`](https://github.com/thetahealth/mirobody/blob/main/deploy.sh), which prepares the configuration and builds an image, and [`compose.yaml`](https://github.com/thetahealth/mirobody/blob/main/compose.yaml), which declares the stack. [Installation](/en/installation) covers the short version — clone, run `./deploy.sh`, four containers come up. This page is the same path read as a deployment: what the image contains, why it almost never needs rebuilding, and which parts of `compose.yaml` you have to change when you move off a laptop.
There is **no `Dockerfile` in the repository**. `deploy.sh` holds the Dockerfile as a shell string and pipes it into `docker build -`, so the image is a side effect of the script rather than a file you edit.
## The four phases of deploy.sh
Four phases, all idempotent — an existing file or an unchanged image is left alone.
The last phase has two consequences to consider before running it on a server:
- The script ends in `docker compose logs -f`, so it **stays in the foreground**. `Ctrl-C` stops the log tail; the containers keep running.
- The `docker compose down` is run without `-v`, so the named volumes — including the database — survive. Re-running `./deploy.sh` is safe.
## The image
The Dockerfile is a string in the script. Reproduced here as it is built:
```dockerfile inline Dockerfile, from deploy.sh
FROM ubuntu:24.04
RUN apt update && \
apt install -y --no-install-recommends \
ca-certificates curl \
g++ gfortran build-essential \
libfftw3-dev libhdf5-dev libblas-dev liblapack-dev \
python3 python3-venv python3-dev \
fonts-wqy-microhei fonts-wqy-zenhei fontconfig && \
rm -rf /var/lib/apt/lists/* && \
fc-cache -fv && \
mkdir /root/venv && \
python3 -m venv /root/venv && \
mkdir -p /app
WORKDIR /app
```
Note what is *not* there: no `COPY`, no `pip install`, no application code. The image is an environment, not a build of the engine. The repository arrives at `/app` as a bind mount and the dependencies install when the container starts.
### What the image contains
The image is "Ubuntu 24.04 + a compiler toolchain + a virtualenv at `/root/venv`". **Python dependencies are not installed at image-build time but into a volume at container start**, so the image has to carry a C / C++ / Fortran toolchain and numeric development headers — anything without a prebuilt wheel for the platform compiles on the spot. It also carries TLS roots, `curl` (the healthcheck shells out to it) and CJK glyphs, without which rendered labels are empty boxes.
**No Node.js.** `nodejs` is not installed, nothing at runtime executes `node`, and the web client ships as prebuilt static files that the application serves itself.
### Image rebuild conditions
The script writes the md5 of the Dockerfile text onto the image as a label and compares it next run: unchanged means it prints `Using existing docker image.` and skips the build.
```bash
# which checksum the current image was built from
docker image inspect --format '{{ index .Config.Labels "dockerfile.md5" }}' mirobody
# force a rebuild
docker image rm mirobody && ./deploy.sh
```
The only input to that decision is the Dockerfile **text**. If `ubuntu:24.04` or an apt package moved upstream while the script did not, you keep the old image — refreshing the base needs an explicit `docker image rm mirobody`, and it never happens on its own.
### Mirrors for restricted networks
Before building, the script calls a reachability probe against `hub.docker.com`: `curl` with a 3-second connect timeout if available, otherwise `wget --spider`, otherwise `ping`. HTTP `200`, `301` or `302` counts as reachable.
If it is not reachable, the script walks its mirror list — one entry, `docker.1ms.run` — and the first one that answers becomes a registry prefix, so `FROM ubuntu:24.04` is built as `FROM docker.1ms.run/ubuntu:24.04`.
Three limits of that fallback, all of which matter on a restricted network:
- **It only rewrites the image the script builds.** `pgvector/pgvector:pg17-trixie` and `redis:7.0-alpine` are pulled by `docker compose`, which never sees the prefix. Configure a registry mirror in the Docker daemon for those.
- **The npm registry is set unconditionally.** `npm config set registry https://registry.npmmirror.com` is baked into the image whether or not the probe failed. Override it in the container if you want the default registry.
- **PyPI is not mirrored.** Neither the script nor `compose.yaml` sets an index URL, so a `pip` mirror is something you add yourself — for example as another entry in the `mirobody` service's `environment`.
## The four services
| Service | Image | Published | Address on `mirobody_network` |
| ------- | ----- | --------- | ----------------------------- |
| `pg` | `pgvector/pgvector:pg17-trixie` | `18082:5432` | `10.108.0.2` |
| `redis` | `redis:7.0-alpine` | `18089:6379` | `10.108.0.9` |
| `mirobody` | `mirobody`, built locally | `18080:18080` | `10.108.0.8` |
| `mirobody_worker` | `mirobody`, the same image | none — `ports: []` | `10.108.0.11` |
The network is a bridge named `mirobody_network` with an explicit IPAM block: subnet `10.108.0.0/24`, gateway `10.108.0.1`. Addresses are pinned rather than discovered, which is the whole reason `config.yaml` can ship `PG_HOST: 10.108.0.2` and `REDIS_HOST: 10.108.0.9` as working defaults with no service-name resolution involved.
### pg — PostgreSQL with pgvector
Three environment variables initialise the cluster on first boot — `POSTGRES_USER: holistic_user`, `POSTGRES_DB: holistic_db`, `POSTGRES_PASSWORD: REPLACE_THIS_VALUE_IN_PRODUCTION` — and they line up with `PG_USER` and `PG_DBNAME` in `config.yaml`. The data directory is the `mirobody_postgres` volume.
**pgvector is a hard dependency, not an optimisation.** `mirobody/schema/00_init_schema.sql` creates `vector`, `pg_trgm` and `pgcrypto`, and `01_basedata.sql` then declares `vector(1024)` columns on `fhir_indicators` and `th_series_dim` with HNSW indexes using `vector_cosine_ops`. A plain `postgres:17` image has `pg_trgm` and `pgcrypto` but no `vector`, so the bootstrap fails, those columns are never created, and semantic indicator search has nowhere to store its embeddings. Keep an image or a managed instance that provides the extension.
### redis
Redis is not started bare; the service overrides `command` with an explicit flag list:
```yaml compose.yaml
redis-server
--bind 0.0.0.0 --port 6379 --protected-mode no
--requirepass REPLACE_THIS_VALUE_IN_PRODUCTION
--maxmemory 512mb --maxmemory-policy allkeys-lru
--appendonly yes --appendfilename "appendonly.aof" --appendfsync everysec
--loglevel notice --timeout 60 --tcp-keepalive 30
--io-threads 4 --io-threads-do-reads yes --tcp-backlog 511
```
The `redis` service declares **no volume**, so although append-only persistence is switched on, the AOF file lives in the container's writable layer and goes away when the container is removed. For the development stack that is deliberate — Redis holds rate-limit counters, task queues and provider pull locks, all of which can be rebuilt. If you want them to survive, add a volume. Note also that `allkeys-lru` evicts *any* key under memory pressure, not only keys with a TTL.
### mirobody
The HTTP process. It depends on `pg` and `redis`, publishes `18080:18080`, and takes six environment variables:
| Variable | Value | Note |
| -------- | ----- | ---- |
| `ENV` | `${ENV}` | Substituted by compose from `.env`. Chooses which `config.{env}.yaml` is loaded. |
| `CONFIG_SERVER` · `CONFIG_TOKEN` | `${…}` | Forwarded for the optional remote config server. These two are read from the environment only — putting them in YAML has no effect. |
| `PYTHONUNBUFFERED` | `1` | Log lines appear in `docker compose logs` immediately. |
| `PYTHONPATH` | `/app` | So `mirobody serve` resolves against the bind mount. |
| `HTTP_HOST` · `HTTP_PORT` | `0.0.0.0` · `18080` | Both are commented out in `config.yaml`, and the fallback in code is `0.0.0.0` and port **80** — so without these two entries the container would listen on the wrong port. |
Environment variables outrank both YAML layers in the config loader. So inside this container, setting `HTTP_PORT` in your `config.{env}.yaml` does **nothing** — the compose `environment` entry wins. To move the port, change it in `compose.yaml` on both sides of the mapping.
### mirobody_worker
The worker is declared as `<<: *mirobody_base`, a YAML merge of the `mirobody` service, and then overrides four keys:
| Key | Override | Why |
| --- | -------- | --- |
| `ports` | `[]` | Reset to empty. Inheriting `18080:18080` would make two containers claim the same host port. |
| `command` | activate the venv, `mirobody worker` | Deliberately **no** `pip install` — the anchor's install step already ran in `mirobody`. |
| `networks` | `ipv4_address: 10.108.0.11` | A pinned address cannot be inherited; it would collide. |
| `depends_on` | `pg`, `redis`, **and** `mirobody` | The shared `site_packages` volume has to be populated before the worker imports the package. |
Everything else — `image`, `volumes`, `environment` — is inherited unchanged.
YAML merge keys are shallow: any key the worker declares **replaces** the anchor's value outright rather than merging into it. That is why `ports: []` has to be written as an explicit empty list; omitting the key would inherit the mapping instead of clearing it. It also means the worker carries `HTTP_HOST` and `HTTP_PORT` in its environment and simply never uses them — it runs no HTTP server and registers no routes.
## Named volumes
Four volumes, and each one is doing a distinct job:
| Volume | Mount point | Holds |
| ------ | ----------- | ----- |
| `mirobody_postgres` | `/var/lib/postgresql/data` | The database cluster. |
| `mirobody_upload` | `/app/.theta/mcp/upload` | Uploaded files when no object store is configured — this is `LocalStorage`'s default base path. |
| `mirobody_charts` | `/app/.theta/mcp/charts` | Rendered chart PNGs, served back out at `/charts`. |
| `mirobody_site_packages` | `/root/venv/lib/python3.12/site-packages` | Installed Python dependencies, plus the `.deps_hash` marker described below. |
The last three exist because of the bind mount. `.:/app` maps your working copy into the container, so if the upload directory were an ordinary path it would materialise inside your checkout. Mounting a named volume on top of a subdirectory of the bind mount keeps that content in Docker and out of the repository, while still surviving `docker compose down`.
`docker compose down -v` removes all five — which means the database *and* every installed dependency. The next start reinstalls the Python and npm trees from scratch. Use plain `docker compose down` unless discarding data is the point.
## Dependency installation at container start
Both application containers run the same shell pipeline as their `command`: activate the venv in the image → checksum `pyproject.toml` and `requirements.txt` → compare against a marker inside the site-packages volume → `pip install` only on a mismatch, otherwise print `>> deps unchanged, skip install` → then start the server or the worker. It is chained with `&&`, so a failed install never reaches the start.
For day-to-day development there are only two conclusions:
- **Changing a `.py` file needs a restart and nothing else.** The source is bind-mounted and the install is editable, so code goes through no build step.
- **Only a changed dependency set reinstalls.** The checksum marker lives *inside* the site-packages volume, so it cannot drift from the packages it describes: delete the volume and the marker goes with it.
To force a reinstall without destroying the database:
```bash
docker compose exec mirobody rm -f /root/venv/lib/python3.12/site-packages/.deps_hash
docker compose restart mirobody mirobody_worker
```
## Day-to-day operations
```bash
docker compose ps # what is up
docker compose logs -f mirobody mirobody_worker # both application logs
docker compose restart mirobody mirobody_worker # pick up a config change
docker compose exec mirobody bash # a shell in the server container
docker compose exec pg psql -U holistic_user -d holistic_db
docker compose down # stop, keep the volumes
```
Configuration is read once at startup, so a change to `config.{env}.yaml` needs a restart of both application containers — the worker loads the same files and is just as stale otherwise.
## Troubleshooting
Before starting, `deploy.sh` greps `docker ps` for `":->"` and stops what it finds. That only reaches **containers publishing exactly that host port** — a non-Docker process holding the port is untouched, and so is a container that publishes the same service on a different host port. Free the port, or change the left-hand side of the mapping in `compose.yaml`; if you move 18080, change `HTTP_PORT` in the `mirobody` environment to match.
Read `docker compose logs mirobody`. Both application containers run a shell pipeline chained with `&&`, so the failure is almost always inside it — `npm install` or `pip install` could not reach a registry, or the import failed. Note that `depends_on` waits for the dependency to *start*, not to be ready, and the file declares no healthchecks, so on a cold machine the engine can reach a PostgreSQL that is still initialising. Restarting the container is enough in that case.
The install happens at container start, inside the container, so it needs egress to the npm registry — pinned to `registry.npmmirror.com` in the image — and to PyPI. A failure leaves no `.deps_hash`, so the next start retries from scratch. To work through it interactively:
```bash
docker compose exec mirobody bash
source /root/venv/bin/activate
pip install -r requirements.txt
```
The build is skipped whenever the md5 of the inline Dockerfile text matches the `dockerfile.md5` label on the local `mirobody` image, and nothing else is compared. Remove the image to force the rebuild:
```bash
docker image rm mirobody && ./deploy.sh
```
Three causes, in order of likelihood. The engine reads config once at startup — restart. Environment variables beat both YAML layers, so anything set in the compose `environment` block (`HTTP_HOST`, `HTTP_PORT`, `ENV`) cannot be overridden from a file. And on first load the loader **encrypts secret-looking values in place**: any key matching `_KEY`, `_PASSWORD`, `_PASS`, `_PWD`, `_SECRET`, `_SK` or `_TOKEN` is rewritten into your `config.{env}.yaml` as ciphertext. Finding `gAAAA…` where you typed a password is the expected behaviour, not corruption.
## Next steps
The placeholders, the demo logins, and everything else that has to change
The three layers and what each key group controls
The other two install paths — local Python and the PyPI package
What the two processes are actually doing
---
# Production Deployment
https://docs.mirobody.ai/en/deployment/production
What has to change between a deploy.sh stack and a production one: placeholders, secrets, the demo logins, CORS, rate limits, the public URL, and running the worker on its own.
Production runs the same two processes as a laptop — `mirobody serve` for HTTP and `mirobody worker` for the queues — against the same three configuration layers. What changes is everything the repository ships as a convenient default: placeholder credentials, three demo accounts, a debug log level, and a rate limit tuned for one person clicking around. This page is the delta from a `./deploy.sh` stack, key by key.
## Production checklist
- [ ] Replace every `REPLACE_THIS_VALUE_IN_PRODUCTION` — the full list is in the next section
- [ ] Supply `CONFIG_ENCRYPTION_KEY` from the environment rather than a `.env` file on disk
- [ ] Generate `JWT_KEY` yourself; the one `deploy.sh` wrote came from the shell's `$RANDOM`
- [ ] Set `PG_ENCRYPTION_KEY`, and keep it out of the config file
- [ ] Clear `EMAIL_PREDEFINE_CODES`
- [ ] Terminate TLS in front of the engine and forward the public `Host` header
- [ ] Set `MCP_PUBLIC_URL` to the public HTTPS origin
- [ ] Narrow the CORS entries in `HTTP_HEADERS` to origins you own
- [ ] Revisit `REQUEST_RATE_LIMITER`, and rate-limit anonymous traffic at the proxy
- [ ] Make sure the proxy does not buffer the `/api/chat` event stream
- [ ] A managed PostgreSQL where `vector`, `pg_trgm` and `pgcrypto` are available
- [ ] Apply `mirobody/schema/` yourself — a production `ENV` skips the built-in bootstrap
- [ ] Redis with a password, and a persistence and eviction policy you chose deliberately
- [ ] Configure `S3_*` instead of writing uploads to a container-local directory
- [ ] Automate backups and rehearse a restore
- [ ] `LOG_LEVEL: INFO` or higher — `DEBUG` also switches on FastAPI's debug mode
- [ ] Decide between `LOG_NAME` + `LOG_DIR` and capturing the console
- [ ] Point liveness and readiness probes at `GET /api/health`
- [ ] Deploy and scale `mirobody worker` separately from `mirobody serve`
- [ ] Set `DEFAULT_TIMEZONE` to something your users actually live in
## Placeholders you must replace
The template ships a literal sentinel string wherever a value cannot have a safe default. Six places carry it:
| Key | Location | What it is |
| --- | ----- | ---------- |
| `PG_PASSWORD` | `config.yaml` | The database password. |
| `PG_ENCRYPTION_KEY` | `config.yaml` | Key for the encrypted columns in the schema. |
| `REDIS_PASSWORD` | `config.yaml` | Has to match whatever the Redis server was started with. |
| `JWT_KEY` | `config.yaml`, and `deploy.sh` seeds a random one into `config.{env}.yaml` | HS256 signing key for the access tokens. |
| `POSTGRES_PASSWORD` | `compose.yaml`, the `pg` service | Initialises the cluster on first boot; must match `PG_PASSWORD`. |
| `--requirepass` | `compose.yaml`, the `redis` command | The Redis password; must match `REDIS_PASSWORD`. |
The loader treats the sentinel specially: a value that is exactly `REPLACE_THIS_VALUE_IN_PRODUCTION` is skipped by the automatic encryption pass, so it stays legible in the file instead of turning into ciphertext you can no longer recognise. That is the only thing special about it — it is not a working credential.
Two keys in the template look like they belong on the list above but should **not** be configured.
`JWT_PRIVATE_KEY` is a commented line in `config.yaml`, and the RS256 path behind it is not wired up: `get_jwt_options` returns only `jwt_key`, and the `jwt_private_key` argument is commented out on both the config and the server side. Use `JWT_KEY`.
`DATABASE_DECRYPTION_KEY` is annotated **DEPRECATED** in `config.yaml`, with a note that it may be removed. Do not add it to a new deployment.
## Secrets and the encryption key
Any config key whose name matches `_KEY`, `_PASSWORD`, `_PASS`, `_PWD`, `_SECRET`, `_SK` or `_TOKEN` — and does not end in `_URL` — is encrypted when the file is loaded, and **the YAML file is rewritten in place** with the ciphertext. So a password you paste into `config.{env}.yaml` becomes a `gAAAA…` string after the first start. That is by design, and it means the process needs write access to that file.
The Fernet key is derived from `CONFIG_ENCRYPTION_KEY` alone: the value is trimmed, truncated to 32 characters, right-padded with `0` to 32 bytes, then base64-url-encoded. Two consequences follow. Only the first 32 characters ever matter, so a longer key buys nothing; and a shorter key is silently padded, so aim for exactly 32.
Because the derivation depends on nothing but `CONFIG_ENCRYPTION_KEY`, rotating that key makes every value already encrypted with the old one undecryptable. Rotation means re-supplying the plaintext for each of them, not just swapping the key.
Environment variables outrank both YAML layers at read time, so the cleanest production shape is to keep no secrets in files at all. Then nothing needs encrypting, the config file is safe to commit, and rotation is a restart with a new environment.
```bash
export ENV=prod
export CONFIG_ENCRYPTION_KEY="$(openssl rand -hex 16)" # 32 characters
export PG_PASSWORD="…"
export PG_ENCRYPTION_KEY="$(openssl rand -hex 16)"
export REDIS_PASSWORD="…"
export JWT_KEY="$(openssl rand -hex 32)"
export GOOGLE_API_KEY="…"
```
Values are read in this order, later layers overriding earlier ones: the repository template `config.yaml`, then a remote config document if `CONFIG_SERVER` and `CONFIG_TOKEN` are set, then your `config.{env}.yaml`. Environment variables win over all three. `.env` is loaded with `setdefault`, so a real environment variable also beats the file — which is what makes the same image work across environments.
## Turn off the demo logins
`config.yaml` ships three accounts with a fixed verification code so that a fresh clone can sign in immediately:
```yaml config.yaml
EMAIL_PREDEFINE_CODES:
exp1@mirobody.ai: '111111'
exp2@mirobody.ai: '111111'
exp3@mirobody.ai: '111111'
```
Later layers override earlier ones by key, so declaring the key with no value in your override clears it — the loader falls back to an empty mapping when the value is not a dictionary:
```yaml config.prod.yaml
EMAIL_PREDEFINE_CODES:
```
Leaving these in place is worse than it looks. Anyone who can reach `/email/verify` can sign in as those three addresses without receiving any mail. On top of that, `Server.start` calls `print_predefined_codes()` on every boot, which writes each address and its code to the log in plaintext — so the credentials end up in your log aggregator as well.
## Logging and the debug flag
`config.yaml` ships `LOG_LEVEL: DEBUG`, and the level does more than control verbosity. `Server.start` constructs the app as `FastAPI(debug = config.log.level <= logging.DEBUG, …)`, so a `DEBUG` level also switches FastAPI into debug mode.
Set `LOG_LEVEL: INFO` (or higher) in production. An unrecognised level name falls back to `INFO`, but `DEBUG` is spelled correctly in the template, so it will be honoured unless you override it.
Logs go to the console unless you set both `LOG_NAME` and `LOG_DIR`, in which case files are written as `{date}_{name}_{time}.log` in that directory. On a container platform, leaving them unset and collecting the console is usually the better choice — the compose file already sets `PYTHONUNBUFFERED=1` so lines are not held back.
## CORS and rate limiting
The `HTTP_HEADERS` block is entirely commented out in the template, and its example origin is `http://localhost:18080`. If you leave it unset, no CORS middleware is added at all — fine when the bundled SPA is served from the same origin, but a separately hosted front end will be blocked. Exactly five keys are read out of the block:
```yaml config.prod.yaml
HTTP_HEADERS:
Access-Control-Allow-Origin: 'https://app.example.com'
Access-Control-Allow-Credentials: 'true'
Access-Control-Allow-Methods: 'GET, POST, PUT, DELETE, OPTIONS'
Access-Control-Allow-Headers: 'Authorization, Content-Type'
Access-Control-Max-Age: '600'
```
`Access-Control-Allow-Origin` is passed through as a single origin, not a list. Pairing `*` with credentials is rejected by browsers, and the engine logs a warning if you try.
`REQUEST_RATE_LIMITER` maps a URL path to a per-minute budget; the defaults are `/api/chat: 6` and `/api/session: 6`. The implementation is a Redis `INCR` on `limit:{user_id}:{path}` with a 60-second expiry set on the first hit, returning `429` with `Retry-After` set to the key's remaining TTL once the threshold is passed.
Two limits worth designing around. The counter is keyed by user, and the middleware only runs when the caller is authenticated — anonymous traffic is never counted, so anything reachable without a token has to be limited at the proxy. And paths are matched **exactly**, not by prefix, so a rule has to name each path it should cover.
## Public URL, TLS and the reverse proxy
The engine serves plain HTTP on `HTTP_HOST:HTTP_PORT` through uvicorn; TLS terminates in front of it.
`MCP_PUBLIC_URL` is the publicly reachable HTTPS origin, and it does more than appear in a banner. When no object store is configured, `LocalStorage` builds its file URLs as `{MCP_PUBLIC_URL}/files` — with the key unset, that base is empty. It is also the origin remote MCP clients are pointed at, so set it to the address the outside world uses, not the container's.
The OAuth discovery documents build their endpoint URLs from the **incoming request**: the scheme is `https` for any hostname other than `localhost`, the host is `request.url.hostname`, and no port is included. So a proxy that rewrites the `Host` header, or a public origin that is not reachable on 443, produces a discovery document advertising endpoints that do not resolve. Forward the real `Host`, and serve the public origin over standard HTTPS.
`/api/chat` is a Server-Sent Events stream. The engine already sends `cache-control: no-cache, no-transform` and `x-accel-buffering: no` on that response — make sure your proxy honours them rather than buffering the stream into a single reply.
`HTTP_URI_PREFIX` is not a general sub-path mount. It is prepended to the **Starlette** routes — the chat surface, sign-in, MCP, WebAuthn, the health check — but the FastAPI routers declare their own prefixes and are unaffected, so the Pulse `/api/v1/*` routes do not move with it. Serving the engine on its own hostname avoids the mismatch entirely.
## Data stores
### PostgreSQL
The schema needs three extensions, created by `mirobody/schema/00_init_schema.sql`: `vector`, `pg_trgm` and `pgcrypto`. Either grant the role the rights to create them or have them installed ahead of time; `vector` in particular is load-bearing, since the indicator tables declare `vector(1024)` columns with HNSW indexes.
When `ENV` is one of `TEST`, `GRAY`, `PROD` or `TEST-INLOCAL`, `Server.start` skips both the schema creation and the execution of `mirobody/schema/`. A production deployment therefore **must** apply those files itself — otherwise the engine starts happily against an empty database and only fails once a request touches a missing table.
The files are executed in filename order, so applying them by hand is a sorted pass:
```bash
for f in mirobody/schema/*.sql; do
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$f"
done
```
Note that the engine's own bootstrap logs and rolls back a failing file and then continues to the next one, so on a non-production `ENV` a partially applied schema is easy to miss — `ON_ERROR_STOP=1` above is deliberately stricter.
`PG_SCHEMA` defaults to `theta_ai`; the bootstrap splits the value on commas and creates each schema it names. Pool sizing is `PG_MIN_CONNECTION` (5) and `PG_MAX_CONNECTION` (20) — per process, and there are two processes, so budget the server's connection limit for both.
### Redis
Redis is not optional in a normal deployment. Three things use it: the rate limiter's counters, the worker's task queues, and the locks and temporary OAuth state in the provider pull path.
The compose Redis is configured for a laptop — `--maxmemory 512mb --maxmemory-policy allkeys-lru`, and no volume, so append-only persistence writes into the container's own filesystem. Both deserve a decision in production: `allkeys-lru` will evict *any* key under pressure, including a queued task or a pending OAuth handshake.
For a managed endpoint, `REDIS_SSL`, `REDIS_SSL_CHECK_HOSTNAME` and `REDIS_SSL_CERT_REQS` control the TLS side; the template ships them off. A second, separate connection can be declared by suffixing the same keys with `_LOG`.
### Object storage
`get_storage_client()` tries each cloud backend in turn and falls back to local disk. The S3 backend needs all four of `S3_KEY`, `S3_TOKEN`, `S3_REGION` and `S3_BUCKET` — if any is missing it raises, and the factory quietly falls through to `LocalStorage`. `S3_PREFIX` and `S3_CDN` are optional.
That silent fallback is the thing to watch: with an incomplete S3 block, uploads land in `./.theta/mcp/upload/` inside the container and charts in `./.theta/mcp/charts`. Behind more than one replica, a file written by one instance is then invisible to the others.
## Default timezone
`DEFAULT_TIMEZONE` is the fallback for a user who has not set one; both the template and the code default to `America/Los_Angeles`. A request can carry its own timezone, and a stored user record can too, so this value only decides what happens when neither does — which, for server-side jobs and for the first turn of a new account, is often.
## Deploy the worker separately
`mirobody serve` and `mirobody worker` are packaged the same way and configured the same way, but they should not be one deployment.
- **Only `main` serves traffic.** `Worker.start` starts no uvicorn and registers no routes, so a worker replica behind your load balancer would be a black hole.
- **They scale on different signals.** HTTP capacity follows request concurrency; queue capacity follows ingest volume. A bulk import can need many workers and no extra HTTP capacity, and a traffic spike is the reverse.
- **Extra replicas divide the work.** Consumers `BLPOP` a shared Redis list per task class, so additional worker replicas take from the same queue instead of duplicating it. Task discovery is automatic — every registered task gets its own consumer loop, and there are two today: indicator sync and profile refresh.
- **Give it time to drain.** `SIGINT` and `SIGTERM` set the stop events and the loops finish their current batch, so the termination grace period should exceed a typical task rather than truncate it.
For monitoring: an idle consumer logs a heartbeat every ten minutes naming its queue, and a task class can declare a maximum queue length, in which case enqueueing raises once the queue is at capacity — treat that as the signal that consumers are falling behind.
If you skip the worker entirely the API still answers every request. What stops is indicator sync and profile refresh, which means newly ingested indicators never get embeddings and semantic search silently returns stale results. It fails quietly, so do not discover it in production.
## Health checks
```bash
curl -s https://api.example.com/api/health
```
```json example response
{
"service": "mirobody",
"version": "…",
"tools": 12,
"public_tools": 4,
"authenticated_tools": 8,
"resources": 3,
"agents": 3
}
```
The endpoint is unauthenticated and answers from counters gathered at startup, without touching PostgreSQL or Redis. That makes it a good liveness probe and a poor readiness probe for the data stores — it will keep returning `200` while the database is unreachable. The counts are useful in their own right: if the extension directories you configured did not load, the tool and agent numbers say so immediately.
The worker has no HTTP surface at all. Supervise it by process liveness and by queue depth in Redis.
## An example production override
Everything that is not a secret, in one file:
```yaml config.prod.yaml
LOG_LEVEL: INFO
DEFAULT_TIMEZONE: UTC
HTTP_HOST: 0.0.0.0
HTTP_PORT: 18080
HTTP_HEADERS:
Access-Control-Allow-Origin: 'https://app.example.com'
Access-Control-Allow-Credentials: 'true'
Access-Control-Allow-Methods: 'GET, POST, PUT, DELETE, OPTIONS'
Access-Control-Allow-Headers: 'Authorization, Content-Type'
Access-Control-Max-Age: '600'
MCP_PUBLIC_URL: 'https://api.example.com'
REQUEST_RATE_LIMITER:
/api/chat: 30
/api/session: 30
PG_HOST: pg.internal
PG_PORT: 5432
PG_USER: mirobody
PG_DBNAME: mirobody
PG_SCHEMA: theta_ai
PG_MIN_CONNECTION: 5
PG_MAX_CONNECTION: 20
REDIS_HOST: redis.internal
REDIS_PORT: 6379
REDIS_SSL: true
S3_REGION: us-west-2
S3_BUCKET: mirobody-prod
S3_PREFIX: uploads/
# Clear the demo accounts the template ships.
EMAIL_PREDEFINE_CODES:
```
And the secrets, from the environment instead:
```bash
export ENV=prod
export CONFIG_ENCRYPTION_KEY="…"
export PG_PASSWORD="…"
export PG_ENCRYPTION_KEY="…"
export REDIS_PASSWORD="…"
export JWT_KEY="…"
export S3_KEY="…"
export S3_TOKEN="…"
export GOOGLE_API_KEY="…"
```
Note that `HTTP_HOST` and `HTTP_PORT` are in the file here for a deployment that reads YAML. Under the shipped `compose.yaml` those two arrive as container environment variables, which take priority — so there you change them in the compose file instead.
## Next steps
The image, the four services, and the volumes underneath them
Every key group, and what the three layers are for
The two processes, the middleware stack, and the route families
What `MCP_PUBLIC_URL` opens up once it is reachable