Amy
Recipes

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.

VariantWhen you'd use itWall time
Blocking (stream: false)Scripts, cron jobs, anywhere a single response is easier than a stream30s-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-pollBackground 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 URLYour own deployment (https://amy-api.YOUR.workers.dev) or the hosted backend (https://api.amy.health)
API keyamy 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.answer

You 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.answer

TypeScript

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:

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
TypeScript SDKnew 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

On this page