Cosmo Realtime SDK
Concepts

Transcripts

transcript (what was spoken) vs model-text (what the model emitted alongside) — which to subscribe to for which UI.

The server emits two distinct text channels. They are different things and should not be merged.

What it is

transcript (RealtimeTranscriptDelta) — a streaming transcription of audio. 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. Use this to render a "what was said" conversation bubble.

model-text (RealtimeModelText) — the model's text-channel output. In audio sessions, the model may emit function-call narration here (e.g. start_analysis({...})) or written-style text that it did NOT speak 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.

The key rule

For a "what was spoken" transcript UI, subscribe to transcript only. Do not include model-text.

For a text-mode (no audio) session, subscribe to model-text only — assistant transcript events will not fire.

transcript event fields

FieldTypeMeaning
role"USER" | "ASSISTANT"Who spoke
textstringStreaming: the new fragment since the previous event. Final: the cumulative full transcript for the turn
is_finalboolfalse = streaming delta (append it); true = terminating event (replace what you accumulated with text)

In the TypeScript SDK the normalized TranscriptEvent has:

FieldTypeMeaning
idstring${turnId}-${seq} — stable per delta
turnIdstringShared across all deltas in one turn; resets on turn-complete
role'user' | 'assistant'Normalized from the wire "USER" / "ASSISTANT"
textstringFragment text
isFinalboolWhether this is the terminating event for the turn
appendbooltrue if this delta should be appended to the last bubble for this turn; false for a new bubble

Code

TypeScript — via session.on():

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

// model-text for debug:
session.on('model_text', (event) => {
  console.log(`[model-text] ${event.text}`);
});

React apps can use the useTranscript() hook instead, which folds deltas into ready-to-render items:

import { useTranscript } from 'cosmo-ai';

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>
  );
}

Python — match on the event class in the session stream:

from cosmo_ai import RealtimeModelText, RealtimeTranscriptDelta

async with agent.start() as session:
    async for event in session:
        if isinstance(event, RealtimeTranscriptDelta):
            role = event.role.value          # "USER" or "ASSISTANT"
            marker = "»" if event.is_final else "…"
            print(f"[{role}] {event.text}{marker}")
        elif isinstance(event, RealtimeModelText):
            print(f"[model-text] {event.text}")

Swift — switch on the event stream:

for try await event in session.events {
    switch event {
    case .transcript(let delta):
        let role = delta.role == .user ? "user" : "assistant"
        let marker = delta.isFinal ? " »" : "…"
        print("[\(role)]\(marker) \(delta.text)")
    case .modelText(let text):
        print("[model-text] \(text.text)")
    default:
        break
    }
}

is_final semantics

The wire contract applies to both roles:

  • Streaming events (is_final: false) — text is the new fragment since the previous event for that role's turn. Append.
  • Terminating event (is_final: true) — text is the cumulative full transcript for the turn. Replace whatever you accumulated with this value.

After the terminating event, turn-complete arrives for that role.

current = ""

async for event in session:
    if isinstance(event, RealtimeTranscriptDelta):
        if event.is_final:
            current = event.text        # replace: full turn transcript
            print("Final:", current)
        else:
            current += event.text       # append: streaming preview

Pitfalls

  • Do not merge model-text into the conversation transcript. In audio sessions it contains function-call narration that was never spoken, and mixing it produces confusing transcripts.
  • Deltas for the same turn arrive in order, but do not assume each delta is a full sentence. Append streaming fragments and let the is_final event replace the accumulation.
  • is_final: true means "this turn's transcription is complete", not "the assistant turn is over" — audio can still be playing out. turn-complete signals the turn boundary.

See also

  • Lifecycle — when transcript and turn-complete fire relative to each other
  • Events — the full data-channel message map
  • Toolsmodel-text may carry tool-call narration in audio sessions
  • Sessionsresume_session_id for continuing a conversation

On this page