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

# Conversations overview

> Every call your agent handles is recorded as a conversation. List, filter, inspect, tag, and re-analyse them through these endpoints.

A **conversation** is one voice call between a user and an agent. It owns a transcript (one row per turn), the per-agent-turn retrieval sources that informed each reply, an LLM-generated summary and call-status verdict, free-form tags, and the metadata your billing pipeline needs to attribute cost.

Two surfaces share the resource:

* The **live-call surface** — `POST /v1/agents/{agentId}/conversations` — spins up a LiveKit room and dispatches a runtime worker. This is the existing dispatch endpoint and is unchanged.
* The **history surface** — list, detail, transcript, tags, rerun analysis — covered below.

A separate internal route (`POST /v1/internal/conversations/{id}/turns`) records turns as the call proceeds. It is server-to-server only and is not part of the public surface.

## Lifecycle

```mermaid theme={null}
flowchart LR
  Dispatch([POST /v1/agents/:agentId/conversations]) --> Created[(Conversation created\nstatus = null)]
  Created --> Ingest([POST /v1/internal/conversations/:id/turns])
  Ingest --> InProgress[(Turns accumulate\nstatus = null)]
  InProgress --> Ended[(Runtime ends call\nduration + endedReason set)]
  Ended --> Analyse([Post-call analysis])
  Analyse --> Analysed[(summary, callStatus set\nstatus = successful|failed|unknown)]
  Analysed --> Rerun([POST /v1/conversations/:id/analysis/rerun])
  Rerun --> Analysed
```

A conversation is **created** at dispatch — the row exists before the first user word reaches the runtime. While the call is live, the runtime POSTs turn events to the internal recorder, which upserts one `conversation_turn` row per `turnIndex` (plus zero-to-many `conversation_retrieval_source` rows under it). Once the runtime emits the end-of-call event, the analysis pipeline runs against the now-frozen transcript and writes `summary`, `callStatus`, and the per-call metrics. From there a re-analysis can be queued by the caller via `POST /v1/conversations/:id/analysis/rerun` — it does not erase the existing summary, it overwrites it on completion.

## Identifiers

The `id` on a `Conversation` doubles as the **LiveKit room name** the call ran in — they're allocated together at dispatch and never diverge. Treat it as opaque: it's stable, unique within an organization, and safe to embed in URLs.

| Field            | Mutable | Notes                                                                                                               |
| ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
| `id`             | No      | Same string as the LiveKit room name.                                                                               |
| `agentId`        | No      | Pinned at dispatch.                                                                                                 |
| `agentVersionId` | No      | The immutable version the call was served from. May be `null` for conversations recorded before versioning shipped. |
| `branchId`       | No      | Derived from `agentVersionId` — the response inlines it as a convenience; conversations don't store it directly.    |
| `title`          | Yes     | Filled by analysis; null until then.                                                                                |
| `summary`        | Yes     | Filled by analysis. Overwritten by rerun.                                                                           |
| `callStatus`     | Yes     | Filled by analysis. Overwritten by rerun.                                                                           |
| `tags`           | Yes     | Independent of analysis. Add and remove freely.                                                                     |

A conversation row is never deleted by the API. Tags can be removed; the conversation itself goes away only when its parent agent is deleted.

## Authorization

All public conversation endpoints are **organization-scoped**. They read the requester's active organization from the session and only operate on conversations whose parent agent belongs to it. Cross-tenant lookups return `404 conversation_not_found` — no enumeration distinction between "wrong org" and "doesn't exist." A session without an active organization returns `404 no_active_organization`.

There is no per-conversation ACL beyond the organization gate: any session member of the owning org can read, tag, or rerun analysis on any conversation in the org. Per-member visibility constraints are tracked on the deployments spec, not on conversations.

## Pagination

The list endpoint paginates with an **opaque cursor**, ordered `(startedAt DESC, id DESC)` to break ties deterministically:

```http theme={null}
GET /v1/agents/{agentId}/conversations?limit=25&cursor=<opaque>
```

* `limit` defaults to 20, max 100. Out-of-range values are clamped, not rejected.
* `cursor` is opaque — clients should not parse it. It encodes `startedAt` plus `id` for the last row of the previous page and is stable as long as that conversation isn't deleted.
* The response shape is `{ items: ConversationSummary[]; nextCursor: string | null; hasMore: boolean }`. `hasMore` is equivalent to `nextCursor !== null` today but is surfaced explicitly so clients can branch on a boolean without parsing the cursor; an empty `items` array on a follow-up page is also possible if a filter removes every match between cursors.
* Filters are URL params (`branch_id`, `date_from`, `date_to`, `status`, `direction`, `duration_min_sec`, `duration_max_sec`, `language`, `user_id`, `tag_ids`) and can be combined freely with the cursor. `tag_ids` is AND across the list (a conversation must carry every listed tag id) and accepts the repeated form (`?tag_ids=a&tag_ids=b`) or a single comma-separated value (`?tag_ids=a,b`). `summary_mode=include` embeds the post-call `summary` on each item — leave it off (the default `exclude`) when the table doesn't need the text. Changing the filter set invalidates the cursor — clients should fetch the first page when filters change.

## Departures from the ElevenLabs reference

This endpoint is intentionally "ElevenLabs-familiar" rather than ElevenLabs-compatible — we mirror their filter names and item shape where the choice is good and deviate where it isn't. The deviations:

| ElevenLabs                                              | Ours                                            | Why                                                                                                                                                                     |
| ------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v1/convai/conversations?agent_id=…`               | `GET /v1/agents/{agentId}/conversations`        | Agent identity is path-scoped, not a required query string — REST hierarchy, no enumeration window for omitted scopes.                                                  |
| `call_start_after_unix` / `_before_unix` (UNIX seconds) | `date_from` / `date_to` (ISO-8601)              | Epoch seconds are write-only ergonomics; ISO-8601 is the rest of our surface (`createdAt`, `expiresAt`, …) and copy-pastes directly between curl, logs, and dashboards. |
| `call_duration_min_secs` / `_max_secs`                  | `duration_min_sec` / `duration_max_sec`         | Singular `_sec` matches our existing field names (`durationSeconds` returns one number, not a collection).                                                              |
| `call_successful` enum (`success`/`failure`/`unknown`)  | `status` enum (`successful`/`failed`/`unknown`) | Matches our `conversation_call_status` Postgres enum and the `ConversationStatus` type, so the wire and the schema agree without an extra mapping.                      |
| `next_cursor` / `has_more` (snake)                      | `nextCursor` / `hasMore` (camel)                | Response keys are camelCase across our surface; query params stay snake\_case to match ElevenLabs's input naming.                                                       |
| `main_languages` (repeat)                               | `language` (single)                             | We don't auto-detect transcript language — the value comes from the client overrides at dispatch, so a single-value filter is the honest contract.                      |
| `page_size`                                             | `limit`                                         | Matches our other list endpoints (`/v1/agents`, `/v1/agents/:agentId/branches`, …).                                                                                     |
| `search` (full-text over transcript)                    | `q` (ILIKE on the post-call `summary`)          | The transcript index isn't in this PR-set; `q` searches the LLM-generated summary, which is the only meaningful text column on the row today.                           |

We also skip filters that require schema we haven't shipped yet: `rating_min` / `rating_max` / `has_feedback_comment` (no feedback schema), `evaluation_params` / `data_collection_params` / `tool_names*` / `topic_ids` / `exclude_statuses` / `conversation_initiation_source` (evaluation framework, data collection, tool-use tracking, and topic discovery are out-of-scope per the spec).

## Internal recorder

```http theme={null}
POST /v1/internal/conversations/{id}/turns
Authorization: Bearer <AGENT_RUNTIME_SHARED_SECRET>
Content-Type: application/json

{
  "events": [
    {
      "v": 1,
      "conversationId": "conv_…",
      "turnIndex": 0,
      "role": "user",
      "text": "What's the carrier lane mismatch process?",
      "startedAt": "2026-05-20T15:42:00.000Z",
      "metrics": { "asrMs": 106, "llmMs": null, "ttsMs": null, "ragMs": null },
      "sources": []
    }
  ]
}
```

The recorder exists because the agent runtime does not have a user session — it's a Python (or Node) worker dispatched by LiveKit. The cookie-auth guard the public surface uses would reject every request. Instead:

* **Auth** is the `AGENT_RUNTIME_SHARED_SECRET` env var, presented as `Authorization: Bearer <secret>`. The guard does a constant-time compare and returns `401 invalid_service_token` on mismatch.
* **Network exposure** — the route lives at `/v1/internal/...` and is **not** included in the public OpenAPI surface. Only the agent runtime (deployed alongside the API) should reach it.
* **Idempotency** is on the `(conversationId, turnIndex)` tuple — the table has a unique index on those columns and the service upserts. A worker retrying a batch will re-emit the same `turnIndex` and the API will replace the row in place, not duplicate it. Retrieval sources for that turn are replaced atomically when the turn upserts.
* **Versioning** — every event carries `"v": 1`. Readers reject any version they don't recognise; emitters must not mix versions in one request.
* **Batching** — `events` is an array, intentional. The runtime may stream one event at a time or batch the entire call's turns at end-of-call. The whole batch is wrapped in a single Postgres transaction: if any event in the array is rejected (unknown version, mismatched conversation id, …), every prior turn upsert in the same request is rolled back too. Retry the whole batch — partial replay is not supported by this contract.
* **Final envelope** — set `"final": true` (with optional `durationSeconds`, `callStatus`, `endedReason`, `summary`, `messageCount`) to commit end-of-call metrics in the same transaction as the last turn batch. The cached `messageCount` is always recomputed from `COUNT(*)` over `conversation_turn`; the runtime-reported value is advisory.

The recorder responds with `204 No Content` once the transaction commits. It does **not** trigger analysis — analysis runs on the end-of-call event the runtime fires separately.

## Endpoint summary

Per-endpoint pages auto-generate from the [OpenAPI spec](/api-reference/introduction):

| Method   | Path                                    | Purpose                                                   |
| -------- | --------------------------------------- | --------------------------------------------------------- |
| `POST`   | `/v1/agents/{agentId}/conversations`    | Start a call (dispatch + LiveKit room).                   |
| `GET`    | `/v1/agents/{agentId}/conversations`    | List conversations on an agent, with filters + cursor.    |
| `GET`    | `/v1/conversations/{id}`                | Conversation detail (Overview + Client-data tab payload). |
| `GET`    | `/v1/conversations/{id}/transcript`     | Transcript (turns + per-turn retrieval sources).          |
| `GET`    | `/v1/conversations/tags`                | List conversation tags used across the org.               |
| `POST`   | `/v1/conversations/{id}/tags`           | Add a tag to a conversation.                              |
| `DELETE` | `/v1/conversations/{id}/tags/{tagId}`   | Remove a tag from a conversation.                         |
| `DELETE` | `/v1/conversations/tags?label=…`        | Delete a tag from every conversation in the org.          |
| `POST`   | `/v1/conversations/{id}/analysis/rerun` | Queue a re-analysis of the conversation.                  |

## Related guides

* [Conversations operate guide](/agents/operate/conversations) — what the dashboard renders and how teams use it.
* [Branches](/api-reference/branches/overview) — how conversations associate with branches and versions.
