# Recordings in the console

> How the recordings list is assembled from CDR rows and storage objects, why some objects are hidden, and how playback URLs are presigned.

The recordings screen lists recorded calls for the active org and plays them
back in the browser. It is read-only: nothing on this screen starts, stops,
or deletes a recording.

The list does **not** come from a single table. One `cdr.recordings` procedure
merges two sources — the `cdrs` table in ClickHouse and an object listing of
the recording bucket — de-duplicates them by call id, then filters, sorts, and
pages the merged set server-side.

## What the list is built from

`cdr.recordings` runs two lookups in parallel for the requested org:

| Source | Query | Contributes |
| ------ | ----- | ----------- |
| CDR rows | `cdrs` where `tenant_id` matches the org and `recording_url != ''`, grouped by `call_id` | `call_id`, `starting_time`, `duration`, `caller`, `callee`, `direction`, `agent`, `recording_url`, `recording_size_bytes` |
| Storage objects | An object listing of the recording bucket for the org | `call_sid` (from the object key), `lastModified`, `sizeBytes`, and the object's `source` |

Because a call can appear as several `cdrs` rows, the CDR side collapses them
per `call_id`: `max(starting_time)`, `max(duration)`, `max(recording_size_bytes)`,
and `argMax(..., starting_time)` for `caller`, `callee`, `direction`, `agent`,
and `recording_url`. You see one row per call, built from its latest CDR write.

For every object that the listing returned, the procedure then looks the call id
back up in `cdrs` for the same tenant to borrow call metadata. The merged map is
keyed by call id, and the storage-derived entry is written last — so when a call
exists in both sources, the row you see carries:

- `recording_url` rewritten to `s3://<managed-bucket>/<object-key>`
- `recording_size_bytes` taken from the object listing
- everything else (participants, start time, duration, direction, agent) from
  the CDR lookup

This is why a row can show a managed-bucket URI even though the CDR recorded a
different destination. It is also how a recording that exists in storage but has
no CDR row at all still reaches the list — as long as it can be attributed to
your tenant.

## Tenant attribution and why some objects never appear

Objects from the listing carry a `source`. Objects whose `source` is `shared`
live in a bucket that is not exclusive to one tenant, so the object key alone is
not proof of ownership. For those, the CDR lookup is the attribution check:

- **Shared-bucket object with a matching `cdrs` row for this tenant** — the row
  is included, using the CDR metadata.
- **Shared-bucket object with no `cdrs` row for this tenant** — the row is
  **dropped**. It is not rendered greyed out, and it is not counted in `total`.
  TeleQuick would otherwise have no way to prove the audio belongs to the org
  asking for it, so the object is treated as invisible rather than exposed
  cross-tenant.
- **Object from a non-shared source** — kept even with no CDR row, because the
  listing itself is already scoped to the org.

The practical consequence: if you know a recording exists in a shared bucket but
it never shows up on this screen, the missing piece is the CDR row, not the
audio. Check that the call wrote a `cdrs` row under this tenant with a matching
`call_id`.

## Reading a row with missing metadata

A storage-discovered row with no CDR metadata is populated from the object alone.
Those rows look incomplete on purpose:

| Column | Value when there is no CDR row |
| ------ | ------------------------------ |
| Call ID | The call sid parsed from the object key |
| Timestamp | The object's `lastModified` — a **storage** time, not the call start |
| Participants | Empty, rendered as `—` |
| Duration | Empty, rendered as `—` |
| Direction | Empty; the admin table's direction chip has nothing to show |
| Size | The object size from the listing |

So a row whose timestamp is minutes or hours after you expect is showing when
the object was last written to the bucket. Treat the timestamp column as a call
start time only for rows that have participants and a duration.

One side effect matters for filtering: a row with no `starting_time` at all is
treated as timestamp `0` by the range predicate, which puts it before any
`fromMs` you set. Such a row only appears when the time range has no lower
bound.

## Filtering, search, and paging

All filtering happens on the server, over the merged set, before paging. The
screens send filter state as query input and re-render whatever comes back —
they do not post-filter rows in the browser.

| Input | Applied as |
| ----- | ---------- |
| `fromMs` / `toMs` | Inclusive comparison against the row's `starting_time` in milliseconds |
| `direction` | Exact match on the row's `direction` (`inbound` or `outbound`) |
| `search` | Case-insensitive substring across `call_id`, `caller`, and `callee` joined together |
| `agent` | Case-insensitive substring on the CDR agent attribution |
| `limit` | Page size. Minimum 1, maximum 500, default 200 |
| `offset` | Rows to skip. Minimum 0, default 0 |

The surviving rows are sorted newest first by `starting_time`, then sliced by
`offset`/`limit`. The response carries:

- `rows` — the current page
- `total` — the count of rows that passed the filters, which is what drives the
  pager and the "N recordings in range" subtitle
- `stats` — `total_recordings`, `total_duration`, `total_bytes`, and
  `avg_duration` computed across the whole filtered set, not just the page

Because `total` is post-filter, widening the date range or clearing the search
box changes the page count as well as the rows.

The two screens drive the same procedure differently:

| | Admin recordings | Voice AI recordings |
| - | ---------------- | ------------------- |
| Page size | 50 | 25 |
| Time range | Date-range picker with relative tokens (`now-7d` → `now`), rounded to the minute so the query key stays stable while "now" advances | Preset select: Today, 7 days, 30 days, All, pinned to the preset rather than to render time |
| Direction filter | All / inbound / outbound chips | Not sent |
| Search | Text box, debounced 300 ms, resets to page 1 | Not sent |
| Auto-refresh | Refetches every 60 s | No interval; keeps the previous page's data while the next loads |

Changing any filter resets the pager to the first page on both screens.

## Filters the API supports but these screens do not send

`cdr.recordings` accepts an `agent` input — a case-insensitive substring match
on the CDR agent attribution. Neither recordings screen exposes a control for
it, so it is always unset from the console. Call the procedure directly if you
need to narrow recordings to one agent.

The Voice AI screen additionally leaves `direction` and `search` unset. If you
need those, use the admin recordings screen or the procedure.

## Playback and presigned download URLs

Nothing is fetched until you ask for it. Each row starts with a **Load player**
/ **Play** button; pressing it resolves that row's `recording_url` into
something the browser can play:

- A URL that already starts with `http://` or `https://` (a bring-your-own
  destination) is used as-is.
- An `s3://` URI (the managed bucket) is exchanged for a presigned GET by
  calling `storage.signedUrl({ orgId, callSid })`. The procedure returns a
  `url`, and that URL is valid for one hour.

Playback is a native `<audio controls>` element pointed at the resolved URL.
There is no HLS, no streaming session, and no request-header auth — the
presigned URL is the credential.

Resolved URLs are kept only in component state for the life of the screen. They
are never persisted, because they expire. Reload the page and the row goes back
to its unresolved state; press the button again to mint a fresh URL.

Download works the same way. The admin screen resolves the URL, then triggers a
browser download named `<call_id>.wav`. The Voice AI screen reuses the URL that
playback already resolved as a plain download link, so it only offers download
after you have loaded the player.

Two things follow from the one-hour lifetime:

- A download that starts near the end of the window can fail partway. Re-press
  the button to get a new URL.
- A presigned URL you copy out of the page is a bearer credential for that
  recording until it expires. Treat it accordingly.

## Enabling recording for a workspace

The admin recordings screen is gated on the `features.recording` entitlement
flag for the active org. The gate is deliberately fail-open:

| Entitlement state | Screen behaviour |
| ----------------- | ---------------- |
| Still loading (`undefined`) | The list renders normally |
| Entitlement service returns no claims (`null`) | Treated as "unconfigured" and allowed through — the list renders |
| Claims present and `features.recording === false` | The list is replaced by an upsell panel: "Recordings aren't on your plan" |
| Claims present and the flag is anything else | The list renders |

To turn recording on for a workspace, move the org to a plan whose entitlement
claims include `features.recording`. The flag controls this screen; it is not a
per-call switch, and the screen itself has no toggle. Once the flag is set,
recorded inbound and outbound calls appear here as the CDR pipeline and the
bucket listing pick them up.

The Voice AI recordings screen does not check the entitlement. It will render an
empty list rather than an upsell panel for an org without the flag.

## Troubleshooting an empty or short list

| Symptom | Likely cause |
| ------- | ------------ |
| Upsell panel instead of a list | `features.recording` is explicitly `false` in the org's entitlement claims |
| Empty list with no filters set | No `cdrs` rows with a non-empty `recording_url` for this tenant, and no attributable objects in the bucket listing |
| A known recording is missing | It is a shared-bucket object with no `cdrs` row for this tenant, so it was dropped during attribution |
| Row has a timestamp but no participants or duration | Storage-discovered row with no CDR metadata — the timestamp is the object's last-modified time |
| Row disappears when you narrow the date range | Its `starting_time` is empty, so the range predicate treats it as timestamp `0` |
| "recordings are temporarily unavailable" banner | The query errored; the message shown is the procedure's error text |
| Player loads but audio 403s later | The presigned URL has passed its one-hour validity; press the button again |

## Related

- [Telemetry](/platform/telemetry) — the `recording_url` column on the CDR schema
- [Authentication](/concepts/authentication) — API keys and short-lived tokens
