API Reference
Every endpoint, every shape. The live OpenAPI spec at /openapi.json is the source of truth; this page mirrors it in human-readable form and the interactive explorer renders it with Try-It-Now.
Base URL: https://amy.heyamy.xyz
Auth: Authorization: Bearer <api_key> on every request unless
otherwise noted. See Authentication.
Three views of the same API. ① This page (prose) · ② the live spec at /openapi.json · ③ the interactive explorer at /reference with auth + Try-It-Now baked in. Endpoints not in /openapi.json are planned but not yet shipped — marked Planned below.
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 browser flow
- Meta, health, OpenAPI, llms.txt
Conventions
Authentication
Every authenticated endpoint expects a bearer token:
Authorization: Bearer <clerk-jwt-or-amy-cli-jwt>Clerk is the source of truth. Every authenticated endpoint verifies the bearer in this order:
- Try as an Amy-signed JWT (CLI sessions; see below).
- Otherwise verify as a Clerk session JWT via Clerk's JWKS.
Either form is accepted — pick the one that fits your surface:
| Surface | Token kind | How you get it |
|---|---|---|
| Mobile / web (recommended) | Clerk session JWT | @clerk/expo (mobile) or @clerk/react (web) → useAuth().getToken(). Refreshes itself; no exchange step needed. |
| CLI | 30-day Amy JWT | amy login (browser sign-in) → token saved to ~/.amy/credentials.json. amy whoami --print-key prints it. |
Admin endpoints (/admin/*) | AMY_ADMIN_KEY secret | Out of band; not for user-facing clients. |
Clerk Core 3 (March 2026) renamed the packages:
@clerk/clerk-expo→@clerk/expo,@clerk/clerk-react→@clerk/react. The@clerk/nextjspackage kept its name but bumped to v6. If you're upgrading older code,npx @clerk/upgradehandles the rename + the<SignedIn>/<SignedOut>→<Show when="signed-in">consolidation automatically.
The Amy JWT exists purely as a convenience for terminal sessions — the CLI can't easily run Clerk in-process, so it does a one-time browser sign-in and mints a long-lived bearer. Web and mobile clients should send the Clerk session token directly:
// React / React Native via Clerk
const { getToken } = useAuth();
const amy = new Amy({ apiKeyProvider: () => getToken() });The SDK's apiKeyProvider calls getToken() per request, and Clerk
handles its own caching and refresh.
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.heyamy.xyz/docs/concepts/errors#turn_not_found"
}
}Every authenticated 401/403, every 4xx from a typed route, and every 5xx follows this shape. (A handful of legacy Phase-1 routes return a flatter shape; treat that as a bug and report it.)
| 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 | Too many requests (concurrency_limit_exceeded for in-flight turns; 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 opaque random strings under the prefix — treat them as
unordered (not time-sortable). Order resources by their created_at /
ts field instead.
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 <clerk-jwt-or-amy-cli-jwt>Response (streaming):
event: turn.started
id: 1
data: {"type":"turn.started","seq":1,"at":"2026-05-25T10:00:00Z","turn_id":"turn_abc"}
event: phase
id: 2
data: {"type":"phase","agent":"orchestrator","phase":"classifying query vagueness"}
event: agent_start
id: 7
data: {"type":"agent_start","agent":"Data Science Agent","question":"Compute the user's average HRV…"}
event: synthesis_delta
id: 41
data: {"type":"synthesis_delta","text":"Your 30-day HRV picture "}
...
event: turn.completed
id: 9007199254740991
data: {"type":"turn.completed","turn_id":"turn_abc","result":{...}}(id is monotonic but skips values; the terminal frame uses a large
sentinel id.)
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 | Reserved. Today every POST returns 202 Accepted with a stream_url; subscribe to /events to watch live, or poll GET /v1/turns/:id for the final result. A blocking stream: false mode is planned. |
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):
{
"id": "turn_01HX2K3M4N5P6Q7R8S9T0V1W2X",
"status": "queued",
"created_at": "2026-05-25T10:00:00Z",
"stream_url": "/v1/turns/turn_01HX.../events"
}Errors:
400 invalid_request,messagesis empty or last message isn't from user.429 concurrency_limit_exceeded, you already have many turns in flight; wait for them to complete.
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": [
{ "key": "ds-001.mean", "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
Both fields are optional (provider defaults to WHOOP; redirect_url
defaults to a hosted "connected" page). The legacy alias POST /v1/connect
behaves identically.
{ "provider": "WHOOP", "redirect_url": "amy://oauth/terra/callback" }Response:
{ "widget_url": "https://widget.tryterra.co/session/abc...", "session_id": "..." }Open widget_url in a browser; Terra handles the OAuth dance and
redirects to redirect_url on completion. If Terra is unreachable, the
endpoint returns 502 upstream_unavailable.
DELETE /v1/sources/:id, Disconnect
204 No Content. Marks the connection inactive (requires the src_…
id from GET /v1/sources). Ingested historical data is retained —
only the live connection is deactivated. 404 source_not_found if the
id doesn't match an active connection you own.
Labs
A lab is one uploaded bloodwork report.
POST /v1/labs/upload, Upload
Multipart form upload:
POST /v1/labs/upload
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 |
Response (200):
{
"ok": true,
"upload_id": "1bfbb25a-0af8-4109-b0eb-ce64e5f7df7b",
"storage_key": "lab-uploads/user_.../1bfbb25a....pdf",
"terra_status": "submitted",
"note": "Terra is parsing your report. Run `amy sync` in ~30s to pull biomarkers."
}The file lands in R2 immediately; Terra OCR runs asynchronously (~30s).
When parsing finishes the webhook stores biomarkers; pull them with
GET /v1/data/sync.
GET /v1/labs, List
{
"uploads": [
{
"id": "1bfbb25a-0af8-4109-b0eb-ce64e5f7df7b",
"uploaded_at": "2026-05-16 11:46:38",
"terra_status": "parsed",
"parsed_at": "2026-05-16 11:52:28"
}
]
}terra_status flow: pending → submitted → parsed (or failed:<reason>).
GET /v1/labs/:id, Get status + parsed biomarkers — Planned
Returns the full Lab with parsed biomarkers inline. Not yet shipped;
read biomarkers via /v1/data/sync for now.
Data
Sync, query, and aggregate views of wearable + lab data.
GET /v1/data/sync?since=…, Delta sync
Returns every row whose updated_at is newer than since (an ISO-8601
watermark; the legacy alias cursor also works). Used by offline-first
clients (the CLI's local SQLite, and the mobile app's local cache).
Mirror the response, then pass the response's now back as the next
request's since.
{
"user_id": "user_…",
"since": "1970-01-01T00:00:00Z",
"now": "2026-05-28T10:00:00Z",
"daily_summary": [...],
"activities": [...],
"sleep_sessions": [...],
"biomarkers": [...],
"counts": { "daily": 12, "activities": 3, "sleep": 12, "biomarkers": 1 }
}This response is not cursor-paginated — there is no next_cursor /
has_more; it returns the full delta since since in one shot.
/v1/syncis the legacy alias and still works.
POST /v1/import, Historical backfill
This route is shipped but is a plain (non-typed) route, so it does not appear in
/openapi.json, and its errors use a flatter shape than the standard envelope (see below).
Triggers Terra to replay history across activity, sleep, daily,
and body for every connected wearable on the calling user. The
chunks land asynchronously via the same webhook → queue → normalize
path as live data — counts may take 30-120s to appear in
/v1/data/sync.
POST /v1/import
Authorization: Bearer <clerk-jwt-or-amy-cli-jwt>
Content-Type: application/json
{ "days": 90 }| Field | Type | Default | Notes |
|---|---|---|---|
days | int | 30 | 1–1460 (4 years). Most providers retain 2-3 years; values above that silently return whatever the provider has. |
Response:
{
"ok": true,
"days": 90,
"start": "2026-02-25",
"end": "2026-05-25",
"connections": [
{
"provider": "whoop",
"terra_user_id": "tu_…",
"per_type": {
"activity": "queued",
"sleep": "queued",
"daily": "queued",
"body": "queued"
}
}
],
"note": "Chunks land via webhook. Counts may take 30–120s to appear locally."
}If the user hasn't connected any wearable yet (or Terra disagrees with
Amy's view), it returns 400 with the route's flat error shape (not
the standard envelope):
{ "error": "no_active_connections",
"hint": "No wearable is connected for this account on Terra." }Idempotent — re-running just overwrites the same rows, safe to call repeatedly while debugging.
GET /v1/data/biomarkers, Timeseries — Planned
A scoped time-series read by biomarker name. For now, pull via
/v1/data/sync and filter client-side.
GET /v1/data/summaries/:date, Daily summary — Planned
Single-day rollup of sleep/recovery/HRV/RHR/strain. For now, derive
from /v1/data/sync payloads.
Memory — Planned
The facts Amy remembers about the user are written internally at the
end of every turn (used to seed context.include_memory), but a public
CRUD surface (GET /v1/memory, POST /v1/memory, DELETE /v1/memory/:id)
isn't shipped yet. Track this section for updates.
Me
The current user, plus inline list of connected wearables (so a fresh client can render an "is the user set up?" screen in one round-trip).
GET /v1/me
{
"user_id": "user_3DecVwTirmDSeJyNZgbOOhvozQJ",
"email": "you@example.com",
"connections": [
{
"id": "699067f7340eeb9779e96788d3563151",
"provider": "whoop",
"terra_user_id": "6a5b4fc8-439f-4025-9ffe-3450656e114e",
"connected_at": "2026-05-16 11:44:09",
"deactivated_at": null
}
],
"env": "production"
}/v1/me reconciles against Terra on every call, so a fresh connection
shows up immediately even if the auth-success webhook was dropped.
PATCH /v1/me — Planned
Update name / display preferences. Not shipped yet.
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 SHA-256 of the raw request body —
the dedup key is sha256(rawBody), mapped to the raw_events
unique (event_type, terra_user_id, dedup_key) constraint. Duplicate
deliveries (including replays from Terra retries) become no-ops at the
DB layer.
The webhook handler is mounted at five aliased paths so Terra
configurations from earlier dashboards still work: POST /, POST /terra,
POST /webhook, POST /webhook/terra, and the canonical
POST /webhooks/terra. New configurations should point at the
canonical path.
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 | Lab OCR finished |
Full payload reference: Terra docs.
Auth: CLI browser flow
Mobile + web don't use this. They send the Clerk session token directly as
Authorization: Bearer <clerk-jwt>— see Authentication. The flow below exists because the CLI can't run Clerk in-process and needs a long-lived terminal token.
GET /cli/login?cb=<localhost-callback>&state=<csrf>
Anonymous, returns HTML. Only http://localhost:* and
http://127.0.0.1:* are accepted as cb. The page mounts Clerk; once
the user is signed in, it calls /v1/auth/cli-approve and redirects to
cb?token=<amyJwt>&state=<csrf>.
POST /v1/auth/cli-approve
Clerk-protected. Verifies the Clerk session and mints a 30-day Amy-signed JWT. The browser, not the CLI, calls this — the CLI only listens on its callback.
{ "amy_token": "eyJhbGciOiJIUzI1NiIs...", "expires_in_days": 30, "user_id": "user_…" }The CLI stores the token at ~/.amy/credentials.json. A pure
device-code flow (POST /v1/auth/cli/start, polling
/v1/auth/cli/approve) is Planned — useful when the CLI runs in
a headless environment with no browser.
Meta
GET /healthz
Liveness. Always 200 OK. Used by monitoring.
GET /reference
The interactive API explorer (Scalar) rendered from the live spec, with bearer auth + Try-It-Now baked in.
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 — Planned
Intended to serve every doc concatenated for one-shot context loading by
AI agents. Currently a placeholder that points back at /llms.txt;
fetch the .md form of individual pages for now.
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 === "synthesis_delta") process.stdout.write(event.data.text);
if (event.type === "turn.completed") console.log("\n", event.data.result.answer);
}
// Connect a wearable
const { widget_url } = await amy.sources.terra.connect({
redirect_url: "https://your-app/callback",
});
// Upload a lab (filename is required when `file` is a raw Blob)
const lab = await amy.labs.upload({ file: fileBlob, filename: "panel.pdf" });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.
Auth
How Amy verifies who's making a request — the three token kinds (Clerk session JWT, 30-day Amy CLI JWT, AMY_ADMIN_KEY), when to use each, how the SDK passes them, and what happens on expiry.