API Reference
Every endpoint, every shape. The authoritative source is the live OpenAPI spec at /openapi.json on any deployed backend, this page mirrors it in human-readable form.
Base URL: https://api.amy.health (or your own deployment).
Auth: Authorization: Bearer <api_key> on every request unless
otherwise noted. See Authentication.
Versioning: all endpoints live under /v1/. Breaking changes ship
under /v2/; v1 stays available with at least 6 months notice. See
Versioning.
Quick navigation
- Conventions, auth, errors, pagination, idempotency, IDs
- Streaming, SSE protocol for live turn events
- Resources
- Webhooks, Terra → Amy ingest
- Auth: CLI device flow
- Meta, health, OpenAPI, llms.txt
Conventions
Authentication
Every authenticated endpoint expects a bearer token:
Authorization: Bearer amy_live_<random>Two kinds of token exist:
| Kind | Prefix | How you get one | Scope |
|---|---|---|---|
| User API key | amy_live_… | The CLI: amy whoami --print-key. Or the mobile app's settings screen | Full user scope: read/write everything the user can |
| Admin key | (set via AMY_ADMIN_KEY secret) | Out of band | /admin/* endpoints only |
Unauthenticated requests get 401 Unauthorized with an error code of
missing_authorization or invalid_token.
Errors
Every error response has the same shape:
{
"error": {
"code": "turn_not_found",
"message": "No turn exists with id turn_abc123.",
"request_id": "req_01HX2K3M4N5P6Q7R8S9T0V1W2X",
"docs_url": "https://docs.amy.health/concepts/errors#turn_not_found"
}
}| Status | When |
|---|---|
| 400 | Validation failure (invalid_request, invalid_field) |
| 401 | Missing/invalid token (missing_authorization, invalid_token) |
| 403 | Authorized but not allowed (forbidden) |
| 404 | Resource doesn't exist (<resource>_not_found) |
| 409 | Conflict (already_exists, idempotency_key_mismatch) |
| 422 | Semantic validation failure (unprocessable) |
| 429 | Rate limited (rate_limit_exceeded) |
| 500 | Internal error (internal_error), always include request_id when reporting |
| 502/503/504 | Upstream failure (upstream_unavailable) |
Full code list: Concepts: Errors.
Pagination
Every list endpoint uses cursor-based pagination:
GET /v1/turns?limit=20&cursor=eyJ0IjoxNzMy...Response:
{
"data": [ ... ],
"next_cursor": "eyJ0IjoxNzMy...",
"has_more": true
}When has_more is false, next_cursor is null. Default limit is
20, max 100.
Idempotency
Every POST/PATCH/DELETE accepts an Idempotency-Key header:
POST /v1/turns
Idempotency-Key: 6e8b3a1c-…The first response is cached in KV for 24 hours. Subsequent requests with the same key return the cached response unchanged, even if the body differs.
If the body does differ for the same key, the API returns 409 idempotency_key_mismatch to prevent silent overwrites.
The TypeScript SDK auto-generates a UUIDv4 for every write call. You can
override it via options.idempotencyKey.
IDs
Every resource has a typed prefix. Easy to grep, hard to confuse.
| Prefix | Resource |
|---|---|
turn_… | Turn |
lab_… | Lab upload |
src_… | Source connection |
mem_… | Memory entry |
user_… | User |
req_… | Request ID (in errors and logs) |
IDs are ULIDs (sortable by creation time) under the prefix.
Request IDs
Every response includes:
X-Request-Id: req_01HX2K3M4N5P6Q7R8S9T0V1W2XInclude this when reporting bugs. It maps to a single line in the backend logs.
Streaming
The GET /v1/turns/:id/events endpoint returns Server-Sent Events.
GET /v1/turns/turn_abc/events
Accept: text/event-stream
Authorization: Bearer amy_live_…Response (streaming):
id: 1
event: turn.started
data: {"turn_id":"turn_abc","at":"2026-05-25T10:00:00Z"}
id: 2
event: agent.started
data: {"agent":"investigator","at":"2026-05-25T10:00:01Z"}
id: 3
event: agent.thought
data: {"agent":"investigator","delta":"Looking at "}
id: 4
event: agent.thought
data: {"agent":"investigator","delta":"your sleep data..."}
...
id: 87
event: turn.completed
data: {"turn_id":"turn_abc","result":{...}}Reconnects: clients should pass Last-Event-Id: <last_seen_id> on
reconnect. The server replays from that ID forward (replays are
available for 1 hour after turn completion).
Full event-type catalog: Concepts: Streaming.
Resources
Turns
A turn is one round-trip of the agent: user asks → Amy answers, including all the multi-step reasoning in between.
POST /v1/turns, Start a turn
Request:
{
"messages": [
{ "role": "user", "content": "Is my sleep score drop meaningful?" }
],
"stream": true,
"context": {
"include_memory": true,
"include_biomarkers": true
}
}| Field | Type | Default | Notes |
|---|---|---|---|
messages | Message[] | required | Conversation so far. Last message must be from user. |
stream | boolean | true | If true, response is 202 Accepted and you subscribe via /events. If false, response blocks until done (up to 7 min) and returns the full Turn. |
context.include_memory | boolean | true | Whether to inject memory into the agent context. |
context.include_biomarkers | boolean | true | Whether to inject the latest biomarker snapshot. |
Response (202, streaming):
{
"id": "turn_01HX2K3M4N5P6Q7R8S9T0V1W2X",
"status": "queued",
"created_at": "2026-05-25T10:00:00Z",
"stream_url": "/v1/turns/turn_01HX.../events"
}Response (200, non-streaming, when stream: false): the full
Turn object below.
Errors:
400 invalid_request,messagesis empty or last message isn't from user.429 rate_limit_exceeded, too many turns in flight (per-user concurrency cap is 3).
GET /v1/turns/:id, Get a turn
Response:
{
"id": "turn_01HX...",
"status": "completed",
"created_at": "2026-05-25T10:00:00Z",
"completed_at": "2026-05-25T10:03:42Z",
"messages": [...],
"result": {
"answer": "Short answer: no — by the most defensible read...",
"fact_sheet": [
{ "claim": "average_rhr_60.39_bpm", "value": 60.39, "unit": "bpm",
"source": "data_science", "n": 160, "window": "all" }
],
"agents_used": ["data_science", "domain_expert"],
"cost_usd": 0.1288,
"duration_ms": 222000
},
"error": null
}result is null until status is completed. error is non-null
when status is failed.
GET /v1/turns/:id/events, Stream events
See Streaming above.
GET /v1/turns, List turns
GET /v1/turns?limit=20&cursor=…&status=completedFilters:
| Param | Type | Default |
|---|---|---|
status | queued/running/completed/failed | all |
after | ISO date | unbounded |
before | ISO date | unbounded |
Response: paginated list of summary turns (no messages, no result)
for index views.
Sources
A source is a wearable or data provider the user has connected.
GET /v1/sources, List
{
"data": [
{ "id": "src_…", "provider": "whoop",
"connected_at": "2025-11-01T...", "last_sync_at": "...",
"status": "active" }
]
}POST /v1/sources/terra/connect, Get Terra widget URL
{ "redirect_url": "amy://oauth/terra/callback" }Response:
{ "widget_url": "https://widget.tryterra.co/session/abc..." }Open this URL in a browser; Terra handles the OAuth dance and redirects
to redirect_url on completion.
DELETE /v1/sources/:id, Disconnect
204 No Content. Revokes Terra access and deletes ingested data for
that source.
Labs
A lab is one uploaded bloodwork report.
POST /v1/labs, Upload
Multipart form upload:
POST /v1/labs
Content-Type: multipart/form-data; boundary=…
--…
Content-Disposition: form-data; name="file"; filename="panel.pdf"
Content-Type: application/pdf
<binary>
--…--| Limits | |
|---|---|
| Max file size | 10 MB |
| Accepted types | application/pdf, image/jpeg, image/png, image/heic |
Response (202):
{
"id": "lab_01HX...",
"status": "processing",
"filename": "panel.pdf",
"uploaded_at": "2026-05-25T10:00:00Z"
}The file lands in R2 immediately; Terra OCR runs asynchronously (~30s).
GET /v1/labs/:id, Get status + parsed biomarkers
{
"id": "lab_…",
"status": "parsed",
"filename": "panel.pdf",
"uploaded_at": "...",
"parsed_at": "...",
"biomarkers": [
{ "name": "ldl_cholesterol", "value": 124, "unit": "mg/dL",
"reference_range": "<100", "out_of_range": true }
]
}Status values: processing → parsed → optionally failed with an
error field.
GET /v1/labs, List
Paginated list, summary only.
Data
Sync, query, and aggregate views of wearable + lab data.
GET /v1/data/sync?cursor=…, Delta sync
Returns everything new since cursor. Used by offline-first clients
(the CLI's local SQLite).
{
"activities": [...],
"sleep_sessions": [...],
"daily_summaries": [...],
"biomarkers": [...],
"next_cursor": "...",
"has_more": false
}GET /v1/data/biomarkers, Timeseries
GET /v1/data/biomarkers?name=resting_heart_rate&from=2026-01-01&to=2026-05-25{
"data": [
{ "date": "2026-01-01", "value": 58 },
{ "date": "2026-01-02", "value": 60 }
]
}GET /v1/data/summaries/:date, Daily summary
{
"date": "2026-05-24",
"sleep_score": 78,
"recovery_score": 65,
"hrv_ms": 52,
"resting_heart_rate": 59,
"strain": 12.4
}Memory
The facts Amy remembers about the user, populated by the agent at the end of every turn.
GET /v1/memory, List
{
"data": [
{ "id": "mem_…", "text": "Goal: lift deep sleep by 15 min",
"category": "goal", "created_at": "...", "source_turn_id": "turn_…" }
]
}Categories: goal · insight · preference · history.
POST /v1/memory, Add
{ "text": "Vegetarian. No fish.", "category": "preference" }DELETE /v1/memory/:id, Remove
204 No Content.
Me
The current user.
GET /v1/me
{
"id": "user_…",
"email": "you@example.com",
"name": "Yatendra",
"created_at": "...",
"sources_count": 2,
"labs_count": 3,
"turns_count": 47
}PATCH /v1/me
{ "name": "Yat" }Webhooks
POST /webhooks/terra
The Terra ingest webhook. Verified by HMAC.
| Header | Value |
|---|---|
terra-signature | t=<unix-ts>,v1=<hex-hmac> |
Content-Type | application/json |
Verification: HMAC-SHA256 of <timestamp>.<raw-body> with the secret
TERRA_WEBHOOK_SECRET. Reject if the timestamp is more than 5 minutes
old or the signature doesn't match.
The endpoint is idempotent by Terra's event ID, duplicate deliveries are no-ops.
Event types handled:
type | What it means |
|---|---|
activity | A new workout |
sleep | A sleep session |
body | Body composition metrics |
daily | Daily summary |
large_request_processing | Backfill chunk |
lab_report.processed | Lab OCR finished |
Full payload reference: Terra docs.
Auth: CLI device flow
For the CLI to authenticate without typing a key, it uses a one-shot device flow.
POST /v1/auth/cli/start
Anonymous. Returns a short code the user enters in a browser.
{
"device_code": "AMY-XKCD-1234",
"verification_url": "https://app.amy.health/cli/AMY-XKCD-1234",
"expires_in": 600,
"interval": 5
}POST /v1/auth/cli/approve
Called by the browser flow after the user signs in via Clerk and approves the device. Returns the API key.
The CLI polls /v1/me every interval seconds with the device code as
the bearer; on approval it gets a 200 with the real API key in the
response body.
Meta
GET /healthz
Liveness. Always 200 OK. Used by monitoring.
GET /openapi.json
The live OpenAPI 3.1 spec. Generated from the route definitions at build time. Use this to generate clients in any language.
GET /llms.txt
The llmstxt.org index for AI agents. Lists every doc page with a 1-line description.
GET /llms-full.txt
Every doc concatenated, for one-shot context loading by AI agents.
Versioning
| Version | Status | Sunset |
|---|---|---|
v1 | current | TBD (6 mo notice) |
Breaking changes that ship under v2 will include a migration guide
and at least 6 months of overlap. Additive changes (new endpoints, new
optional fields) ship under v1 without notice.
Code samples
Every endpoint above is callable from the TypeScript SDK with the same method shape. Example:
import { Amy } from "@amy/sdk";
const amy = new Amy({ apiKey: process.env.AMY_API_KEY });
// Start a turn
const turn = await amy.turns.create({
messages: [{ role: "user", content: "How's my recovery?" }],
});
// Stream events
for await (const event of amy.turns.stream(turn.id)) {
if (event.type === "agent.thought") process.stdout.write(event.delta);
if (event.type === "turn.completed") console.log("\n", event.result.answer);
}
// Connect a wearable
const { widget_url } = await amy.sources.terra.connect({
redirect_url: "https://your-app/callback",
});
// Upload a lab
const lab = await amy.labs.upload({ file: fileBlob });
// Read memory
const { data: facts } = await amy.memory.list();Full SDK reference: SDK: TypeScript.
Architecture
The complete picture of how Amy's backend is built, why each piece was chosen, and where the seams are.
Turns
A turn is one round-trip of the agent: the user asks, Amy answers, and every quantitative claim in the answer has been checked. It's the unit of work the backend dispatches, persists, streams, and bi…