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/sdkis 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 installThe 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.)
Mobile / web — apiKeyProvider (recommended)
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; runnpx @clerk/upgradeto 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 staticapiKey. If you usedapiKeyProvider, resolve a token first and callamy.setApiKey(token)before callingeventSourceInit.
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 returnamy.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.type | ev.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 biomarkersamy.data.biomarkers({ name, from, to })— timeseriesamy.data.summaries.retrieve(date)— single-day rollupamy.memory.*— list / add / delete remembered factsamy.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
- API reference — every endpoint + shape
- Interactive explorer — try every endpoint with auth
- Concepts: Streaming — full event payloads
- Recipe: Build a mobile app
SDKs
One contract, one spec, many languages. The TypeScript SDK ships today. Python and Swift land when the first user asks. Until then, the OpenAPI spec at /openapi.json lets anyone generate a client in…
Runtime
How Amy's cloud actually executes on Cloudflare. One Worker (cloud/) ties together D1, R2, KV, Queues, and Cron. There are no Workflows yet, async work runs via a Queue consumer in the same Worker.…