Streaming text and stored turns have different meanings
Read the wire protocol 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. Keep its audio, configuration, tool, and interruption handlers. It makes no new call or recording. Run authenticated transcript retrieval on your server with anomni: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.
transcript-pipeline.mjs
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:
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
Onflush 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:andUser: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.
Yes. turns remain separate;
quoted labels inside text remain text.
transcript-fixture.json