Conversations API
Multi-channel conversation threads and threading state
Base path: /api/v1/conversations
Endpoint count: 26
List all conversations
GET /api/v1/conversations/
Unified omnichannel inbox view across SMS, WhatsApp, email, Instagram, Messenger, RCS, Viber, and LINE. Each conversation represents a unique contact + channel pair.
Sort field (prefix with - for DESC)
channel
string (enum: sms|whatsapp|email|rcs|viber|instagram|…)
—
status
string (enum: open|closed|pending|snoozed|active|archived|…)
—
Filter by assigned agent ID (legacy)
Search by contact name, phone, or email
Filter by tag (legacy, single)
Comma-separated list of channels
Comma-separated list of statuses (open,closed,pending,snoozed,…)
Comma-separated tag list (ANY match)
“me”, “unassigned”, or a user id
unread
string (enum: true|false)
—
has_agent
string (enum: true|false)
—
has_video_room
string (enum: true|false)
—
sentiment
string (enum: positive|neutral|negative)
—
mentioned_internal
string (enum: true|false)
—
JSON-encoded full filter AST (takes precedence)
Get a conversation
GET /api/v1/conversations/{id}
Returns details about a specific conversation including contact info, channel, status, and assignment.
Conversation activity feed
GET /api/v1/conversations/{id}/activity
Operator state-changes — assignments, status transitions (close/reopen/snooze), tag updates, merges, handoff actions, bulk operations. Cursor-paginated newest-first. Does NOT include chat messages (use /:id/messages) or internal notes (use /inbox/:id/internal-notes).
Read agent_active state for a conversation
GET /api/v1/conversations/{id}/agent/active
AGT-017: Returns . The agent-runtime calls this before running the executor to check if the conversation is paused for human-in-the-loop handling. Fails open (agent_active=true) on DB error.
Get the full handoff packet for an in-flight AI conversation
GET /api/v1/conversations/{id}/handoff/packet
Read-only operator-facing DTO that bundles everything the human taking over needs: executive summary, last-inbound sentiment, full tool-call timeline (what the bot tried), KB articles consulted, explicit open questions the bot is uncertain about, a suggested first reply, and the per-turn confidence trajectory. Derived from existing data — no schema change. Fails open on every slot (missing LLM, missing tool-call rows, missing messages all degrade individual fields to null/[], never 5xx the endpoint).
Get current queue position for an in-flight handoff
GET /api/v1/conversations/{id}/handoff/queue-position
Returns when the conversation is currently in the org’s handoff queue (Redis sorted set inbox_queue:<orgId>). Returns null position when the conversation has been picked up, was never enqueued, or Redis is unavailable. The customer-facing chat widget polls this endpoint to render ‘You are #N in queue, est wait Mm’.
List linked conversations
GET /api/v1/conversations/{id}/links
Return every conversation linked to this one along with a denormalised preview (channel, status, last_message_at, contact_name, last_message) and the link metadata (id, type, note, created_by, created_at). Directional link types are surfaced from the queried conversation’s perspective — e.g. if A is stored as parent_of B, then GET /A/links returns parent_of and GET /B/links returns child_of for the same row.
Get conversation messages
GET /api/v1/conversations/{id}/messages
Full thread of inbound + outbound messages for a conversation, paginated with cursor.
Per-message sentiment timeline
GET /api/v1/conversations/{id}/sentiment-timeline
Chronologically-ordered (oldest → newest) sentiment trajectory across the messages in this conversation. Returns [{message_id, ts, sentiment_score, sentiment_label}] for every analyzed message (sentiment_score IS NOT NULL). The FE renders this as a sparkline strip at the top of the conversation detail with per-message hover. Unanalyzed messages are skipped; scheduler-sentiment-backfill (5-min cadence in webhook-worker) populates the columns for messages with NULL sentiment_score. Empty array when no messages have been analyzed yet — FE renders an Analyzing… empty state.
GET /api/v1/conversations/tags/distinct
Returns the complete tag vocabulary in use across the tenant’s conversations table, with usage counts. Used by the inbox UI to populate the tag-chip rail and the advanced-filter tag dropdown. Tenant-scoped; caps at 200 tags; results cached server-side for 60s and invalidated on every tag update.
Resume AI agent after human-in-the-loop handoff
POST /api/v1/conversations/{id}/agent/resume
AGT-017: Flips agent_active=TRUE so the AI agent processes the next inbound message. Called from the inbox ‘Resume AI’ button when the conversation’s agent_active is false. Idempotent — already-active conversations return 200 with already_active=true.
Assign conversation
POST /api/v1/conversations/{id}/assign
Assign a conversation to a team member by their user ID.
Close conversation
POST /api/v1/conversations/{id}/close
Close/resolve a conversation. Closed conversations can be reopened.
Agent Copilot — suggest replies + next-best-actions
POST /api/v1/conversations/{id}/copilot/suggest
Returns up to 3 suggested replies and up to 2 next-best-action recommendations for the human agent currently working the conversation. Reads the recent message thread, asks the platform LLM for a structured response. Optional lookback (default 25, max 50) controls how many recent messages are fed to the model.
How many recent messages to feed the LLM. Defaults to 25.
Escalate conversation
POST /api/v1/conversations/{id}/escalate
Mark a conversation as escalated (stamps escalated_at + metadata.escalation), fan out a Slack notification + email to the org’s configured escalation recipients, write an audit row, and emit an inbox SSE event. Idempotent — re-escalating an already-escalated conversation returns the existing escalated_at without re-fanning out the notifications. Recipients resolve from organizations.settings.escalation.user_ids + .email_recipients (or the per-request target_user_ids override). notify_channels lets callers opt out of slack-only or email-only fan-out (default: both).
Free-text reason — surfaced in Slack + email body and stored on metadata.escalation.reason.
priority
string (enum: normal|high|urgent)
Escalation priority. Default ‘urgent’. Drives Slack button styling (danger vs primary) and email subject prefix.
Optional caller override of recipients. Each id is validated against the caller’s organizationId so a malicious or buggy caller cannot leak the notification to another tenant’s users. When omitted, falls back to organizations.settings.escalation.user_ids.
notify_channels
string (enum: slack|email)[]
Opt-out lever for individual notification channels. Default [‘slack’,‘email’] (both). Useful for tests / programmatic callers that only want one fan-out path.
Handoff conversation from AI to a human
POST /api/v1/conversations/{id}/handoff
Distinct from /assign: clears any AI agent assignment, flips status to pending so the conversation re-enters the unassigned queue, and stamps metadata.needs_human + handoff_reason + target_queue + handoff_skill_tags + handoff_matched_operators so the receiving human picks up the context. Audit #HANDOFF-1 also persists a top-level handoff_brief (LLM-generated 2-3 sentence summary) and handoff_reason_category (machine-readable enum) on the conversation row, and audit #HANDOFF-2 enqueues the conversation in the org’s Redis sorted-set queue so the customer-facing widget can render queue position.
Short reason — surfaced to the receiving human (e.g. ‘asked for a manager’).
Optional queue to route to. Defaults to the tenant’s default queue.
reason_category
string (enum: cant_understand|policy_block|customer_request|tool_failed|escalation_threshold|manual)
Machine-readable handoff category for analytics. When omitted, inferred from the free-text reason.
Caller override for the skill set used to match operators. When omitted, derived from the conversation’s most recent AI agent’s skill_tags.
Caller-supplied 2-3 sentence summary. When omitted, the service generates one via the LLM gateway.
Resolve AI handoff
POST /api/v1/conversations/{id}/handoff/resolve
Clear the AI-handoff flags (needs_human, handoff_reason, target_queue) from a conversation’s metadata once a human has engaged. Stamps handoff_resolved_at so the audit trail keeps a record.
POST /api/v1/conversations/{id}/links
Create a soft, reversible cross-reference between this conversation and another without collapsing either timeline (distinct from merge). The link surfaces in both threads’ sidebars and can be removed at any time via DELETE /:id/links/:linkId. Supported link_type values: relates_to (default, symmetric), duplicate_of (symmetric), blocks/blocked_by (directional inverses), parent_of/child_of (directional inverses). Directional types respect the operator’s submitted order from the perspective of /:id.
link_type
string (enum: relates_to|duplicate_of|blocks|blocked_by|parent_of|child_of)
—
Merge conversations
POST /api/v1/conversations/{id}/merge
Fold one or more sibling conversation rows into this one. Source rows must belong to the same contact (matching contact_id, phone, or email). Sources are archived with metadata.merged_into pointing at the primary; the primary’s channels[] is unioned so the existing thread query loads messages from every channel.
Reopen conversation
POST /api/v1/conversations/{id}/reopen
Reopen a closed conversation so agents can continue the thread.
Reply in a conversation
POST /api/v1/conversations/{id}/reply
Send a message back to the contact on the same channel. Delegates to the existing messaging router.
Snooze conversation
POST /api/v1/conversations/{id}/snooze
Hide a conversation from the open inbox until the supplied ISO 8601 timestamp. The webhook-worker auto-reopens the row once the deadline passes.
Absolute ISO 8601 timestamp (must be in the future)
Optional human-readable label e.g. ‘wait for invoice approval’
Undo a recent conversation merge
POST /api/v1/conversations/{id}/unmerge
Reverse a recent mergeConversations call within a 30-minute window. Restores every source row archived into this primary using the pre_merge_snapshot stamped at merge-time; recomputes the primary’s channels[] from pre_merge_channels minus any sources still merged. Mirrors the contact-merge undo gate in /contacts/unmerge/:mergeId. Returns — expired counts sources outside the 30-min window so the UI can show ‘contact support for a manual revert’ when the window has fully expired.
Bulk action
POST /api/v1/conversations/bulk
Apply the same action (close / reopen / assign / add_tags / remove_tags) to up to 200 conversations in a single request. Returns per-id success/failure so the UI can surface partial-success outcomes.
action
string (enum: close|reopen|assign|add_tags|remove_tags)
required
—
Required for action=assign. Null to unassign.
Required for action=add_tags / remove_tags.
PUT /api/v1/conversations/{id}/tags
Set the tags array on a conversation. Tags are normalized to lowercase, trimmed, max 20 chars each, max 10 per conversation.
DELETE /api/v1/conversations/{id}/links/{linkId}
Remove a conversation link by its link id (NOT by the other conversation’s id — the link id is returned from POST /:id/links and from GET /:id/links). Returns 404 when the link id doesn’t exist in this tenant so the FE can distinguish ‘no longer exists’ from ‘successfully removed’.