Amy

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

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:

  1. Try as an Amy-signed JWT (CLI sessions; see below).
  2. Otherwise verify as a Clerk session JWT via Clerk's JWKS.

Either form is accepted — pick the one that fits your surface:

SurfaceToken kindHow you get it
Mobile / web (recommended)Clerk session JWT@clerk/expo (mobile) or @clerk/react (web) → useAuth().getToken(). Refreshes itself; no exchange step needed.
CLI30-day Amy JWTamy login (browser sign-in) → token saved to ~/.amy/credentials.json. amy whoami --print-key prints it.
Admin endpoints (/admin/*)AMY_ADMIN_KEY secretOut 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/nextjs package kept its name but bumped to v6. If you're upgrading older code, npx @clerk/upgrade handles 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.)

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)
429Too many requests (concurrency_limit_exceeded for in-flight turns; 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 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_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 <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
  }
}
FieldTypeDefaultNotes
messagesMessage[]requiredConversation so far. Last message must be from user.
streambooleantrueReserved. 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_memorybooleantrueWhether to inject memory into the agent context.
context.include_biomarkersbooleantrueWhether 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, messages is 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=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

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 size10 MB
Accepted typesapplication/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: pendingsubmittedparsed (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/sync is 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 }
FieldTypeDefaultNotes
daysint301–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": "[email protected]",
  "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/mePlanned

Update name / display preferences. Not shipped yet.


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 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:

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

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

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 === "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.

On this page