Auth
How Amy verifies who's making a request — the three token kinds (Clerk session JWT, 30-day Amy CLI JWT, AMY_ADMIN_KEY), when to use each, how the SDK passes them, and what happens on expiry.
Amy verifies every authenticated request as one of three things: a Clerk session JWT (mobile / web), a 30-day Amy CLI JWT (terminal sessions), or
AMY_ADMIN_KEY(admin-only endpoints). Pick the one that fits your surface — the SDK'sapiKeyProvidermakes the Clerk path zero-config, and the others fall back to a static bearer.
This page explains the model. For per-endpoint behavior, see API reference: Authentication. For the underlying token verification, see Internals: Runtime.
Quick navigation
- The three token kinds
- Which one your client should send
- How the SDK passes the token
- The provisioning side-effect
- Token TTLs and refresh
- What 401 / 403 mean
- Common mistakes
The three token kinds
| Kind | Who issues it | TTL | Format | Where it lives |
|---|---|---|---|---|
| Clerk session JWT | Clerk Frontend API after sign-in | ~60s, auto-refreshed by Clerk SDKs | RS256 JWT verified against Clerk's JWKS | In-memory on the device; written to expo-secure-store (mobile) or memory (web) by Clerk's tokenCache |
| Amy CLI JWT | POST /v1/auth/cli-approve after the CLI browser-login handshake | 30 days, no refresh — re-run amy login to mint a new one | HS256 JWT signed with AMY_JWT_SECRET | ~/.amy/credentials.json on the dev's machine |
AMY_ADMIN_KEY | Hand-rolled by the Amy team and put in 1Password | Forever, rotated manually | Opaque static string | Server-side only (Cloudflare Worker env). Never embedded in clients. |
For user-facing routes, the bearer-auth middleware tries the two JWT kinds in order on every request:
- If the bearer looks like an Amy JWT (HS256 header), verify it under
AMY_JWT_SECRET; on success, extract thesub(Clerk user ID) and proceed. - Otherwise verify it as a Clerk session JWT against Clerk's JWKS; on
success, extract the
suband proceed. - Otherwise return
401 invalid_token(or401 missing_authorizationif there was noAuthorizationheader at all).
The two user-bearing modes converge on the same Clerk user id, so the rest of the request handler doesn't care which one you sent.
AMY_ADMIN_KEY is not part of this chain. Admin routes (/admin/*)
sit behind a separate middleware that checks an x-admin-key header —
it's never an Authorization: Bearer token (see
Hitting /admin/* from a user client).
Which one your client should send
| Surface | Token kind | Why |
|---|---|---|
| Mobile (Expo / React Native) | Clerk JWT via @clerk/expo | Native sign-in UI, biometric token cache, auto-refresh — all free. |
| Web (Next.js / React / Vue) | Clerk JWT via @clerk/nextjs or @clerk/react | Same as above, plus middleware-based route protection. |
| CLI / TUI | Amy CLI JWT via amy login | Terminals can't run Clerk's React UI; the CLI does a one-time browser handshake and mints a long-lived bearer. |
| Server-to-server / cron | Amy CLI JWT (per service account) | Same shape as the CLI; treat the service account like a developer. |
| Internal Amy tools (DLQ, user wipe) | AMY_ADMIN_KEY (as the x-admin-key header) | Bypasses Clerk so the operator can act on any user without impersonation. |
You never need to "exchange" between kinds. If a mobile app needs to hand a credential to a server, mint a service-account Amy JWT for the server — don't pass the mobile user's Clerk session.
How the SDK passes the token
The SDK (@amy/sdk) accepts either form via
AmyClientOptions:
// Static bearer — CLI, server jobs, anything non-interactive.
const amy = new Amy({ apiKey: process.env.AMY_API_KEY! });// Provider — called once per request to fetch a fresh token.
// The intended pattern for every Clerk-backed client.
import { useAuth } from "@clerk/expo"; // or "@clerk/react"
const { getToken } = useAuth();
const amy = new Amy({ apiKeyProvider: () => getToken() });apiKeyProvider runs once per HTTP request, so Clerk's internal
caching + refresh is what keeps you under the rate limit. For SSE
streams (amy.turns.stream()), the provider runs once when the
stream opens — see Streaming for the
reconnect-with-fresh-token pattern.
A null/undefined return from apiKeyProvider throws synchronously
with "Amy: apiKeyProvider returned null/undefined. Clerk session expired?" — surface that as a re-login prompt.
The provisioning side-effect
There is no POST /v1/users or signup endpoint. The first
authenticated request a Clerk user makes lazily inserts a row into
the users table (INSERT … ON CONFLICT DO NOTHING). From the
client's perspective: sign in with Clerk → call any authenticated
endpoint → the backend user exists.
That's why a fresh mobile install does:
const { user } = useUser(); // wait for Clerk
const me = await amy.me.get(); // implicitly provisions
// now any other endpoint also worksIf amy.me.get() returns { user_id, email, connections: [], env: …},
provisioning succeeded. There is no separate "create profile" step.
Token TTLs and refresh
| Kind | Initial TTL | Refresh behavior |
|---|---|---|
| Clerk JWT | ~60 seconds (Clerk default) | Clerk SDK's getToken() auto-refreshes silently when called; the SDK's apiKeyProvider calls it per request, so you almost never see an expired token in practice. |
| Amy CLI JWT | 30 days | None — run amy login to mint a fresh one. The CLI prompts the user when expiry is < 7 days away. |
AMY_ADMIN_KEY | None | Rotated manually; old keys stop working immediately. |
The one place TTL matters is mid-stream. An SSE connection
captures the bearer once at open time; if the connection survives
across a Clerk refresh, the original token (now expired) stays on the
wire until disconnect. In practice, Cloudflare-side timeouts (~100s)
or the natural turn duration force a reconnect before this matters.
If you do see invalid_token mid-turn, reconnect with
Last-Event-Id — the SDK will call apiKeyProvider again and pick
up a fresh token.
What 401 / 403 mean
| Status | error.code | Cause | Recovery |
|---|---|---|---|
| 401 | missing_authorization | No Authorization header sent | Add one. |
| 401 | invalid_token | JWT didn't verify under either AMY_JWT_SECRET or Clerk's JWKS | If the user's logged in via Clerk, call signOut() then re-prompt — the local token cache is stale. If using the CLI, re-run amy login. |
| 403 | forbidden | Authenticated but not allowed for this operation | Don't retry; this means your client requested something out of scope. (Note: requesting another user's resource by id returns 404 turn_not_found / source_not_found, not 403 — ownership is enforced as "not found.") |
The error envelope always includes request_id; surface it to the
user as a copyable string for support. See Errors for
the full catalog.
Common mistakes
Mismatched Clerk instances
Mobile/web publishable key must come from the same Clerk
project as the backend's CLERK_SECRET_KEY. They share a JWKS URL;
keys from a different project verify against different JWKS and
every request returns 401 invalid_token. Symptom: sign-in succeeds
on the client, every API call fails.
Caching the Clerk token yourself
getToken() already caches and refreshes. Wrapping it with your own
cache (useMemo, a module-level singleton, etc.) defeats the
refresh logic and you'll start seeing invalid_token after ~60s.
Just pass () => getToken() directly.
Passing a Clerk publishable key as the bearer
The publishable key (pk_test_… / pk_live_…) is for Clerk's
Frontend API, not Amy. Sending it as Authorization: Bearer pk_…
returns 401 invalid_token. The bearer is always a session JWT.
Treating apiKey and apiKeyProvider as interchangeable
They are mutually exclusive. Setting both throws at constructor time.
The SDK calls setApiKey() is an override for the static path
only; mobile/web should use apiKeyProvider and never touch
setApiKey.
Hitting /admin/* from a user client
Admin endpoints don't accept Clerk or Amy JWTs at all — they require an
x-admin-key: <AMY_ADMIN_KEY> header (not an Authorization: Bearer
token). Missing or wrong key returns 401. Don't try to "share" the
admin key with a trusted user; build a thin admin tool that runs
server-side and proxies the call.
Where to next
- API reference: Authentication — the per-endpoint table.
- Errors — full catalog of error codes you might see.
- SDK: TypeScript — the client option matrix.
- Recipe: Build a mobile app — Clerk wired up end-to-end.
- Internals: Local development — how to point your local stack at a dev Clerk project.
API Reference
Every endpoint, every shape. The live OpenAPI spec at /openapi.json is the source of truth; this page mirrors it in human-readable form and the interactive explorer renders it with Try-It-Now.
Turns
A turn is one round-trip of the agent: the user asks, Amy answers, and every quantitative claim in the answer has been checked. It's the unit of work the backend dispatches, persists, streams, and bi…