Cosmo Realtime SDK
Concepts

Transcripts

The session-owned conversation (session.transcript) vs the two raw text streams underneath — which to read for which UI.

The session maintains the conversation for you: session.transcript is a list of coalesced turn items, updated as speech streams in. Rendering a chat UI is reading that list — no delta folding on your side. Underneath it, the server emits two distinct raw text channels, which stay available for debug surfaces and custom pipelines.

The transcript: session-owned state

Every SDK exposes the same value and a change notification in its native idiom:

SDKReadChange notification
TypeScriptsession.transcriptsession.on('transcript_updated', ({ items }) => …) — replays the current value on subscribe
Pythonsession.transcriptTranscriptUpdatedEvent on the session iterator, carrying items
Swiftawait session.transcript.transcriptUpdated(TranscriptUpdatedEvent) on session.events, carrying items

Each item is one turn:

FieldTypeMeaning
idstringStable render key, minted when the turn opens and never reused
roleuser | assistantWho spoke
textstringThe coalesced turn text so far
isFinal / is_finalboolfalse while the turn is in progress; true once it closed

What the session guarantees:

  • At most one in-progress item per role at a time, always that role's most recent item.
  • While isFinal is false, the item's text may grow, be replaced wholesale by the closing final (transcription can correct earlier words), or the item may disappear entirely (a retracted turn) — so diff by id, not by index.
  • Once isFinal is true the item never changes again. Safe to persist, copy, or feed downstream.
  • Text you send with sendText / send_text / send(text:) lands as its own closed user item — an in-progress speech transcription is untouched — unless you pass transcript: false.
  • The list is uncapped and survives the session's end — read the full conversation after end().
  • The notification always carries the complete updated list: replace what you're rendering, don't merge.

The two raw text streams

transcript (TranscriptDeltaEvent) — a streaming transcription of audio, and what session.transcript is folded from. For the user side, this is speech-to-text from the mic. For the assistant side, this is the transcription of the model's audio output — the words the listener actually heard.

model-text (ModelTextEvent) — the model's text-channel output. In audio sessions, the model may emit function-call narration here (for example, start_analysis({...})) or written-style text that it never spoke aloud. In text-only sessions (audio.output: false), this carries the model's full response. Use this for debug surfaces, text-mode sessions, or logs.

For a "what was spoken" conversation UI, read session.transcript — it is built from transcript events only. Don't merge model-text into it: in audio sessions it contains narration that was never spoken.

Code

session.on('transcript_updated', ({ items }) => {
  render(items); // one bubble per item — that's the whole algorithm
});

// Read at any point, including after the session ends:
await session.end();
save(session.transcript);

React apps use the useTranscript() hook, which reads the same state:

import { useTranscript } from 'cosmo-ai/react';

function Transcript() {
  const items = useTranscript();
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} style={{ opacity: item.isFinal ? 1 : 0.6 }}>
          <strong>{item.role}</strong>: {item.text}
        </li>
      ))}
    </ul>
  );
}
from cosmo_ai import TranscriptUpdatedEvent

async with agent.start() as session:
    async for event in session:
        if isinstance(event, TranscriptUpdatedEvent):
            render(event.items)  # the whole conversation — replace, don't merge
for try await event in session.events {
    if case .transcriptUpdated(let update) = event {
        render(update.items)
    }
}

SwiftUI renders items directly — TranscriptItem is Identifiable:

List(items) { item in
    Text("[\(item.role)] \(item.text)")
}

The raw delta stream

The transcript events remain available in every SDK for pipelines that want the firehose — live captioning, latency measurement, custom storage. Their wire contract: a streaming event (is_final: false) carries the new fragment since the previous event for that role's turn; the terminating event (is_final: true) carries the turn's cumulative text and supersedes the accumulated fragments (transcription may correct earlier words). After the terminating event, turn-complete arrives for that role.

The stream has edge cases the session's fold already handles — turns that finalize empty (a retraction), silent sessions (audio.output: false) that commit one utterance as several finals, and finals whose bubble only turn-complete closes. A consumer building conversation state from raw deltas takes those on itself; session.transcript exists so nothing else has to.

Pitfalls

  • Don't merge model-text into the conversation transcript. In audio sessions it contains function-call narration that was never spoken.
  • isFinal: true on an item means "this turn's transcription is complete", not "the audio finished playing" — playback can still be under way.
  • Transcript lines in a language nobody spoke name one of two failures, and the agent's own replies tell them apart. Replies (and tool-call text) in the wrong language too means the conversation drifted — there is no language setting to reach for; steering lives in the agent's instructions (Language). User lines alone looking wrong while the agent answers in the right language is the transcription layer: on the OpenAI-family and Grok providers, user transcripts come from a separate speech-to-text model that identifies language on its own, and instructions never reach it.

On this page