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:
| SDK | Read | Change notification |
|---|---|---|
| TypeScript | session.transcript | session.on('transcript_updated', ({ items }) => …) — replays the current value on subscribe |
| Python | session.transcript | TranscriptUpdatedEvent on the session iterator, carrying items |
| Swift | await session.transcript | .transcriptUpdated(TranscriptUpdatedEvent) on session.events, carrying items |
Each item is one turn:
| Field | Type | Meaning |
|---|---|---|
id | string | Stable render key, minted when the turn opens and never reused |
role | user | assistant | Who spoke |
text | string | The coalesced turn text so far |
isFinal / is_final | bool | false 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
isFinalisfalse, 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 byid, not by index. - Once
isFinalistruethe 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 passtranscript: 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 mergefor 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-textinto the conversation transcript. In audio sessions it contains function-call narration that was never spoken. isFinal: trueon 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.