Amy

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

Authentication

Every authenticated endpoint expects a bearer token:

Authorization: Bearer amy_live_<random>

Two kinds of token exist:

KindPrefixHow you get oneScope
User API keyamy_live_…The CLI: amy whoami --print-key. Or the mobile app's settings screenFull 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"
  }
}
StatusWhen
400Validation failure (invalid_request, invalid_field)
401Missing/invalid token (missing_authorization, invalid_token)
403Authorized but not allowed (forbidden)
404Resource doesn't exist (<resource>_not_found)
409Conflict (already_exists, idempotency_key_mismatch)
422Semantic validation failure (unprocessable)
429Rate limited (rate_limit_exceeded)
500Internal error (internal_error), always include request_id when reporting
502/503/504Upstream 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.

PrefixResource
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_01HX2K3M4N5P6Q7R8S9T0V1W2X

Include 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
  }
}
FieldTypeDefaultNotes
messagesMessage[]requiredConversation so far. Last message must be from user.
streambooleantrueIf 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_memorybooleantrueWhether to inject memory into the agent context.
context.include_biomarkersbooleantrueWhether 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, messages is 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=completed

Filters:

ParamTypeDefault
statusqueued/running/completed/failedall
afterISO dateunbounded
beforeISO dateunbounded

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 size10 MB
Accepted typesapplication/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: processingparsed → 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": "[email protected]",
  "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.

HeaderValue
terra-signaturet=<unix-ts>,v1=<hex-hmac>
Content-Typeapplication/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:

typeWhat it means
activityA new workout
sleepA sleep session
bodyBody composition metrics
dailyDaily summary
large_request_processingBackfill chunk
lab_report.processedLab 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

VersionStatusSunset
v1currentTBD (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.

On this page