Amy
SDKs

TypeScript

The canonical client for the Amy API. Works in Bun, Node 20+, Deno, browsers, and React Native. Built on fetch streams so SSE works everywhere without an EventSource polyfill.

Install

Internal use. The Amy stack is a single Bun monorepo. The mobile, web, and future clients all live next to the backend they call. @amy/sdk is not on npm and isn't planned to be — it's a workspace package consumed from inside this repo.

@amy/sdk and @amy/contracts live under packages/ in amy_health_assistant. New clients (a mobile app, a web app, a script, anything that talks to amy.heyamy.xyz) get added under apps/ in the same monorepo and declare the SDK as a Bun workspace dependency.

# From the repo root
git clone https://github.com/yatendra2001/amy_health_assistant.git
cd amy_health_assistant
bun install

# Scaffold your new client under apps/ (example: an Expo app)
bun create expo@latest apps/mobile --template default

# Add to apps/mobile/package.json:
#   "dependencies": {
#     "@amy/sdk":       "workspace:*",
#     "@amy/contracts": "workspace:*",
#     "zod":            "^3"
#   }
# Add "apps/*" to the root package.json `workspaces` array.

bun install

The mobile recipe walks this end-to-end for apps/mobile/ (Expo + Clerk + the SDK). The same shape applies to any other client — drop it under apps/<name>/, reuse @amy/sdk as workspace:*, and backend type changes show up as TypeScript errors on save.

@amy/contracts and zod are peer deps; declare them explicitly so your app pins the same Zod version as the SDK.

Initialize

The SDK accepts two auth shapes — pick the one that matches your surface. (Clerk is the source of truth for auth across all of Amy; the API verifies Clerk JWTs natively.)

import { Amy } from "@amy/sdk";
import { useAuth } from "@clerk/expo"; // web: "@clerk/react"

const { getToken } = useAuth();

const amy = new Amy({
  apiKeyProvider: () => getToken(),
  baseUrl: "https://amy.heyamy.xyz",
});

Clerk Core 3 renamed the packages: @clerk/clerk-expo@clerk/expo, @clerk/clerk-react@clerk/react. Older code lives under the old names; run npx @clerk/upgrade to migrate.

The SDK calls apiKeyProvider once per request. Clerk's getToken() caches and refreshes the session token itself, so there's nothing to wire on your end. No JWT exchange step, no secure-store dance.

Terminal / scripts — static apiKey

const amy = new Amy({
  apiKey: process.env.AMY_API_KEY!, // 30-day Amy JWT from `amy whoami --print-key`
  baseUrl: process.env.AMY_BASE_URL ?? "https://amy.heyamy.xyz",
});

// Rotate later (e.g. after a Clerk refresh in long-running processes):
amy.setApiKey(newToken);

All optional knobs

new Amy({
  apiKey,                  // or apiKeyProvider — exactly one
  baseUrl,                 // default: https://amy.heyamy.xyz
  timeout: 60_000,         // ms per non-stream request
  maxRetries: 2,           // 429 / 5xx / network failures, exp. backoff
  fetch: globalThis.fetch, // override (Workers, Undici, polyfilled fetch)
  headers: { "x-myapp": "v1" },
  userAgent: "myapp/1.0",
});

Why apiKeyProvider instead of a wrapped fetch?

The provider runs before every request, including streaming. With a wrapped fetch you'd have to redo the SDK's auth header logic; with the provider the SDK still owns the Authorization header and you own the token.

eventSourceInit(id) — used when handing the SSE URL to a third-party library — is synchronous and so only works with a static apiKey. If you used apiKeyProvider, resolve a token first and call amy.setApiKey(token) before calling eventSourceInit.


Resources

amy.me

const me = await amy.me.get();
// → { user_id, email, connections: TerraConnection[], env }

Reconciles against Terra on every call so a freshly OAuth'd wearable shows up without waiting on a webhook.

amy.sources

const { data } = await amy.sources.list();
// → [{ id: "src_…", provider: "whoop", connected_at, last_sync_at, status }]

const { widget_url } = await amy.sources.terra.connect({
  provider: "WHOOP",                            // optional, defaults to "WHOOP"
  redirect_url: "https://your-app/callback",    // optional
});
// Open widget_url in a browser; Terra runs the OAuth dance and
// emits webhooks back to the Worker.

await amy.sources.disconnect("src_…");          // 204, no return

amy.labs

// Upload a PDF, PNG, or JPEG (≤10MB).
const r = await amy.labs.upload({ file: blobOrFile, filename: "panel.pdf" });
// → { ok, upload_id, storage_key, terra_status, note }

const { uploads } = await amy.labs.list();
// → [{ id, uploaded_at, terra_status, parsed_at }]

Terra parses asynchronously (~30s). The resulting biomarkers land in the next amy.data.sync() payload.

amy.data

// Delta sync — pass the previous response's `now` as the next `since`
// for a clean incremental loop.
const a = await amy.data.sync();
const b = await amy.data.sync({ since: a.now });
// → { user_id, since, now, daily_summary, activities,
//     sleep_sessions, biomarkers, counts }

amy.turns

const turn = await amy.turns.create({
  messages: [{ role: "user", content: "How's my recovery this week?" }],
});
// → { id: "turn_…", status: "queued", created_at, stream_url }

// Poll for the final result.
const t = await amy.turns.retrieve(turn.id);
if (t.status === "completed") console.log(t.result?.answer);

// Or list, with cursor pagination.
const page = await amy.turns.list({ limit: 20, status: "completed" });
// → { data: TurnSummary[], next_cursor, has_more }

Streaming

amy.turns.stream(id) is an async iterator over the lifecycle events. Heartbeats are dropped silently; the generator returns cleanly when the server emits turn.completed or turn.failed.

for await (const ev of amy.turns.stream(turn.id)) {
  if (ev.type === "phase") {
    // Real-time progress: "Data Science Agent | executing python (17 lines)"
    console.log(`${ev.data.agent} → ${ev.data.phase}`);
  }
  if (ev.type === "synthesis_delta") {
    // Token-level streaming of the final answer text.
    process.stdout.write(ev.data.text);
  }
  if (ev.type === "turn.completed") {
    console.log("\n→", ev.data.result.answer);
  }
}

Resume after disconnect with lastEventId:

for await (const ev of amy.turns.stream(turn.id, { lastEventId: 42 })) {
  // server replays events with id > 42 (1-hour buffer after completion)
}

Cancel with AbortSignal:

const ac = new AbortController();
setTimeout(() => ac.abort(), 10_000);
for await (const ev of amy.turns.stream(turn.id, { signal: ac.signal })) {
  // throws AmyApiError({ code: "aborted" }) when ac.abort() fires
}

Wire into your own SSE client (third-party libraries, react-native-sse, etc.):

const { url, headers } = amy.turns.eventSourceInit(turn.id);
// → { url: "https://amy.heyamy.xyz/v1/turns/.../events",
//     headers: { Authorization: "Bearer ...", Accept: "text/event-stream" } }

Event type catalog

ev.typeev.data shape (key fields)
turn.started{ turn_id, at, seq }
phase{ agent, phase, detail? } — cheap, most-used progress marker
routing{ decision } — main + supporting agents picked
rephrase{ main, supporting } — per-agent sub-questions
agent_start{ agent, question }
agent_end{ agent, cost_usd, duration_ms, preview? }
cost{ agent, usd, reason } — granular spend events
validation_start{ finding_id, claim }
validation_end{ finding_id, verdict, gates_passed, gates_total, duration_ms, reason }
synthesis_delta{ text } — token chunks of the final answer
fact_check{ issues: [{ value, expected_keys, severity }] }
memory{ entries: [...] }
turn.completed{ turn_id, result }result.answer is the user-facing text
turn.failed{ turn_id, error }

Not every event fires on every turn — the conversational fallback emits just turn.started + turn.completed, while a vague Investigator run fills the catalog. Forward-compat: unknown event types pass through untouched, so the SDK won't error on a server upgrade that adds new types.

The full event catalog with example payloads: Concepts: Streaming.


Errors

Every non-2xx response throws a typed AmyApiError. Transport failures (network, timeout, abort, malformed JSON) throw the same class with a synthetic code.

import { Amy, AmyApiError } from "@amy/sdk";

try {
  await amy.turns.retrieve("turn_does_not_exist");
} catch (e) {
  if (e instanceof AmyApiError) {
    e.code         // "turn_not_found" | "invalid_token" | ... | "network_error" | "timeout"
    e.status       // 404 (or 0 for transport failures)
    e.message      // human-readable
    e.requestId    // "req_…" — paste into a bug report
    e.docsUrl      // "https://docs.heyamy.xyz/docs/concepts/errors#turn_not_found"
    e.details      // server-supplied structured detail, if any
    e.isRetryable()// true for 429 / 5xx / network / timeout
  }
}

Full error catalog: Concepts: Errors.


Idempotency

Every POST/PATCH/DELETE auto-generates a UUIDv4 as the Idempotency-Key header. Override it for cross-process retry safety:

const turn = await amy.turns.create(
  { messages: [...] },
  { idempotencyKey: "user-action-abc-123" },
);

POSTing the same key twice within 24 hours returns the cached response. Differing bodies under the same key get 409 idempotency_key_mismatch.


React Native

The SDK uses fetch only — no EventSource dependency — so streaming works in React Native out of the box, including the Authorization: Bearer … header (which browser EventSource can't set). Just install and use:

const amy = new Amy({ apiKey, baseUrl });

for await (const ev of amy.turns.stream(turn.id)) { /* … */ }

For file uploads from expo-document-picker:

const res = await DocumentPicker.getDocumentAsync({ type: "application/pdf" });
const file = await fetch(res.assets[0].uri).then((r) => r.blob());
await amy.labs.upload({ file, filename: res.assets[0].name });

If you'd rather drive SSE with a battle-tested library (react-native-sse), use amy.turns.eventSourceInit(id) for the URL

  • headers and pass them to that library directly.

Browser

fetch + streams work natively in every evergreen browser. CORS is open on the Worker for any origin; pass the token via Authorization, not a cookie. For long streams, consider running the call from a Web Worker so the main thread stays responsive.


Currently shipped vs. planned

The SDK exposes only methods the live Worker actually answers. The following are planned and not in the SDK yet — they'll appear when the backend ships them:

  • amy.labs.retrieve(id) — full lab with parsed biomarkers
  • amy.data.biomarkers({ name, from, to }) — timeseries
  • amy.data.summaries.retrieve(date) — single-day rollup
  • amy.memory.* — list / add / delete remembered facts
  • amy.me.update({ name }) — profile edits

The API reference marks each one Planned in the same column. Until they ship, derive what you need from amy.data.sync().


Where to next

On this page