Amy
Concepts

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's apiKeyProvider makes 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

KindWho issues itTTLFormatWhere it lives
Clerk session JWTClerk Frontend API after sign-in~60s, auto-refreshed by Clerk SDKsRS256 JWT verified against Clerk's JWKSIn-memory on the device; written to expo-secure-store (mobile) or memory (web) by Clerk's tokenCache
Amy CLI JWTPOST /v1/auth/cli-approve after the CLI browser-login handshake30 days, no refresh — re-run amy login to mint a new oneHS256 JWT signed with AMY_JWT_SECRET~/.amy/credentials.json on the dev's machine
AMY_ADMIN_KEYHand-rolled by the Amy team and put in 1PasswordForever, rotated manuallyOpaque static stringServer-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:

  1. If the bearer looks like an Amy JWT (HS256 header), verify it under AMY_JWT_SECRET; on success, extract the sub (Clerk user ID) and proceed.
  2. Otherwise verify it as a Clerk session JWT against Clerk's JWKS; on success, extract the sub and proceed.
  3. Otherwise return 401 invalid_token (or 401 missing_authorization if there was no Authorization header 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

SurfaceToken kindWhy
Mobile (Expo / React Native)Clerk JWT via @clerk/expoNative sign-in UI, biometric token cache, auto-refresh — all free.
Web (Next.js / React / Vue)Clerk JWT via @clerk/nextjs or @clerk/reactSame as above, plus middleware-based route protection.
CLI / TUIAmy CLI JWT via amy loginTerminals can't run Clerk's React UI; the CLI does a one-time browser handshake and mints a long-lived bearer.
Server-to-server / cronAmy 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 works

If amy.me.get() returns { user_id, email, connections: [], env: …}, provisioning succeeded. There is no separate "create profile" step.


Token TTLs and refresh

KindInitial TTLRefresh 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 JWT30 daysNone — run amy login to mint a fresh one. The CLI prompts the user when expiry is < 7 days away.
AMY_ADMIN_KEYNoneRotated 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

Statuserror.codeCauseRecovery
401missing_authorizationNo Authorization header sentAdd one.
401invalid_tokenJWT didn't verify under either AMY_JWT_SECRET or Clerk's JWKSIf 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.
403forbiddenAuthenticated but not allowed for this operationDon'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

On this page