Cosmo Realtime SDK
Production

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:

RoleCredentialLives
You (the developer)workspace API key, cosmo_…your server, your laptop — never a shipped binary or a browser bundle
Your end userminted 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_id

external_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 tokenswith 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.

On this page