Conversations
A conversation is an ordered thread of activities on one channel, optionally tied to an external participant. It owns the per-conversation sequence counter (last_seq) and a lifecycle status that decides whether new activities are accepted.
Source: lib/converger/conversations/conversation.ex, lib/converger/conversations.ex.
Schema
Table conversations:
| Field | Type | Default | Notes |
|---|---|---|---|
id | uuid | Primary key. | |
tenant_id | uuid | Owner. | |
channel_id | uuid | The channel the conversation lives on. Its activities are delivered to this channel (when it can send) and to its routing rule targets. | |
origin_channel_id | uuid, nullable | channel_id | The channel the conversation started on, set on insert, never cast. Replies written from other channels go back to it (reply-back routing). NULL only on rows the migration has not backfilled yet, which are treated as channel_id. |
participant_id | uuid, nullable | The external party, set by inbound participant resolution. Never cast from client input. Cleared if the participant is deleted. | |
status | text | "active" | active or closed, validated by the changeset. |
last_seq | bigint | 0 | Highest activity seq handed out. Server-managed. |
metadata | map | {} | Free-form. Converger sets {"source": "converger"} for client-API conversations and {"source": "inbound_webhook"} for conversations created by inbound messages. |
inserted_at, updated_at | utc_datetime_usec | updated_at is bumped on every activity insert and status change, so it is the last-activity time. |
Indexes: (tenant_id), (channel_id), (participant_id, status), (status, updated_at) for expiry, and the keyset pagination indexes (ADR-0018).
The REST representation (ConvergerWeb.ConversationJSON) is:
{
"data": {
"id": "5b0c8f2e-3c1d-4a8e-9f3a-1d2e3f4a5b6c",
"status": "active",
"metadata": { "source": "inbound_webhook" },
"channel_id": "8d1e2f3a-...",
"origin_channel_id": "8d1e2f3a-...",
"tenant_id": "3f2a1b0c-...",
"participant_id": "c4d5e6f7-...",
"participant": { "id": "c4d5e6f7-...", "external_id": "905551112233", "display_name": "Ayse" },
"inserted_at": "2026-10-09T10:00:00.000000Z",
"updated_at": "2026-10-09T10:15:02.733914Z"
}
}
participant is null when the conversation has none, or when it was not preloaded (the close and reopen responses do not preload it).
GET /api/v1/conversations/:id also returns channels, the channels the conversation spans (below):
"channels": [
{ "channel_id": "8d1e2f3a-...", "role": "origin", "participant_id": "c4d5e6f7-...", "inserted_at": "2026-10-09T10:00:00.000000Z" },
{ "channel_id": "4b7c9d0e-...", "role": "target", "participant_id": null, "inserted_at": "2026-10-09T10:00:00.120000Z" }
]
Channels of a conversation
A conversation starts on one channel but can span several: the routing rules deliver it to target channels, and
an agent console or another channel can write into it. Table conversation_channels records them, one row per
(conversation_id, channel_id) (ADR-0046):
| Field | Notes |
|---|---|
conversation_id, channel_id, tenant_id | Deleting the conversation, the channel or the tenant deletes the row. |
role | origin: the channel the conversation started on, written when the conversation is created. target: a channel the pipeline delivered one of its activities to. member: a channel an activity was written from that is neither. A channel keeps the role it got first. |
participant_id | The conversation's participant on the origin row; NULL otherwise. Cleared if the participant is deleted. |
inserted_at, updated_at | When the channel joined. |
Every activity also records the channel it came in on (activities.source_channel_id; NULL for the tenant API,
system events and activities stored before this column existed). The pipeline uses it to tell an inbound message
from a reply: an activity from a channel other than the origin goes back to the origin channel and its participant
only, unless a routing rule of the replying channel says otherwise (Routing rules).
The migration backfills origin_channel_id and an origin row for every existing conversation.
Lifecycle
A conversation has exactly two statuses. There is no separate "expired" status: expiry closes the conversation with reason "expired".
| Status | Meaning | Accepts activities |
|---|---|---|
active | Open. Conversations.open_status/0. | yes |
closed | Closed manually or by inactivity. Conversations.closed_status/0. | no (except the server's own lifecycle event) |
Close and reopen
| Operation | Tenant API (x-api-key) | Client API (bearer token) |
|---|---|---|
| Close | POST /api/v1/conversations/:id/close | POST /api/v1/converger/conversations/:id/close |
| Reopen | POST /api/v1/conversations/:id/reopen | POST /api/v1/converger/conversations/:id/reopen |
The tenant API returns the conversation ({"data": {...}}). The client API returns {"conversationId": "...", "status": "closed"}. The tenant portal (/portal/conversations/:id) has Close and Reopen buttons too.
close_conversation/2 and reopen_conversation/2 run one conditional UPDATE ... WHERE id = $1 AND status = <from>:
- Idempotent. Closing a closed conversation (or reopening an open one) changes nothing, emits nothing, and returns the conversation as it is.
- Serialized with activity inserts. The update takes the conversation's row lock, the same lock that allocates
seq. An activity either commits before the close, or is rejected after it. Nothing slips in between (ADR-0017). - Announced in-band. A successful transition emits a
conversationUpdateactivity with sender"system":
{
"type": "conversationUpdate",
"sender": "system",
"text": null,
"metadata": {
"event": "conversation_closed",
"status": "closed",
"reason": "manual"
}
}
event is conversation_closed or conversation_reopened. reason is "manual" by default (the :reason option), and "expired" for expiry. The close event is created with allow_closed: true, so it is always the last activity of a closed conversation. It is broadcast to WebSocket clients like any activity. Of the external channels, only webhook channels receive lifecycle events. Messaging adapters (WhatsApp, echo) skip them, so they never send an empty message.
Expiry
Converger.Workers.ConversationExpirationWorker runs hourly (Oban cron 0 * * * *) and calls Conversations.expire_inactive_conversations/1. It closes every active conversation whose updated_at is older than the inactivity window, and emits a conversationUpdate with reason "expired" for each one.
| Setting | Default | Description |
|---|---|---|
config :converger, :conversation_inactivity_hours | 24 | Inactivity window in hours. |
job arg "inactivity_hours" | Overrides the window for one job run, for example a manually inserted job. |
The worker closes conversations in batches of 500 using the (status, updated_at) index. The outer UPDATE re-checks status and updated_at under the row lock, so a conversation that received an activity after the batch was selected is not closed. Migration 20261009170000_add_conversation_lifecycle_index moved updated_at forward to the latest activity for existing rows, so the first run after deploy did not close conversations that were still in use.
Rejection of activities on closed conversations
The status check happens in the statement that allocates seq:
UPDATE conversations SET last_seq = last_seq + 1, updated_at = now()
WHERE id = $1 AND status = 'active'
RETURNING last_seq
When no row is updated and the conversation exists, Activities.create_activity/2 returns {:error, :conversation_closed} and the whole transaction rolls back. Clients see:
| Path | Result |
|---|---|
REST (/api/v1, /api/v1/converger) | 409 conversation_closed, message "Conversation is closed" (errors) |
WebSocket postActivity push (Converger API socket) | error reply with reason conversation_closed |
WebSocket new_activity push (legacy socket, deprecated) | error reply with reason conversation_closed |
| Echo adapter replying into a conversation closed meanwhile | the reply is dropped, and the delivery counts as successful |
| Inbound webhook | never reaches a closed conversation through participant resolution (see below). A request with an explicit conversation_id of a closed conversation stops with 409. |
Use Conversations.open?/1 or ensure_open/1 to check the status before expensive work such as uploads.
Creating conversations
| Path | Credentials | Channel |
|---|---|---|
POST /api/v1/conversations (deprecated) | x-channel-token (channel token, shown in the admin channel table, valid 1 h; deprecated, see migrating from the legacy surfaces) | The token's channel. It must be active. The body may carry metadata. |
POST /api/v1/converger/conversations | bearer Converger token | The token's channel. Returns a conversation-scoped token and streamUrl. |
| Inbound webhook | channel signature | Resolved automatically (see below). |
How inbound messages find their conversation
Providers such as WhatsApp never send a Converger conversation id. ConvergerWeb.InboundController resolves the conversation for each inbound message in this order (issue #16, ADR-0016):
- Explicit id. If the request carries
conversation_id, that conversation is used (scoped to the channel's tenant,404if unknown). - Participant. If the adapter parsed a participant (
"participant": {"external_id": ..., "display_name": ...}),Participants.resolve_conversation/2upserts the participant(channel_id, external_id)and reuses its most recentactiveconversation on the channel, unless that conversation has been idle longer than the channel's idle timeout. Otherwise it creates a new conversation for the participant. - Neither. A new conversation without a participant is created.
Closed (manually or by expiry) conversations are never reused for inbound messages: the next message from that participant starts a new conversation. The participant upsert locks the participant row until the transaction commits, so concurrent messages from one participant end up in one conversation. See participants.
Before resolution, a message whose provider id (idempotency_key) already exists in any conversation of the channel is treated as a duplicate and skipped (ADR-0015).
Listing
GET /api/v1/conversations (tenant API key only) returns the tenant's conversations, newest first, keyset-paginated on (inserted_at, id), with the participant preloaded.
| Query param | Description |
|---|---|
limit | Page size. Default 50, maximum 500 (PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT). |
cursor | meta.next_cursor from the previous page. An invalid cursor returns 400 bad_request with the message "Invalid cursor". |
status | active or closed. |
channel_id | UUID (400 if malformed). |
external_id | The participant's provider id, for example a phone number. |
The response is {"data": [...], "meta": {"next_cursor": "...", "has_more": true, "limit": 50}}.