End-user credentials
Ship apps whose users never see an API key — mint short-lived, per-user JWTs from your server.
This page is the productionization step — it matters when you distribute an app to end users. For local development and server-side apps, your API key alone is the whole story: pass it, or let the SDK resolve it from COSMO_API_KEY / cosmo login. Don't build token minting on day one.
Two roles hold credentials, and they must never swap:
| Role | Credential | Lives |
|---|---|---|
| You (the developer) | workspace API key, cosmo_… | your server, your laptop — never a shipped binary or a browser bundle |
| Your end user | minted end-user token (a JWT), eyJ… | their device, short-lived, scoped to one user |
An API key in a shipped app is your whole realtime workspace in every user's hands. The minting flow keeps the key server-side and hands each user a token that can join sessions but nothing else. During local development your cosmo login key can back the mint (clamped to hour-long tokens), so the same route runs unchanged on your laptop and in production.
The flow
end-user app ──"log me in"──► your server ──POST /auth/token──► Cosmo
▲ │ (API key, user_tokens:mint)
└───────── JWT ◄──────────────┘1. Your server mints (holds an API key with the user_tokens:mint scope — in the key-creation dialog this is the User tokens — mint checkbox, unchecked by default; see creating a key):
// cosmo-ai/server is the React-free entry. The root cosmo-ai export re-exports
// the React bindings, which a Next route handler's react-server condition rejects.
import { RealtimeClient } from 'cosmo-ai/server';
const client = new RealtimeClient({ apiKey: process.env.COSMO_API_KEY });
const { jwt, expiresAt, tokenId } = await client.mintToken(user.id);client = RealtimeClient(api_key=os.environ["COSMO_API_KEY"])
minted = await client.mint_token(external_user_id=user.id)
# → minted.jwt, minted.expires_at, minted.token_idexternal_user_id is an opaque string of your choosing (1–128 chars) — your user id, not an email. Cosmo uses it to attribute sessions to that user; it's the join key between your users and the dashboard's External Users list. Usage metering stays workspace-wide.
2. The device connects. The recommended shape is a TokenSource pointing at your minting endpoint — the SDK fetches the JWT itself, caches it, and re-fetches as expiry nears, so the app carries no refresh code:
const client = new RealtimeClient({
token: TokenSource.endpoint('https://your-backend.example.com/token', {
headers: { Authorization: `Bearer ${appSession}` }, // your app's own auth
}),
});
const session = await client.agent({ instructions: '…' }).start();client = RealtimeClient(token=TokenSource.endpoint(
"https://your-backend.example.com/token",
headers={"Authorization": f"Bearer {app_session}"},
))let client = RealtimeClient(tokenSource: try .endpoint(
URL(string: "https://your-backend.example.com/token")!,
headers: ["Authorization": "Bearer \(appSession)"]
))TokenSource.endpoint POSTs an empty JSON body and expects { jwt, expires_at } back — exactly what your server's mintToken call returns, so the route is a forward. Any endpoint speaking that shape works. When your fetch logic doesn't fit a static URL + headers (custom transport, request signing), TokenSource.custom(async () => …) takes an arbitrary async function returning a MintedToken instead.
A raw string still works wherever you already have a JWT in hand:
const client = new RealtimeClient({ token: jwt });No backend yet? Deploy the token-server template — a single zero-dependency file that runs on Cloudflare Workers, Vercel, Deno Deploy, AWS Lambda, or plain Node, holding your key and speaking the endpoint shape out of the box.
Provisioning-key scope
For defense in depth, mint with a provisioning key — an API key carrying only the user_tokens:mint scope (check User tokens — mint and nothing else when creating the key). It can create end-user tokens but can't join sessions itself, so even a leak of your minting endpoint's credential doesn't grant conversational access. Your session-capable key (realtime:start, plus whatever other voice verbs it needs) stays wherever server-side agents actually run.
Expiry and refresh
Minted tokens are short-lived; expires_at tells you when. With a TokenSource the SDK owns the lifecycle:
- The token is fetched on first use and reused while it has comfortably more than a minute left; inside that margin the SDK re-fetches on its own.
- A token only needs to be valid at session start; an in-flight session isn't cut off by its token expiring.
- A session start rejected with
401(a revoked token) drops the cached token, so the next start fetches fresh — surface the error and let the user retry.
Handing raw JWT strings around instead? Then the refresh loop is yours: mint at launch/login, re-mint as expires_at approaches, and on a 401 at session start re-mint and retry once.
Token lifetime
A minted token lives 24 hours by default. POST /auth/token accepts an optional ttl_seconds (60–86400) to shorten that for tokens handed to short-lived contexts — a kiosk session, a support handoff:
curl -X POST https://platform.askcosmo.ai/api/v1/external/auth/token \
-H "Authorization: Bearer $COSMO_API_KEY" -H "Content-Type: application/json" \
-d '{"external_user_id": "user-123", "ttl_seconds": 3600}'The SDK mint methods take the same knob: mintToken('user-123', { ttlSeconds: 3600 }) (TypeScript), mint_token("user-123", ttl_seconds=3600) (Python), mintToken("user-123", ttlSeconds: 3600) (Swift).
With a TokenSource on the device, a shorter lifetime just means more frequent re-fetches — no app code changes.
Token scopes
By default a minted token carries realtime:start alone — it can start and hold conversations, and nothing else. Everything beyond that is your call, made per token with the optional scopes list on POST /auth/token:
curl -X POST https://platform.askcosmo.ai/api/v1/external/auth/token \
-H "Authorization: Bearer $COSMO_API_KEY" -H "Content-Type: application/json" \
-d '{"external_user_id": "user-123", "scopes": ["realtime:start", "realtime:read", "realtime:delete"]}'The mintable set adds realtime:read and realtime:delete — both project-bound, so the token reads or deletes only its own end user's sessions — and the connectors scopes, for apps whose end users connect their own Gmail/Slack for the agent to use. Compose what your app needs: a read-only observer (realtime:read), a "delete my recording" button (realtime:read + realtime:delete), a start-only kiosk (the default). The default already covers the session object's own surface — session.usage() works on any session the token starts, no extra scope needed. Requesting anything outside the mintable set (realtime:dial, realtime:logs, support:write, …) is rejected with 400.
Early revocation
The mint response carries a token_id alongside the JWT. To cut one token off before it expires — a user logged out, a JWT leaked — call the revoke endpoint with your provisioning key:
curl -X DELETE https://platform.askcosmo.ai/api/v1/external/auth/token/$TOKEN_ID \
-H "Authorization: Bearer $COSMO_API_KEY"Revocation takes effect on the next request presenting the JWT; a session already running is not ended. Revoking an already-revoked token succeeds, so retries are safe. Note the asymmetry: revoking or rotating your API key does not invalidate JWTs it already minted — they live out their expires_at unless revoked individually.
What tokens can and can't do
API key (realtime:use) | Minted JWT (default: realtime:start) | |
|---|---|---|
| Start sessions | ✓ | ✓ |
| Client tools, hooks, skills | ✓ | ✓ |
| Catalog agents | ✓ | ✓ |
Post-call usage (session.usage()) | ✓ | ✓ |
| Read own sessions back | ✓ | opt-in at mint (token scopes) |
| Dial phone numbers | ✓ | ✗ |
| Delete session records | ✓ | opt-in at mint (token scopes) |
| Per-turn diagnostics | ✓ | ✗ |
| Mint tokens | with user_tokens:mint | ✗ |
The boundary is intentional: anything that spends money on the phone network, touches workspace routing, destroys records, or exposes operational telemetry stays behind the key you control.