Recipe, Ask Amy a question
Goal: the smallest possible turn against a live Amy backend. One question in, one answer out. Three variants depending on whether you want to block, stream, or fire-and-forget.
If you have a base URL and an API key, you can run the blocking variant in 30 seconds. Streaming and fire-and-poll take about the same, they just give you a different experience while Amy thinks.
| Variant | When you'd use it | Wall time |
|---|---|---|
Blocking (stream: false) | Scripts, cron jobs, anywhere a single response is easier than a stream | 30s-7min, you wait |
| Streaming (SSE iterator) | Chat UIs, live CLI traces, anywhere you want "watch Amy think" | First event in <1s, answer streams over 30s-7min |
| Fire-and-poll | Background workers, mobile apps that get backgrounded, anything where you can't hold a connection open | Start: <1s. Result: poll until status: "completed" |
You need two things before you start:
| Thing | Where to get it |
|---|---|
| Base URL | Your own deployment (https://amy-api.YOUR.workers.dev) or the hosted backend (https://api.amy.health) |
| API key | amy whoami --print-key from the CLI, or the settings screen of your mobile app |
Set them once at the top of your shell:
export AMY_BASE_URL="https://api.amy.health"
export AMY_API_KEY="amy_live_…"Variant 1, Blocking (stream: false)
The whole turn collapses into a single request/response. The server holds the connection open until Amy is done. Simplest possible flow; the only catch is that turns can take up to 7 minutes and your HTTP client needs to be configured to wait that long.
curl
curl -X POST "$AMY_BASE_URL/v1/turns" \
-H "Authorization: Bearer $AMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messages": [{ "role": "user", "content": "What is my average HRV this month?" }],
"stream": false
}' | jq .result.answerYou get the full Turn
object back at the end, with result.answer, result.fact_sheet,
result.agents_used, result.cost_usd, and result.duration_ms.
TypeScript
import { Amy } from "@amy/sdk";
const amy = new Amy({
apiKey: process.env.AMY_API_KEY!,
baseUrl: process.env.AMY_BASE_URL!,
timeout: 7 * 60_000, // 7 min — long enough for the slowest turn
});
const turn = await amy.turns.create({
messages: [{ role: "user", content: "What is my average HRV this month?" }],
stream: false,
});
console.log(turn.result.answer);Variant 2, Streaming (SSE iterator)
This is the experience the CLI gives you: a live trace of every step, with the final answer streaming in token by token.
curl
Streaming with curl is a two-step dance: kick off the turn, then subscribe to its events.
# Step 1: start the turn. Server returns 202 with the turn id.
TURN_ID=$(curl -s -X POST "$AMY_BASE_URL/v1/turns" \
-H "Authorization: Bearer $AMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"How is my recovery trending?"}]}' \
| jq -r .id)
# Step 2: subscribe to the SSE stream. -N disables curl's output buffering.
curl -N -H "Authorization: Bearer $AMY_API_KEY" \
"$AMY_BASE_URL/v1/turns/$TURN_ID/events"You'll see frames like:
id: 1
event: turn.started
data: {"turn_id":"turn_01HX...","at":"2026-05-25T10:00:00Z"}
id: 2
event: agent.started
data: {"agent":"data_science","at":"2026-05-25T10:00:01Z"}
…TypeScript
The SDK hides the SSE parsing behind an async iterator.
import { Amy } from "@amy/sdk";
const amy = new Amy({
apiKey: process.env.AMY_API_KEY!,
baseUrl: process.env.AMY_BASE_URL!,
});
const turn = await amy.turns.create({
messages: [{ role: "user", content: "How is my recovery trending?" }],
// stream: true is the default — you don't need to pass it
});
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\n", event.result.answer);
console.log("Agents used:", event.result.agents_used);
}
}For a richer UI that renders every event type (spinners, agent names, gates), see Recipe: Stream events.
Variant 3, Fire-and-poll
Start the turn, drop the connection, come back later. Useful when you can't hold a stream open, background workers, mobile apps that get backgrounded, scheduled jobs that resume from a checkpoint.
curl
# Step 1: start the turn (same as the streaming variant).
TURN_ID=$(curl -s -X POST "$AMY_BASE_URL/v1/turns" \
-H "Authorization: Bearer $AMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"How is my recovery trending?"}]}' \
| jq -r .id)
# Step 2: poll until done. Every 5s is plenty.
while true; do
STATUS=$(curl -s -H "Authorization: Bearer $AMY_API_KEY" \
"$AMY_BASE_URL/v1/turns/$TURN_ID" | jq -r .status)
echo "status: $STATUS"
[ "$STATUS" = "completed" ] && break
[ "$STATUS" = "failed" ] && break
sleep 5
done
# Step 3: grab the final result.
curl -s -H "Authorization: Bearer $AMY_API_KEY" \
"$AMY_BASE_URL/v1/turns/$TURN_ID" | jq .result.answerTypeScript
import { Amy } from "@amy/sdk";
const amy = new Amy({
apiKey: process.env.AMY_API_KEY!,
baseUrl: process.env.AMY_BASE_URL!,
});
// Kick it off and exit. Save turn.id somewhere durable.
const turn = await amy.turns.create({
messages: [{ role: "user", content: "How is my recovery trending?" }],
});
await saveJobId(turn.id);
// …later, in a different process:
const result = await pollUntilDone(amy, await loadJobId());
console.log(result.answer);
async function pollUntilDone(amy: Amy, id: string) {
while (true) {
const t = await amy.turns.retrieve(id);
if (t.status === "completed") return t.result;
if (t.status === "failed") throw new Error(t.error.message);
await new Promise((r) => setTimeout(r, 5_000));
}
}Inspecting the result
Whichever variant you pick, result has the same shape. Two fields are
usually the most interesting:
result.fact_sheet
The structured list of claims that survived validation. Synthesis is only allowed to cite numbers from this list, so the fact sheet is the provenance trail for every digit in the answer.
const turn = await amy.turns.create({
messages: [{ role: "user", content: "What's my average resting heart rate?" }],
stream: false,
});
for (const claim of turn.result.fact_sheet) {
console.log(`${claim.claim}: ${claim.value} ${claim.unit ?? ""} (n=${claim.n}, source=${claim.source})`);
}
// average_rhr_60.39_bpm: 60.39 bpm (n=160, source=data_science)
// median_rhr_59_bpm: 59 bpm (n=160, source=data_science)
// …result.agents_used
Which specialists Amy ran. Useful for understanding why a turn was fast or slow.
console.log(turn.result.agents_used);
// ["data_science", "domain_expert"]["data_science"] alone is the fastest path (just the numbers).
Adding domain_expert adds a PubMed lookup leg. Adding health_coach
adds a coaching synthesis. Adding investigator means your question
was too vague to route directly, Amy proposed hypotheses first.
Common mistakes
Forgetting the Authorization header
You get 401 missing_authorization. The header is required on every
request except the unauthenticated CLI device-flow start and
/healthz.
# Wrong: no auth header
curl -X POST "$AMY_BASE_URL/v1/turns" -d '…'
# → 401 {"error":{"code":"missing_authorization", …}}
# Right
curl -X POST "$AMY_BASE_URL/v1/turns" \
-H "Authorization: Bearer $AMY_API_KEY" \
-d '…'Hitting the concurrency limit (3 turns in flight)
Each user can have at most 3 turns running at once. The 4th
POST /v1/turns returns 429 rate_limit_exceeded. The fix: either
wait for one to finish (poll GET /v1/turns/:id), or design your
client to throttle itself.
try {
await amy.turns.create({ messages });
} catch (err) {
if (err instanceof errors.RateLimitError) {
console.log(`Slow down. Retry after ${err.retryAfter}s`);
}
}Assuming the turn completes in <30 seconds
A turn is 2-7 minutes of multi-agent work. The default fetch timeout in browsers and many HTTP clients is 30 seconds. For blocking turns, bump it up:
| Client | Where to set it |
|---|---|
fetch (browser/Node) | Pass an AbortSignal with a long timeout, or use the SDK which sets timeout |
| curl | Add --max-time 600 |
| TypeScript SDK | new Amy({ apiKey, timeout: 7 * 60_000 }) |
If you can't bump the timeout (e.g. serverless functions with a 60s cap), use the fire-and-poll variant instead.
Treating stream: false as the default
stream: true is the default. If you call POST /v1/turns with no
stream field and treat the response as the final answer, you'll just
see { id, status: "queued", stream_url } and think Amy returned
nothing. Either set stream: false explicitly, or subscribe to the
stream.
Sending an empty messages array
400 invalid_request, the last message in messages must be from
the user role. You can include prior assistant turns for context,
but the last one needs to be the user's question.
Where to next
- Want a richer streaming UI? Recipe: Stream events, every event type, every reconnect path.
- Want a full mobile experience around this? Recipe: Build a mobile app.
- Looking up the full response shape? API reference: Turns.
- Curious what's actually happening inside a turn? Architecture: Layer 3, Compute model.
Using the CLI
The amy CLI is the reference client for the Amy backend. Every command maps 1:1 to an SDK call. Every step waits for you to press enter before doing anything. Nothing happens by surprise.
Recipe, Stream events from a turn
Goal: render a live UI from a turn's SSE event stream, spinners, agent names, validator verdicts, and the answer streaming in token by token. The "watch Amy think" feel from the CLI, in any client.