Cosmo Realtime SDK
Quickstart

TypeScript quickstart

Build a voice-enabled React app with cosmo-ai in five minutes.

Prerequisites

  • A React 19 app (Vite, Next.js, …). The React bindings live at cosmo-ai/react; react and react-dom are optional peers, so a non-React project installs neither.
  • A Cosmo account — sign up at platform.askcosmo.ai

Set up

The recommended route is the CLI — it signs you in, stores the key, and equips your coding agent in one command:

curl -fsSL https://platform.askcosmo.ai/docs/install.sh | sh
cosmo init

Your browser opens, you pick a workspace, and the CLI stores a key at ~/.cosmo/credentials. Anywhere the SDK runs on Node — a script, a token server, a test — that is the whole setup: new RealtimeClient({}) resolves the credential and the backend it was issued for on its own. Run cosmo whoami to see which workspace you're pointed at, and see Set up with the CLI for what else it does.

Install the SDK

npm install cosmo-ai

livekit-client is a required peer dependency — the SDK imports it at runtime for WebRTC transport. npm 7+ and pnpm 8+ install it automatically; on Yarn, add it yourself with yarn add cosmo-ai livekit-client.

Keeping it a peer means an app that already uses livekit-client directly shares one copy, instead of running two media stacks against the same microphone.

If the install prints an EBADENGINE warning naming machina (a dependency of livekit-client), nothing failed — npm is noting that Node versions before 22.22.0 sit below that package's declared engine floor. The install completes and the SDK runs either way; updating to a current Node 22 LTS or Node 24 clears the warning.

To update later, run npm install cosmo-ai@latest — before 1.0, the ^0.x range npm saves does not cross minor versions, so a plain npm update stays where it is.

Add your token route

A browser page can't hold the API key: whatever a page holds, every visitor holds. What a page gets instead is a minted end-user token — short-lived, scoped to one user — fetched from a /token route in your own app. The route runs on Node, where the key lives; the page only ever sees tokens. The same route, unchanged, is what your app ships with.

In a Vite app, the route is a few lines in vite.config.ts — your code, using the server entry of the SDK:

import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react';
import { RealtimeClient } from 'cosmo-ai/server';

// Your app's /token route, dev-server edition. In production the same
// mint call lives in a real route on your host — see "Ship it" below.
function tokenRoute(): Plugin {
  const client = new RealtimeClient({}); // COSMO_API_KEY from the env, else the `cosmo init` credential
  return {
    name: 'token-route',
    configureServer(server) {
      server.middlewares.use('/token', (_req, res) => {
        void client.mintToken('dev-user').then(
          (minted) => {
            res.setHeader('content-type', 'application/json');
            res.end(JSON.stringify({ jwt: minted.jwt, expires_at: minted.expiresAt }));
          },
          (err) => {
            res.statusCode = 500;
            res.end(String(err));
          },
        );
      });
    },
  };
}

export default defineConfig({ plugins: [react(), tokenRoute()] });

There is no key to paste anywhere: new RealtimeClient({}) on Node resolves a credential itself — COSMO_API_KEY from the environment first, else the one cosmo init stored. In a framework with server routes (Next.js, Remix, …) skip the plugin and put the same three lines in a route handler instead.

Create the client

The page asks your route for a token and the SDK keeps it fresh — refetching as expiry nears, so you never write refresh code. A relative URL rides the page's own origin, which is why this exact line works on localhost:5173 today and on your production domain later:

import { RealtimeClient, TokenSource } from 'cosmo-ai';

const client = new RealtimeClient({ token: TokenSource.endpoint('/token') });

The base URL needs no configuration: it comes from COSMO_BASE_URL on Node, the page's own origin on a Cosmo-served page, and otherwise https://platform.askcosmo.ai.

Never put the API key in browser code or a browser-exposed variable (VITE_*, NEXT_PUBLIC_*): a build inlines it into the served bundle, and a leaked key is not a quota problem — with its minimum scope alone, anyone reading your page's source can list, read, download, and delete every end user's sessions, transcripts, and recordings in the workspace. See End-user credentials.

Start a session

An agent is a reusable persona; start() opens one live session — it POSTs session-start, joins the LiveKit room, publishes your microphone, and resolves once the agent is ready, so every session method works immediately.

const agent = client.agent({
  instructions: 'You are a terse voice assistant.',
  greeting: 'Hi — how can I help?',
});

const session = await agent.start();

session.on('transcript', (event) => {
  if (event.isFinal && event.text) console.log(`[${event.role}] ${event.text}`);
});

A console is append-only, so it prints each completed turn once, straight off the raw delta stream — no folding involved. A UI renders the session-owned state instead: session.transcript is the coalesced conversation (one { id, role, text, isFinal } item per turn), and transcript_updated delivers the full updated list on every change — replace what you show, don't merge. Other events you can subscribe to on the session: ready, transcript (the raw deltas), model_text, tool_call, tool_result, turn_complete, error, and lifecycle — or consume everything as one stream with for await (const event of session). You can also type instead of talk with session.sendText('...'), toggle the mic with session.setMuted(true), and hang up with session.end().

Wire up React

RealtimeProvider distributes the session through context so the hooks work from any child component. Start the session imperatively, then hand it to the provider:

import { useCallback, useState } from 'react';
import {
  RealtimeClient,
  TokenSource,
  type RealtimeSession,
} from 'cosmo-ai';
import {
  RealtimeProvider,
  MicToggle,
  RealtimeAudio,
  useRealtimeSessionContext,
  useTranscript,
  useTransportState,
} from 'cosmo-ai/react';

export function App() {
  const [session, setSession] = useState<RealtimeSession | null>(null);

  const handleStart = useCallback(async () => {
    const client = new RealtimeClient({ token: TokenSource.endpoint('/token') });
    setSession(
      await client.agent({ instructions: 'You are a terse voice assistant.' }).start(),
    );
  }, []);

  if (session === null) return <button onClick={handleStart}>Start Session</button>;

  return (
    <RealtimeProvider session={session}>
      <SessionView />
    </RealtimeProvider>
  );
}

function SessionView() {
  const session = useRealtimeSessionContext();
  const transport = useTransportState();
  const transcript = useTranscript();

  return (
    <div>
      <span>{transport}</span>
      <MicToggle />
      <button onClick={() => void session?.end()}>End Session</button>
      <RealtimeAudio />
      {transcript.map((item) => (
        <p key={item.id} style={{ opacity: item.isFinal ? 1 : 0.6 }}>
          <strong>{item.role}</strong> {item.text}
        </p>
      ))}
    </div>
  );
}

<RealtimeAudio /> owns the <audio> element that plays the agent's voice; <MicToggle /> is a ready-made mute button. Other hooks: useAgentState, useMicLevel, useOutputLevel, and useRealtimeError. <BarVisualizer /> renders a level meter, and <StartAudio> handles browsers (Safari, mobile Chromium) that block audio autoplay until a user gesture.

Run it

Start your dev server, open the page, and select Start Session. Grant the microphone permission and say hello.

What you should see

The agent speaks its greeting, and the transcript pane fills in as you talk — non-final deltas render dimmed, then solidify:

assistant  Hi — how can I help?
user       what's the tallest mountain in the world
assistant  Mount Everest, at 8,849 meters.

Ship it

Deploying changes one thing: where the mint call runs. Move the same mint into a real route on your host — a Next.js route handler looks like this — and set COSMO_API_KEY in the deployment's environment (a dashboard key with only the User tokens — mint scope; see API keys):

// app/api/token/route.ts
import { RealtimeClient } from 'cosmo-ai/server';

const client = new RealtimeClient({});

export async function POST(request: Request) {
  // Your app's own auth. This route mints realtime access and spends the
  // workspace's credits, so it answers logged-in users only — an open one
  // hands both to anyone who finds the URL.
  const user = await getUserFromSession(request);
  if (user === null) return new Response('Unauthorized', { status: 401 });

  // Whose token this is. Cosmo meters and scopes per this id, so it must be
  // your user's — never a constant.
  const minted = await client.mintToken(user.id);
  return Response.json({ jwt: minted.jwt, expires_at: minted.expiresAt });
}

The dev route above mints for a fixed 'dev-user' because it only ever answers your own machine. A deployed one is reachable by anyone, so it authenticates first and mints for the caller it identified.

The page code does not change — TokenSource.endpoint('/token') is already pointing at it. Share your app walks a full deploy; End-user credentials covers identity, TTLs, and revocation.

Troubleshooting

/token answers 403 naming user_tokens:mint. Cause: the credential behind the route can't mint. CLI keys from before minting joined sign-in's grants don't carry the scope. Solution: run cosmo login again for a fresh key; in a deployment, check the key's scopes on the API keys page.

TokenSourceError and the session never starts. Cause: the token route is missing, crashed, or its response isn't { jwt, expires_at }. Solution: hit /token directly (curl -X POST localhost:5173/token) and read the body — the SDK surfaces the route's own error slug on serverCode.

The browser never prompts for the microphone. Cause: getUserMedia needs a secure context. Solution: use localhost or an https:// origin. A LAN IP over plain http is not a secure context and the prompt never appears.

transportState sits at connecting and ready never fires. Cause: the session-start call succeeded but the room join didn't. Solution: start() won't hang — it rejects with a SessionStartError whose code is handshake_failed when the room closes, or ready_timeout after 40 seconds. Read that error, check the browser console for a blocked request, then read Debugging for how to pull the session id and correlate it server-side.

Next steps

On this page