Skip to main content

Conversations API

Multi-channel conversation threads and threading state Base path: /api/v1/conversations Endpoint count: 38

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.
string
Pagination cursor
integer
string
Sort field (prefix with - for DESC)
string (enum: sms|whatsapp|email|rcs|viber|instagram|…)
string (enum: open|closed|pending|snoozed|active|archived|…)
string
Filter by assigned agent ID (legacy)
Search by contact name, phone, or email
string
Filter by tag (legacy, single)
string
Comma-separated list of channels
string
Comma-separated list of statuses (open,closed,pending,snoozed,…)
string
Comma-separated tag list (ANY match)
string
“me”, “unassigned”, or a user id
string
string
string
string (enum: true|false)
string (enum: true|false)
string (enum: true|false)
string (enum: positive|neutral|negative)
string (enum: true|false)
string
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.
string
required

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).
string
required
string
integer

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.
string
required

Get live cost breakdown for one conversation

GET /api/v1/conversations/{id}/cost
Returns the running LLM + tool-call cost for a single conversation, aggregated live from public.llm_cost_events (tenant-scoped). Includes per-model and per-agent breakdown plus a soft-budget alert evaluation against organizations.settings.agent_defaults.soft_cost_per_conversation_usd. Distinct from the hard per-conversation cost cap in apps/agent-runtime’s Redis meter — this is the READ side for the inbox detail panel and never blocks anything. Returns zero totals (never errors) on missing ledger rows, missing soft cap, or DB blip so the FE chip degrades to a hyphen rather than 5xx-ing the panel.
string
required

List conversation followers

GET /api/v1/conversations/{id}/followers
Return every follower of this conversation with a denormalised user preview (name, email, image) joined from public.users so the FE Followers pill renders without an N+1 user-fetch loop. Newest-first by created_at so the operator who just clicked ‘Follow’ sees themselves at the top. Stale rows whose user has been purged surface with user_name=null so the FE can render an ‘(unknown user)’ placeholder rather than dropping the row.
string
required

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).
string
required

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’.
string
required

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.
string
required

Get conversation messages

GET /api/v1/conversations/{id}/messages
Full thread of inbound + outbound messages for a conversation, paginated with cursor.
string
required
string
integer

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.
string
required

List side conversations for a parent

GET /api/v1/conversations/{id}/side-conversations
Returns every side conversation attached to the parent conversation, newest-first. Capped at 200 — operators rarely accumulate more side threads than that on a single customer conversation.
string
required

Get side-conversation message thread

GET /api/v1/conversations/{id}/side-conversations/{sideId}/messages
Returns the full oldest-first message thread for one side conversation. Capped at 500 messages.
string
required
string
required

List distinct conversation tags for the tenant

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.
string
required

Continue a conversation on a different channel

POST /api/v1/conversations/{id}/resume-channel
Re-bind this conversation (its id, message history, AI-agent session state and contact-scoped agent memory) onto target_channel so a customer can continue the SAME thread after switching channel (SMS ↔ email ↔ web-chat ↔ voice ↔ …) without repeating themselves. Repoints the conversation’s primary channel (the default for the next operator or AI-agent reply) and unions the new channel into channels[] so the full cross-channel history keeps loading. Idempotent — already on the target returns 200 with already_on_channel=true. Rejects terminal threads (422, reopen first) and channels the contact has no identity for (400). Sends no outbound message — the next reply still routes via the standard reply path.
string
required
Conversation id.
string (enum: sms|whatsapp|email|rcs|viber|instagram|messenger|line|telegram|voice|apple_messages|web_chat)
required
The channel to continue the conversation on.
string
Optional free-text note (≤280 chars) stamped into the activity trail (e.g. “customer asked to switch to email”).

Assign conversation

POST /api/v1/conversations/{id}/assign
Assign a conversation to a team member by their user ID.
string
required

Close conversation

POST /api/v1/conversations/{id}/close
Close/resolve a conversation. Closed conversations can be reopened.
string
required

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.
string
required
integer
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).
string
required
string
Free-text reason — surfaced in Slack + email body and stored on metadata.escalation.reason.
string
Optional structured escalation reason code (lowercase snake_case) drawn from your organization’s configured escalation reason codes. Validated against your tenant’s active codes before it’s stored; an unknown or archived code silently falls back to the free-text reason only.
string (enum: normal|high|urgent)
Escalation priority. Default ‘urgent’. Drives Slack button styling (danger vs primary) and email subject prefix.
string[]
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.
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.

Follow a conversation

POST /api/v1/conversations/{id}/followers
Subscribe a user (defaults to the caller when user_id is omitted) to activity on this conversation. Followers receive inbox SSE fan-out + notification routing even when they are NOT the assignee — Zendesk CC / Front followers / Intercom followers parity. Idempotent on the (conversation_id, user_id) pair: re-following returns 200 with was_already_following=true rather than 409 so multi-clicks of the ‘Follow’ button never produce an error toast. Fresh add returns 201. Audit row stamps follower_user_id + was_already_following. The composite primary key (conversation_id, user_id) in migration 634 enforces the at-most-once invariant at the DB layer.
string
required
string
Optional explicit follower user_id. Defaults to the caller’s user_id when omitted (the dominant ‘Follow this thread’ button case).

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.
string
required
string
Short reason — surfaced to the receiving human (e.g. ‘asked for a manager’).
string
Optional queue to route to. Defaults to the tenant’s default queue.
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.
string[]
Caller override for the skill set used to match operators. When omitted, derived from the conversation’s most recent AI agent’s skill_tags.
string
Caller-supplied 2-3 sentence summary. When omitted, the service generates one via the LLM gateway.
string
Routes the handoff to a specific team’s human queue inside the Inbox module (and pauses the AI agent) instead of the org-wide queue. When omitted, the conversation enters the org-wide queue.
string
Id of the AI agent handing the conversation off. Stored on the conversation row so the Resume AI action knows which agent to re-activate.

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.
string
required

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.
string
required
string
required
string

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.
string
required
string[]
required
boolean
Acknowledge and override the weak-identity safety check. When a source row shares only a phone number or email with the primary but the two carry a conflicting contact_id (different ids, or one side has a contact record and the other doesn’t), the merge is refused with a 422 WEAK_IDENTITY_MATCH_REQUIRES_CONFIRMATION. Re-submit with this set to true to confirm the rows are the same person and force the merge. Defaults to false.

Translate one message body to a target language

POST /api/v1/conversations/{id}/messages/{mid}/translate
Inline translation for a single message in the conversation thread (Intercom / Zendesk parity). Sends the message body through the platform LLM and caches the translation per-message under metadata.translations[target] so subsequent calls for the same (message, target) tuple are free. target is a BCP-47-ish short tag (en, es, pt-br). Tenant-scoped via the auth ctx schema and double-guarded by a conversation_id predicate on the message read.
string
required
Conversation id
string
required
Message id
string
required
BCP-47-ish target language tag (e.g. en, es, fr, pt-br).

Reopen conversation

POST /api/v1/conversations/{id}/reopen
Reopen a closed conversation so agents can continue the thread.
string
required

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.
string
required

Create a side conversation

POST /api/v1/conversations/{id}/side-conversations
Spawn a child thread under the parent conversation for looping an external vendor / SME into the operator’s investigation without exposing the thread to the customer. external_participants is an array of {label, email?, phone?, role?}. Optional initial_message inserts the opening outbound turn in the same call.
string
required
string
required
object[]
required
string

Reopen a resolved side conversation

POST /api/v1/conversations/{id}/side-conversations/{sideId}/reopen
Flip status back to ‘open’, clear resolved_at + resolved_by. Symmetric to /resolve so the FE can offer a single ‘Reopen’ button when status=‘resolved’.
string
required
string
required

Reply in a side conversation

POST /api/v1/conversations/{id}/side-conversations/{sideId}/reply
Append an outbound (default) or inbound message to the side thread. Outbound = the operator typing to the external participant; inbound = the operator logging a received reply (typed in from email / phone). Rejected with 422 when the side conversation status is ‘resolved’ — the FE prompts the operator to reopen first.
string
required
string
required
string
required
string (enum: outbound|inbound)
string

Resolve a side conversation

POST /api/v1/conversations/{id}/side-conversations/{sideId}/resolve
Mark the side thread as resolved (status=‘resolved’, stamp resolved_at + resolved_by). Idempotent — re-resolving updates resolved_at to NOW() so the audit trail reflects the most recent action.
string
required
string
required

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.
string
required
string
required
Absolute ISO 8601 timestamp (must be in the future)
string
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.
string
required

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.
string (enum: close|reopen|assign|add_tags|remove_tags)
required
string[]
required
string | null
Required for action=assign. Null to unassign.
string[]
Required for action=add_tags / remove_tags.

Update conversation 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.
string
required

Set conversation priority

PATCH /api/v1/conversations/{id}/priority
Set the manual-triage priority flag on a conversation. Accepts ‘low’ | ‘normal’ | ‘high’ | ‘urgent’ or null to clear. Writes the top-level conversations.priority column (tenant migration 454) so the queue-list filter/sort hits idx_conversations_priority rather than seq-scanning the metadata jsonb subfield. Distinct from POST /:id/escalate which also fans out Slack + email — PATCH /priority is the pure-flag set without notifications.
string
required
string | null (enum: low|normal|high|urgent|null)
required
One of low | normal | high | urgent, or null to clear.

Unfollow a conversation

DELETE /api/v1/conversations/{id}/followers/{userId}
Remove a follower from this conversation. Returns 404 when no follow row exists for the (conversation_id, user_id) pair so the FE can distinguish ‘no longer following’ from ‘successfully unfollowed’. CASCADE-purge semantics: a conversation that has been hard-deleted reaps its followers via the migration-634 FK, so a stale unfollow against an already-purged conversation 404s on the row, not on the conversation.
string
required
string
required

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’.
string
required