Recording captures both legs of a call as a single stereo file — the caller on the left channel, the agent (AI or human) on the right — keyed on the call’s sid. Because the two speakers land on separate channels, you can run each side through its own transcript or scorer without any speaker-ID model. Once a call ends, the recording is uploaded to your workspace’s object storage and its URL is stamped onto the call’s CDR, where the control plane can hand you a time-limited download link.
Recording is a workspace-level setting today — there is no per-call record: true flag on calls.originate(...), and CallData carries no recording field. Turn recording on once for the workspace (in the voice console / via your operator) and every call on it is recorded; you cannot opt a single call in or out from the SDK. A per-call record toggle is not shipped yet.

How it works

1

Capture (automatic once enabled)

The media plane taps both RTP legs, mixes them into a stereo stream (caller = L, agent = R), and streams it to the recording pipeline. No SDK call is involved.
2

Finalize

On hang-up the pipeline writes a stereo WAV to your workspace’s object storage and stamps recording_url (and the byte size) onto the call’s CDR.
3

Fetch

Read the CDR to discover the recording, then mint a short-lived presigned download URL keyed on the sid.

Find recordings and get a download URL

The recording URL is stored on the CDR, not on the live Call object, so you fetch it through the control-plane API rather than the audio SDK. Two routes do the job: cdr.recordings lists calls that produced a recording, and storage.signedUrl returns a one-hour presigned download link for a given sid. Both are authenticated with your API key.
const API = "https://engine.telequick.dev";
const KEY = process.env.TELEQUICK_CREDENTIALS!;
const ORG = "org_abc";

// Small tRPC caller — the audio SDK talks to these same endpoints under the hood.
async function trpc<T>(path: string, input: unknown): Promise<T> {
  const url = new URL(`/api/trpc/${path}`, API);
  url.searchParams.set("input", JSON.stringify(input));
  const res = await fetch(url, {
    headers: { authorization: `Bearer ${KEY}` },
  });
  if (!res.ok) throw new Error(`${path} ${res.status}`);
  const body = await res.json();
  return body.result.data as T;
}

// 1. list the most recent calls that have a recording
const { rows } = await trpc<{ rows: Array<{ call_id: string; duration: number }> }>(
  "cdr.recordings",
  { orgId: ORG, limit: 20 },
);

// 2. mint a 1-hour download URL for one of them
const { url, expiresIn } = await trpc<{ url: string; expiresIn: number }>(
  "storage.signedUrl",
  { orgId: ORG, callSid: rows[0].call_id },
);

console.log("download for", expiresIn, "s:", url);
The presigned URL expires in one hour — mint it when you’re about to download, not when you list. Requests are tenant-scoped: you can only presign recordings that belong to your orgId, even if you guess another workspace’s sid.

React to a finished recording (native SDK cores)

The native SDK cores (Python, Go, Rust, and the other FFI wrappers) surface a RECORDING_READY call event the moment the file is finalized — it carries recording_url and duration_seconds, so you can process recordings as they complete instead of polling the CDR.
from telequick import parse_call_event

def on_event(evt_bytes):
    evt = parse_call_event(evt_bytes)
    if evt.status == "RECORDING_READY":
        print(evt.call_sid, evt.recording_url, evt.duration_seconds, "s")
        # evt.estimated_mos / evt.jitter_ms / evt.packets_lost are also populated
This event stream is exposed by the native cores. The browser/TypeScript voice SDK has no recording-event surface — use the CDR + storage.signedUrl path above from a browser or Node app.

Analyze: split the channels

The download is a stereo WAV with the caller on the left channel and the agent on the right. Splitting the two gives you a clean per-speaker signal — this channel split is your speaker separation. There is no built-in diarization or voiceprint in the recording path, so run each channel through your own ASR (or any scorer) independently.
// Deinterleave a 16-bit PCM stereo buffer into caller (L) and agent (R).
function splitStereoPcm16(interleaved: Int16Array) {
  const n = interleaved.length / 2;
  const caller = new Int16Array(n);
  const agent = new Int16Array(n);
  for (let i = 0; i < n; i++) {
    caller[i] = interleaved[2 * i];       // L
    agent[i] = interleaved[2 * i + 1];    // R
  }
  return { caller, agent };
}

// const wav = await (await fetch(url)).arrayBuffer();
// const pcm = new Int16Array(wav, 44);   // skip the 44-byte WAV header
// const { caller, agent } = splitStereoPcm16(pcm);
// await myAsr.transcribe(caller);   // what the caller said
// await myAsr.transcribe(agent);    // what the agent said
Speaker separation here comes entirely from the stereo channel split — there is no speaker-ID, diarization, or voiceprint model in the voice path. If both speakers were mixed to mono you could not tell them apart; the two-channel recording is what makes per-speaker analysis reliable.

Pair it with call quality

Every recorded call also has objective quality on its CDR — an ITU-T E-model MOS estimate plus jitter and packet-loss — computed on the media plane. Join that to the recording when you score a call so a low transcript confidence can be attributed to a bad line rather than a bad agent. See MOS, jitter & loss for the fields and how they’re derived.

MOS, jitter & loss

The per-call quality metrics that live alongside the recording URL.

Call traces

Follow a single sid across signalling, media, and agent turns.

Stream audio to ASR

Transcribe live instead of after the fact — feed uplink into your recognizer.

Observability overview

Recordings, CDRs, dashboards, and how they fit together.