Amy
Recipes

Ask Amy a question

Goal: the smallest possible turn against a live Amy backend. One question in, one answer out. POST always returns a queued turn; you either stream it or poll it to completion.

POST /v1/turns is always asynchronous: it returns 202 with a { id, status: "queued", stream_url } immediately and runs the turn in the background. There are two ways to get the answer — stream the turn's events, or poll it until status: "completed". Both take about the same wall time; they just give you a different experience while Amy thinks.

VariantWhen you'd use itWall time
Streaming (SSE iterator)Chat UIs, live CLI traces, anywhere you want "watch Amy think"First event in <1s, answer streams over 30s-7min
Poll to completionScripts, cron jobs, background workers, mobile apps that get backgrounded, anything where you can't hold a connection openStart: <1s. Result: poll until status: "completed"

You need two things before you start:

ThingWhere to get it
Base URLThe live backend: https://amy.heyamy.xyz
API keyamy whoami --print-key from the CLI (or read ~/.amy/credentials.json → amy_token)

Set them once at the top of your shell:

export AMY_BASE_URL="https://amy.heyamy.xyz"
export AMY_API_KEY="$(amy whoami --print-key)"

Variant 1, Poll to completion

POST returns a queued turn id, then you poll GET /v1/turns/:id until status flips to completed (or failed). Simplest possible flow when you don't want to hold a stream open — works in scripts, cron jobs, serverless functions, and backgrounded mobile apps.

curl

# Start the turn — returns 202 { id, status: "queued", stream_url }.
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":"What is my average HRV this month?"}]}' \
  | jq -r .id)

# 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

# Grab the final answer.
curl -s -H "Authorization: Bearer $AMY_API_KEY" \
  "$AMY_BASE_URL/v1/turns/$TURN_ID" | jq .result.answer

Once completed, the Turn object carries 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!,
});

const turn = await amy.turns.create({
  messages: [{ role: "user", content: "What is my average HRV this month?" }],
});

// turn.status is "queued" here — poll until it completes.
const result = await pollUntilDone(amy, turn.id);
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));
  }
}

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: {"type":"turn.started","seq":1,"at":"2026-05-25T10:00:00Z","turn_id":"turn_01HX..."}

id: 2
event: phase
data: {"type":"phase","agent":"orchestrator","phase":"classifying query vagueness"}

id: 7
event: agent_start
data: {"type":"agent_start","agent":"Data Science Agent","question":"Compute the user's average HRV…"}

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?" }],
});
// create() just enqueues (status: "queued"); stream the turn id for 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\n", event.data.result.answer);
    console.log("Agents used:", event.data.result.agents_used);
  }
}

For a richer UI that renders every event type (spinners, agent names, gates), see Recipe: Stream events.


Polling across processes

Because the turn runs server-side regardless of who's watching, you can start it in one process and collect the result in another. Persist turn.id somewhere durable and reuse the same pollUntilDone helper from Variant 1 — useful for queue workers, scheduled jobs that resume from a checkpoint, or a mobile app that gets backgrounded mid-turn.

// 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);

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.retrieve(turnId); // after status === "completed"

for (const claim of turn.result.fact_sheet) {
  console.log(`${claim.key}: ${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 login (GET /cli/login) 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 (20 turns in flight)

Each user can have at most 20 turns running at once. The 21st POST /v1/turns returns 429 concurrency_limit_exceeded. The fix: either wait for one to finish (poll GET /v1/turns/:id), or design your client to throttle itself. This cap is a gentle guard against a stuck client, not a billing limit — the queue absorbs realistic bursts.

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

try {
  await amy.turns.create({ messages });
} catch (err) {
  if (err instanceof AmyApiError && err.code === "concurrency_limit_exceeded") {
    console.log("Too many turns in flight. Wait for one to finish, then retry.");
  }
}

Assuming the turn completes in <30 seconds

A turn is 2-7 minutes of multi-agent work, but POST /v1/turns returns in <1s — it only enqueues. The wait happens while you poll GET /v1/turns/:id or stream events. Keep each poll request short; only the SSE stream connection needs to stay open a long time. For the stream, give your client a generous read timeout:

ClientWhere to set it
fetch (browser/Node)Pass an AbortSignal with a long timeout, or use the SDK which sets timeout
curlAdd --max-time 600 for the SSE subscribe step
TypeScript SDKnew Amy({ apiKey, timeout: 7 * 60_000 })

If you can't hold a stream open (e.g. serverless functions with a 60s cap), use the poll to completion variant instead.

Treating POST /v1/turns as synchronous

POST is always asynchronous — it returns 202 { id, status: "queued", stream_url } and runs the turn in the background. There is no blocking mode; the stream request field is reserved and ignored. If you treat the POST response as the final answer you'll see no result and think Amy returned nothing. Always either poll GET /v1/turns/:id until status: "completed", 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

On this page