Skip to main content
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 surfacePOST /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

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. 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:
  • 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: 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

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.
  • Batchingevents 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: