Cosmo Realtime SDK
Concepts

Lifecycle

The event sequence from start to end — six phases with a state diagram.

A Cosmo Realtime session progresses through a predictable sequence of events. Understanding the sequence lets you build accurate UI states and avoid race conditions.

State diagram

From idle, agent.start() moves the session into connecting (session-start POST + LiveKit room join), then connected once the room is up; the call itself returns after the agent's ready handshake lands. A rejected handshake lands in disconnected(handshake_failed). While connected, each turn runs the event chain on the right and loops; end(), session-ended, or a transport drop ends in disconnected.

Session lifecycle state diagramFrom idle, agent.start() moves the session to connecting, then to connected once the room is joined; the start call itself returns after the agent's ready handshake lands. A rejected handshake moves it straight to disconnected with reason handshake_failed. While connected, each turn runs user-started-speaking, user-stopped-speaking, bot-llm-started, optional tool events, bot-tts-started, bot-started-speaking, streaming transcript, bot-stopped-speaking, bot-tts-stopped, then turn-complete, and loops; a text turn skips the speech events. Transient drops move connected to reconnecting and back; exhausted retries, end(), session-ended, or a transport drop end in disconnected.idleagent.start()connectingconnectedreconnectingdisconnected(with a typed reason)room joineddroprecoveredretries exhaustedhandshake_failedend()session-endedtransport dropwhile connected — one turnlisteninguser-started-speakinguser-stopped-speakingbot-llm-startedtool events (optional)tool-call → dispatch → resultbot-tts-startedbot-started-speakingtranscript (streaming)bot-stopped-speakingbot-tts-stoppedturn-completeuser speakstextturnnext turn

Phase by phase

Each phase below names the transition, the events that mark it, and what your UI should do.

1. Connect to ready

agent.start() performs the session-start POST, joins the LiveKit room, and waits for the agent's ready handshake before it resolves — so a started session is one the model is already listening on. ready is also delivered on the session's event stream:

session.on('ready', ({ sessionId }) => {
  console.log(`Session ready: ${sessionId}`);
});

The connection lifecycle moves idle → connecting → connected. Transport-connected and agent-ready are distinct points inside that window: the room comes up first, and start() resolves only once the agent is ready to receive input.

2. user-started-speaking

Server-side VAD — voice activity detection, the model's "is the user talking right now?" check — fired. This is informational; the mic track keeps streaming regardless. Use it to show a "listening" indicator.

3. user-stopped-speaking

VAD detected silence. The model treats the turn as complete — tune this with interruption_sensitivity (see Turn-taking).

4. bot-llm-started

The model began generating. Use this to show a "thinking…" indicator before audio starts.

5. Tool call (optional)

If the model calls a tool, three events fire:

  1. tool-call — model decided.
  2. tool-dispatch-started — server handler began.
  3. tool-result — handler returned.

All three share tool_call_id. After the final tool-result, the model continues generating. (Client-executed tools additionally surface tool-invocation — see Tools.)

6. bot-tts-started → bot-started-speaking → transcript → bot-stopped-speaking → turn-complete

bot-tts-started and bot-tts-stopped bracket speech synthesis. On a fused speech-to-speech model they fire close to bot-started-speaking; on a modular STT → LLM → TTS pipeline they sit further apart, which is the gap worth instrumenting if you're chasing latency.

The agent's audio then starts, transcript deltas stream, and audio ends. turn-complete marks the end of the assistant's turn and resets internal state for the next turn.

Session event subscription

Every SDK delivers the same event sequence; only the idiom differs.

RealtimeSession supports both callbacks and async iteration over the wire-level stream:

const session = await agent.start();

session.on('ready', ({ sessionId, rejectedTools, maxSessionSeconds }) => { /* session live */ });
session.on('lifecycle', (state) => { /* { kind, disconnectReason?, detail? } */ });
session.on('agent_state', (state) => { /* 'idle' | 'listening' | 'thinking' | 'speaking' */ });
session.on('transcript', (event) => { /* { role, text, isFinal } */ });
session.on('model_text', (event) => { /* { text, isFinal } */ });
session.on('tool_call', (event) => { /* { toolCallId, name } */ });
session.on('tool_result', (event) => { /* { toolCallId, ok, summary } */ });
session.on('turn_complete', ({ role }) => { /* end-of-turn marker */ });
session.on('reconnecting', ({ secondsRemaining }) => { /* upstream model rotation */ });
session.on('session_ending_soon', ({ secondsRemaining, reason }) => { /* countdown */ });
session.on('session_ended', ({ reason }) => { /* once, on any exit path */ });
session.on('error', (error) => { /* { code, message } | null */ });

Or consume everything in order:

for await (const event of session) {
  // the session's own event types — the same values `on()` delivers —
  // switchable on `event.type`, the SDK's own name for the event rather
  // than the wire's — and the name `on()` takes wherever the event has a
  // callback too; `{type: 'unknown'}` for unrecognized frames; a
  // `session_ended` item is always the final one
}

Python sessions are a single typed async stream — match on the event class:

from cosmo_ai import (
    ReadyEvent,
    TranscriptDeltaEvent,
    ToolCallEvent,
    ToolResultEvent,
    TurnCompleteEvent,
    ReconnectingEvent,
    ErrorEvent,
    SessionEndedEvent,
)

async with agent.start() as session:
    async for event in session:
        match event:
            case ReadyEvent():
                print("live, session:", event.session_id)
            case TranscriptDeltaEvent():
                print(f"[{event.role.value}] {event.text}")
            case ToolCallEvent():
                print("tool:", event.name)
            case TurnCompleteEvent():
                print("turn over:", event.role.value)
            case SessionEndedEvent():
                print("ended:", event.reason)

Speech and processing markers (UserStartedSpeakingEvent, BotStartedSpeakingEvent, BotLlmStartedEvent, BotTtsStartedEvent, and their *Stopped counterparts) arrive on the same stream. Unrecognized frames surface as UnknownEvent and never terminate the stream.

Swift sessions expose one typed AsyncThrowingStream — iterate it from exactly one task:

for try await event in session.events {
    switch event {
    case .ready(let ready):            print("live, session:", ready.sessionId)
    case .transcript(let delta):       print(delta.text)
    case .modelText(let text):         print("model text:", text.text)
    case .turnComplete(let turn):      print("turn over:", turn.role)
    case .userStartedSpeaking:         break
    case .botStartedSpeaking:          break
    case .botLlmStarted:               break
    case .toolCall(let call):          print("tool:", call.name)
    case .toolResult(let result):      print("done:", result.ok)
    case .reconnecting:                break
    case .error(let err):              print("error:", err.code, err.fatal)
    case .sessionEnded(let ended):     print("over:", ended.reason ?? "")
    case .unknown(let rawType, _):     print("unrecognized:", rawType ?? "?")
    default:                           break
    }
}

sessionEnded is always the final element; the sequence finishes after it. Start failures throw from agent.start(...) instead of appearing on the stream.

Connection state

In addition to the application-level events above, every SDK exposes the same formal connection lifecycle:

idle → connecting → connected ↔ reconnecting → disconnected

with the same typed end reasons once disconnected: client_ended (you called end()), client_closed (you tore down locally without the wire goodbye), handshake_failed (the server rejected the session start), server_ended (the server hung up on purpose), transport_error (the connection died).

TypeScript exposes the lifecycle as a lifecycle event plus a synchronous getter:

session.on('lifecycle', (state) => {
  // state.kind: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected'
  if (state.kind === 'disconnected') {
    // state.disconnectReason: 'client_ended' | 'client_closed' |
    //   'handshake_failed' | 'server_ended' | 'transport_error'
    console.log('closed:', state.disconnectReason, state.detail);
  }
});

session.state;  // or read the current SessionState synchronously

(The TypeScript engine additionally exposes a browser-UX transport_state axis with permission/ready/disconnecting phases; the lifecycle machine above is the cross-SDK vocabulary.)

Python delivers state changes through a callback passed to agent.start():

from cosmo_ai import SessionState, SessionStateKind

def on_state(state: SessionState) -> None:
    if state.kind is SessionStateKind.DISCONNECTED:
        print(f"closed: {state.disconnect_reason}")   # DisconnectReason enum
    else:
        print(f"state: {state.kind.value}")

session = await agent.start(on_state_change=on_state)

Swift delivers state changes through a handler passed to agent.start, and the current value is readable as await session.state:

let onStateChange: @Sendable (SessionState) -> Void = { state in
    switch state {
    case .idle:              print("not connected")
    case .connecting:        print("session-start in flight")
    case .connected:         print("LiveKit room is live — first connect or a completed recovery")
    case .reconnecting:      print("transient drop — LiveKit is recovering")
    case .disconnected(let reason, let detail):
        switch reason {
        case .clientEnded:     print("end() was called — wire end frame sent")
        case .clientClosed:    print("close() was called locally")
        case .handshakeFailed: print("session-start rejected: \(detail ?? "")")
        case .serverEnded:     print("server ended: \(detail ?? "no reason")")
        case .transportError:  print("transport error: \(detail ?? "")")
        }
    }
}

let session = try await agent.start(onStateChange: onStateChange)

.disconnected is terminal — start a new session to reconnect.

Reconnect behavior

The SDK never re-mints a session automatically. Reconnect is layered:

Layer 1 — LiveKit's transient recovery (automatic, owned by LiveKit). Network blips, ICE renegotiation, signal-server reconnects all happen inside the LiveKit SDK, with no involvement from the Cosmo SDK. The session_id, room, and tracks survive. While LiveKit is recovering, all three SDKs surface the in-progress state:

  • TypeScript: lifecycle kind becomes 'reconnecting', then back to 'connected' on success.
  • Swift: onStateChange fires with .reconnecting, then .connected on success.
  • Python: on_state_change fires with SessionStateKind.RECONNECTING, then CONNECTED on success.

Surface this for "Reconnecting…" UI; no action required.

Layer 2 — LiveKit gave up (session is gone). If LiveKit's own retry budget exhausts, the lifecycle goes disconnected with reason transport_error and the event stream finishes with its terminal session-ended item. The session object is done in every SDK — mint a fresh session, optionally resuming the conversation:

session = await agent.start(resume_session_id=old_session_id)

See Reconnects for a full recipe.

The application-level reconnecting event (the server emits it while rotating the upstream model session) is distinct from both layers — the transport stays up, the session survives, and no lifecycle state changes. Show a brief indicator and stay put.

Pitfalls

  • bot-started-speaking fires before the first audio frame reaches the speaker. Don't use it to gate mic muting — use real AEC instead (see Audio).
  • Read the conversation from session.transcript (or useTranscript() in React) — the session folds its own delta stream into one item per turn, so nothing accumulates transcript state by hand.
  • The server doesn't send user-started-speaking for text turns — there is no audio to detect. bot-llm-started is the first marker after sendText().
  • The Swift events stream is single-consumer: iterate it from exactly one task; state changes arrive on the onStateChange handler, not the stream.

On this page