> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pyai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Preserve transcript speakers

> Keep Omni speaker roles intact from streaming events through storage, analysis, and your transcript UI.

> [!WARNING]
>
> **Keep structured turns as your source of truth.** Preserve each returned `role`,
> `text`, order, and any IDs or timestamps through storage and analysis. Never
> reconstruct speakers from punctuation, sentence count, alternating rows, or
> literal `Agent:` / `User:` text. Consecutive turns may have the same speaker.

A display string is a derived view, not an interchange format. Flattening turns
into labelled text and later guessing their speakers can corrupt a correct
transcript. Keep analysis and summaries in separate fields; do not let them
replace the original turns. This prevents speaker-label corruption; it does not
correct speech recognition, conversation reasoning, or interrupted playback.

## Streaming text and stored turns have different meanings

| Source | What it means | How to handle it |
| - | - | - |
| Raw Omni `0x02` frame | Plain UTF-8 caller text delta; no role, ID, or finality field in the body. | Append to a provisional caller display using the **frame contract**, never the words or punctuation. Do not treat every delta as a final turn. |
| SDK `onTranscript` caller event | Normalized `role: "user"`, `final: false`, `mode: "delta"`. | Preserve the event; the SDK supplies the role from the transport contract. A UI turn boundary is not a new server finality guarantee. |
| Assistant `0x03` transcript advisory | JSON with `event: "transcript"`, `role: "assistant"`, `text`, `final: true` when supplied. | Preserve the explicit role. Final text does **not** prove that the caller heard the whole reply. |
| Stored call transcript | Structured `transcript.turns` returned by the transcript endpoint. | Use these roles and this order for post-call storage, analysis input, and display. Preserve the response separately from provisional events. |

Read [the wire protocol](/realtime/omni-protocol.md) for framing and lifecycle.
Unknown speaker information from other sources must remain unknown or be rejected
for review, never assigned by alternation. Only interpret caller deltas as caller
speech when their transport actually guarantees that meaning.

## One pipeline: events → storage → analysis → UI

This Node.js 20+ example is the transcript portion of your existing
[Omni connection](/guides/omni-overview.md). Keep its audio, configuration, tool, and
interruption handlers. It makes no new call or recording. Run authenticated
transcript retrieval on your server with an `omni:read` key; never expose that key
in a browser. Use the opaque PyAI `call_id` from the call list/detail API, not a
carrier call ID.

Save the following as `transcript-pipeline.mjs`. `recordEvent` accepts your own
append-only event-store callback; it does not turn live deltas into durable turns.
`readTranscript` fetches the stored response after the call is available.

```js transcript-pipeline.mjs theme={null}
export function recordEvent(appendEvent, frame) {
  // Retain event fields; do not guess a speaker or mark a delta final.
  return appendEvent(structuredClone(frame));
}

export function transcriptViews(document) {
  const turns = document?.transcript?.turns;
  if (typeof document?.call_id !== "string" || !Array.isArray(turns) ||
      turns.some(t => !t || !["user", "assistant"].includes(t.role) ||
        typeof t.text !== "string")) {
    // Reject unsupported shapes for review rather than silently assigning roles.
    throw new Error("Expected structured Omni turns; never infer missing roles");
  }
  const stored = structuredClone(document);
  return {
    stored,
    // Separate copies prevent analysis/UI mutations changing the source record.
    analysisInput: { call_id: stored.call_id,
      turns: structuredClone(stored.transcript.turns) },
    uiRows: structuredClone(stored.transcript.turns),
  };
}

export async function readTranscript(callId, apiKey, fetchImpl = fetch) {
  if (!callId || !apiKey) throw new Error("Call ID and server-side key required");
  const response = await fetchImpl(
    `https://api.pyai.com/v1/omni/calls/${encodeURIComponent(callId)}/transcript`,
    { headers: { Authorization: `Bearer ${apiKey}` },
      signal: AbortSignal.timeout(10000) },
  );
  if (!response.ok) throw new Error(`Transcript HTTP ${response.status}`);
  const document = await response.json();
  if (document.call_id !== callId) throw new Error("Unexpected call ID");
  return transcriptViews(document);
}

export function renderTranscript(root, rows) {
  const doc = root.ownerDocument;
  root.replaceChildren();
  for (const turn of rows) {
    const row = doc.createElement("p");
    const label = doc.createElement("strong");
    label.textContent = turn.role === "user" ? "Caller: "
      : turn.role === "assistant" ? "Assistant: " : "Unknown: ";
    row.append(label, doc.createTextNode(turn.text));
    row.style.whiteSpace = "pre-wrap"; // Keep multiline speech intact.
    root.append(row); // Never use transcript text as HTML.
  }
}
```

Wire `onTranscript: frame => recordEvent(appendEvent, frame)` into your SDK
connection, where `appendEvent` is your bounded, access-controlled event writer.
Store lifecycle/interruption/playback receipts alongside those events with their
original identities. Ensure asynchronous writes are drained and errors handled
before closing your event writer. Do not register both `onEvent` and
`onTranscript` as duplicate transcript writers.

After the call, use the same module on your backend:

```js theme={null}
import { readTranscript } from "./transcript-pipeline.mjs";

const views = await readTranscript(callId, process.env.PYAI_API_KEY);
await transcriptStore.put(callId, views.stored);
const analysis = await analyzeConversation(views.analysisInput);
await analysisStore.put(callId, analysis); // Never overwrite transcriptStore.
// Return views.uiRows through your authenticated app API. In your browser:
// renderTranscript(document.querySelector("#transcript"), rows);
```

`transcriptStore`, `analysisStore`, `analyzeConversation`, and your event writer
are application adapters. Send the analyzer structured turns as data, preserving
roles and order; quoted text is not an instruction or a speaker separator.
Protect transcript storage with your tenant access and retention policies.
A `404` can mean the transcript has not arrived yet or no transcript exists;
retry only under your application's bounded completion policy. Never fall back
to guessed speakers, or overwrite an existing structured record with a summary.

The currently returned `messages` document can contain only `role` and `text`
per turn. **Turn IDs and timestamps are not guaranteed.** Preserve them when
provided; do not fabricate provider IDs. If your database needs row keys, use
application-owned keys scoped to a saved snapshot and array position, clearly
separate from provider IDs. Do not deduplicate identical text: repeated words or
consecutive same-speaker turns can be real speech.

## Interrupted speech is a separate dimension

On `flush` or `barge_in`, clear queued playback as described in the protocol.
Preserve the text's speaker and the interruption receipt independently. Neither
an assistant `final: true` event nor a stored assistant turn proves full playback.
Only associate confirmed playback with text when your transport supplies a
reliable matching identity and progress. Without that evidence, mark delivery
unknown; do not infer that the entire stored greeting was heard, or arbitrarily
trim it using punctuation.

## Regression fixtures to keep

Use synthetic conversations in integration tests, including:

* Two consecutive caller turns, followed by two consecutive assistant turns.
* Multiline text and several sentences inside a single turn.
* Literal quoted `Agent:` and `User:` text, including HTML-like characters.
* Repeated identical utterances: preserve both turns and their order.
* Partial caller deltas and a cancelled assistant reply: no false finality or
  playback claim, no speaker reassignment.
* Optional IDs/timestamps preserved when present; absent fields left absent.
* Missing or unrecognized roles rejected for review, never guessed.

Copy this synthetic fixture into your tests. The expected output is **six turns
with unchanged roles, order, and text**. The two `Yes.` turns remain separate;
quoted labels inside text remain text.

```json transcript-fixture.json theme={null}
{
  "object": "omni.call.transcript",
  "call_id": "ocl_example",
  "transcript": {
    "format": "messages",
    "turns": [
      {
        "role": "user",
        "text": "My heating stopped. Can you help?"
      },
      {
        "role": "user",
        "text": "It is for my house.\nThe address is on the appointment."
      },
      {
        "role": "assistant",
        "text": "I can help with the heating."
      },
      {
        "role": "assistant",
        "text": "The form says \"Agent: <name>\" and \"User: <name>\"."
      },
      {
        "role": "user",
        "text": "Yes."
      },
      {
        "role": "user",
        "text": "Yes."
      }
    ]
  }
}
```

Our documentation checks execute the published module against this fixture and
the interruption, missing-role, and optional-metadata cases without network or
model calls.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.