Amy
Concepts

Errors

Every error Amy returns follows the same shape, has a stable code, and links here. This page is the catalog: every code, its HTTP status, when it fires, and how to recover.

The error contract is part of the API. Codes don't change once they ship, new codes are additive. If you key off error.code in your client (which you should), the API will not break you.

Quick navigation


The response shape

Every error, at every endpoint, with no exceptions, has the same JSON body:

{
  "error": {
    "code": "turn_not_found",
    "message": "No turn exists with id turn_abc123.",
    "request_id": "req_01HX2K3M4N5P6Q7R8S9T0V1W2X",
    "docs_url": "https://docs.heyamy.xyz/docs/concepts/errors#turn_not_found"
  }
}
FieldTypeNotes
error.codestringStable identifier. Listed below. Key off this in code, never on message.
error.messagestringHuman-readable. Subject to copy-edits. Don't parse.
error.request_idstringThe per-request ID, also returned as X-Request-Id. Include when reporting bugs.
error.docs_urlstringDeep link to the relevant heading on this page (https://docs.heyamy.xyz/docs/concepts/errors#<code>).
error.detailsobject?Optional structured detail; shape varies per code.

For SSE turn failures, the error arrives wrapped in a turn.failed event, but in a slimmer shape{ code, message, step? } — without request_id / docs_url (Streaming: turn.failed).

The TypeScript SDK

Every non-2xx response is surfaced as a single AmyApiError; switch on its .code:

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

try {
  await amy.turns.retrieve("turn_does_not_exist");
} catch (err) {
  if (err instanceof AmyApiError && err.code === "turn_not_found") {
    // err.code      === "turn_not_found"
    // err.status    === 404
    // err.requestId === "req_…"
    // err.docsUrl   === "https://docs.heyamy.xyz/docs/concepts/errors#turn_not_found"
  }
}

AmyApiError also carries transport-failure codes (network_error, timeout, aborted, malformed_response, stream_closed_unexpectedly) and an .isRetryable() helper. See SDK: TypeScript, Errors.


Retry guidance

CategoryRetry?Strategy
Auth (401, 403)NoFix the token. Retrying with the same bad token will hit rate limits.
Validation (400, 422)NoFix the request. The same body will fail the same way.
Not found (404)NoThe resource doesn't exist. Retry only after creating it.
Conflict (409)Conditionalalready_exists, no retry needed. idempotency_key_mismatch, fix the key or wait 24h for the cached response to expire.
Rate limit (429)Yes, with backoffHonour Retry-After header. Exponential backoff with jitter, max 5 attempts.
Internal (500)Yes, idempotent onlyLinear backoff (1s, 2s, 4s). Always retry with the same Idempotency-Key to avoid duplicate side effects. Report with request_id if it persists.
Upstream (502/503/504)YesExponential backoff with jitter, max 5 attempts. Most transient (Anthropic 5xx, Terra timeout).

The SDK does this automatically for 429 and 5xx on idempotent calls. For non-idempotent calls (POSTs without an explicit Idempotency-Key), the SDK auto-generates a UUID4 key so the retry is safe.


Status code summary

HTTPMeaningCodes
400Client-side validation failure.invalid_request, invalid_field
401Authentication missing or invalid.missing_authorization, invalid_token, webhook_signature_invalid
403Authenticated but not allowed.forbidden
404Resource doesn't exist (or unknown route).not_found, turn_not_found, lab_not_found, source_not_found, memory_not_found
409Conflict.already_exists, idempotency_key_mismatch
422Semantic validation failure (body is well-formed but business rules reject it).unprocessable
429Rate limited.rate_limit_exceeded, concurrency_limit_exceeded
500Server bug.internal_error
502 / 503 / 504Upstream provider failure.upstream_unavailable

Codes by category

Auth

missing_authorization

HTTP: 401 Unauthorized

When it fires: The request didn't include an Authorization header. Every endpoint except /healthz, /openapi.json, /llms.txt, /llms-full.txt, /reference, the Terra webhook paths, and the public CLI sign-in page /cli/login requires one.

Recovery:

  • Add Authorization: Bearer <token> to the request, where <token> is either a Clerk session JWT (web/mobile) or the Amy CLI JWT (saved in ~/.amy/credentials.json after amy login).
  • For the CLI browser-login flow's public endpoints, omit the header (they're anonymous).
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{ "error": { "code": "missing_authorization",
             "message": "Missing Authorization: Bearer <token> header.",
             "request_id": "req_…",
             "docs_url": "https://docs.heyamy.xyz/docs/concepts/errors#missing_authorization" } }

invalid_token

HTTP: 401 Unauthorized

When it fires: The Authorization header is present but the token is unrecognised, expired, or revoked.

Recovery:

  • Re-issue an API key. From the CLI: amy whoami --print-key. From the mobile app: Settings → API keys.
  • If you just rotated the secret on the user, the previous key was invalidated immediately, fetch the new one.

Note: don't retry. The same bad token will keep failing and may trigger rate-limit penalties on the 401 path.


forbidden

HTTP: 403 Forbidden

When it fires: Authentication succeeded but the token doesn't have scope for the requested operation. The two cases in v1:

  1. A user API key called an /admin/* endpoint.
  2. A token tried to read or mutate another user's resource.

Recovery:

  • For admin endpoints, set the x-admin-key: <AMY_ADMIN_KEY> header instead of a bearer token.
  • For cross-user access: there's no such thing in v1. Each token is scoped to its owner.

Validation

invalid_request

HTTP: 400 Bad Request

When it fires: The request body is malformed JSON, has the wrong content type, or violates a top-level schema constraint that isn't attributable to a single field.

Examples:

  • messages is empty in POST /v1/turns.
  • The last message in messages isn't from role: "user".
  • A multipart upload to POST /v1/labs/upload has no file part.

Recovery: Fix the request body. The message will name the violated constraint.

{ "error": { "code": "invalid_request",
             "message": "messages must contain at least one entry; last must be role=user.",
             ... } }

invalid_field

HTTP: 400 Bad Request

When it fires: A specific field failed schema validation. The message names the field and the constraint.

The error body includes a field extension:

{
  "error": {
    "code": "invalid_field",
    "message": "kind must be one of: goal, barrier, preference, insight, hypothesis, decision, value, tested_hypothesis.",
    "request_id": "req_…",
    "docs_url": "https://docs.heyamy.xyz/docs/concepts/errors#invalid_field",
    "details": { "field": "kind" }
  }
}

Recovery: Fix the named field. The full schema for every endpoint is in API reference and at /openapi.json.


unprocessable

HTTP: 422 Unprocessable Entity

When it fires: The request body is well-formed and passes schema validation, but a business rule rejects it. Examples:

  • Uploading a lab PDF >10MB.
  • Connecting a Terra provider the user is already connected to.
  • Trying to write a memory entry with text.length > 500.

Recovery: Read the message. It names the rule that fired. These are intentionally not field-level errors, they reflect cross-field or stateful constraints.


Not found

not_found

HTTP: 404 Not Found

When it fires: The request didn't match any route — a typo'd path, a wrong method on a real path, or a /v1/... resource that doesn't exist. This is the generic fallback; resource-specific 404s use the typed codes below.

Recovery:

  • Check the method + path against the API reference or /openapi.json. (Unlike the other codes here, this one's docs_url points at the API reference rather than this page.)
{ "error": { "code": "not_found",
             "message": "No route matches GET /v1/nonexistent.",
             "request_id": "req_…",
             "docs_url": "https://docs.heyamy.xyz/docs/api-reference" } }

turn_not_found

HTTP: 404 Not Found

When it fires: No turn exists with the given ID, or the turn exists but belongs to a different user.

Recovery:

  • Double-check the ID. Turn IDs are typed (turn_…), if yours doesn't start with that prefix, you've passed the wrong value.
  • Confirm the API key belongs to the user who created the turn. Two users cannot see each other's turns.
  • The Turn row is permanent (never expires), if you created it and it's gone, file a bug with the request_id.

A related but distinct case: the turn exists but its SSE event buffer has expired (1 hour past completed_at). GET /v1/turns/:id/events still returns 200, but there are no buffered events left to replay — the stream simply opens and closes. Read the terminal state from GET /v1/turns/:id (the Turn row is permanent). See Streaming: Reconnects and replay.


lab_not_found

HTTP: 404 Not Found

When it fires: No lab exists with the given ID, or the lab belongs to a different user.

Recovery:

  • Verify the ID starts with lab_.
  • Verify the API key owns the lab.
  • Note that uploads are eventually consistent: immediately after POST /v1/labs/upload returns 200, the lab row exists and is queryable — there's no race window where it would return 404 for an ID you just received.

source_not_found

HTTP: 404 Not Found

When it fires: No source connection exists with the given ID, or it belongs to a different user.

Recovery:

  • Source IDs are src_…. DELETE /v1/sources/:id requires the src_… id (from GET /v1/sources); a bare provider slug is not accepted. If the id doesn't match an active connection you own, you get this error.

memory_not_found

HTTP: 404 Not Found

When it fires: No memory entry exists with the given ID. Most often hit on DELETE /v1/memory/:id after a prior delete (deletes are immediate and hard, no tombstone).

Recovery:

  • Memory IDs are mem_….
  • Don't retry a delete after it succeeds; the second call returns 404. The SDK treats 404 on DELETE as success by default ({ throwIfNotFound: false }).

Conflict

already_exists

HTTP: 409 Conflict

When it fires: The operation would create a duplicate of an existing resource that's required to be unique. The main case in v1:

  1. POST /v1/sources/terra/connect for a provider the user is already connected to.

(The CLI sign-in flow does not raise this — POST /v1/auth/cli-approve mints a fresh 30-day token on every call; there's no single-use device code to collide with.)

Recovery:

  • For connect: delete the existing connection first, then re-connect. Or just leave the existing connection in place.

This is not the same as idempotency_key_mismatch. That fires when the same Idempotency-Key is reused with a different body; already_exists fires when business uniqueness is violated regardless of idempotency keys.


idempotency_key_mismatch

HTTP: 409 Conflict

When it fires: The same Idempotency-Key was sent twice within 24 hours with different request bodies. Amy refuses to silently overwrite the cached response.

Recovery:

  • Use a new Idempotency-Key. The SDK generates one per call by default, if you're seeing this with the SDK, you've overridden the key explicitly.
  • If you genuinely want to overwrite, wait 24h for the cached response to expire, then retry with the same key.
  • If you want both calls to succeed, use distinct keys (one per intended write).

Diagnostic: the error message includes the byte hash of the cached body and the new body, so you can confirm which fields drifted.

{ "error": { "code": "idempotency_key_mismatch",
             "message": "Key 6e8b…1c was used 12m ago with a different body.",
             "request_id": "req_…",
             "docs_url": "..." } }

Rate limiting

rate_limit_exceeded

HTTP: 429 Too Many Requests

When it fires: The user's per-minute or per-day request quota has been exceeded. v1 quotas (subject to change):

WindowLimit
Per minute60 requests
Per day5,000 requests

Quotas are per user, not per token. Multiple tokens for the same user share one bucket.

Response includes:

HTTP/1.1 429 Too Many Requests
Retry-After: 23

Recovery:

  • Sleep for Retry-After seconds, then retry.
  • The SDK does this automatically with up to 5 attempts.
  • For batch workloads, add a token-bucket limiter client-side that caps at ~50 req/min to leave headroom.

concurrency_limit_exceeded

HTTP: 429 Too Many Requests

When it fires: The user already has 20 turns in flight (queued or running). The 21st POST /v1/turns is rejected.

The cap exists because turns are expensive (multi-agent, multi-minute, LLM-charged). The queue absorbs realistic bursts at max_concurrency: 1, so this cap is a gentle guard against a stuck client flooding the queue with hundreds of stale requests — not a billing rate-limit.

Response includes:

HTTP/1.1 429 Too Many Requests
X-Concurrency-Limit: 20
X-Concurrency-In-Flight: 20

Recovery:

  • Wait for one of the in-flight turns to reach a terminal state (completed or failed).
  • Stream their events; the user usually cares about the current turn anyway, not a queued backlog.
  • If you legitimately need higher concurrency, contact the team (per-user caps can be raised for known users).

Unlike rate_limit_exceeded, there's no Retry-After, the wait depends on the turns finishing, not a fixed window.


Server

internal_error

HTTP: 500 Internal Server Error

When it fires: Unhandled exception in the Worker, contract violation between layers, schema-validation failure on a response shape, etc. These are bugs.

Recovery:

  • Retry once, with the same Idempotency-Key so the retry is safe.
  • If it persists, capture the request_id and file a bug. The request ID maps to a single line in the backend logs, we can find exactly what blew up.
{ "error": { "code": "internal_error",
             "message": "An unexpected error occurred. Please include the request_id when reporting.",
             "request_id": "req_01HX...",
             "docs_url": "..." } }

Note: the message is intentionally non-revealing. We don't leak stack traces or internal state in error bodies; the detail lives in our logs, indexed by request_id.


upstream_unavailable

HTTP: 502 Bad Gateway / 503 Service Unavailable / 504 Gateway Timeout

When it fires: A third-party dependency returned an error or timed out. The dependencies:

  • Anthropic (or OpenRouter, depending on backend config), for every LLM call.
  • Terra, for OAuth widget URLs, sync operations, lab OCR triggering.
  • Clerk, for token verification (rare; Clerk is reliable).

The error body includes upstream:

{ "error": { "code": "upstream_unavailable",
             "message": "Anthropic API timed out after 3 retries.",
             "upstream": "anthropic",
             "request_id": "req_…",
             "docs_url": "..." } }

Recovery:

  • Retry with exponential backoff. Most upstream failures are transient (5-60 seconds).
  • The SDK retries automatically on 5xx, up to 5 attempts.
  • If upstream is anthropic and the failure persists, check status.anthropic.com.
  • For turn failures (turn.failed event), the turn is already marked terminal, retrying means starting a new turn, not resuming the old one. There's no resume-from-step-N for failed workflows in v1.

Webhook

webhook_signature_invalid

HTTP: 401 Unauthorized

When it fires: Inbound webhook (only Terra, in v1) failed HMAC verification. This is logged but not surfaced to clients in normal operation, clients don't call webhook endpoints. It appears here for completeness and for use during webhook replay testing.

The response body uses the slim webhook error shape (see Webhooks: Response shapes):

{
  "error": "invalid_signature",
  "reason": "stale",
  "request_id": "req_…"
}

reason is one of:

reasonMeaning
missing_headerNo terra-signature header.
malformed_headerHeader doesn't parse as t=…,v1=….
bad_timestampt isn't a valid integer.
stale|now - t| > 300s.
signature_mismatchComputed HMAC doesn't equal the v1 field.

Recovery: See Webhooks: HMAC-SHA256 signing for the full diagnosis matrix. Common causes: wrong TERRA_WEBHOOK_SECRET, clock skew, replay outside the 5-minute window, or curl -d reformatting the body (use --data-binary).


Where to next

On this page