> For the complete documentation index, see [llms.txt](https://docs.concurrence.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.concurrence.com/developer-guide/platform-api/conversations.md).

# Conversations

Create, inspect, continue, switch, and close durable agent conversations over REST or WebSocket while preserving cross-channel history and state.

{% hint style="info" %}
Conversation detail is channel-aware, so voice calls and text conversations can be inspected through the same resource shape. Use the unified [Runs](/developer-guide/platform-api/conversations/runs.md) endpoint to browse activity across channels.
{% endhint %}

Conversations are first-class REST resources. You create them, send turns, inspect them, and close them when you are done. A separate WebSocket endpoint handles interactive streaming chat. Both use the same durable conversation store. A conversation first opened by the Sessions WebSocket can later be inspected or continued over REST by using the `conversation_id` from `session.created`; the WebSocket cannot select an independently created REST conversation by ID.

{% hint style="info" %}
**One reasoning system, transport-aware delivery.** REST, WebSocket, SMS, and WhatsApp share the conversation engine, tools, and safety rules, while channel-specific profiles still shape delivery behavior.
{% endhint %}

## API Surface

Conversation resources cover create, detail, turn submission, channel switching, and close operations. Sessions provide the public WebSocket surface for low-latency text conversations. Workspace-wide browsing is provided by [Runs](/developer-guide/platform-api/conversations/runs.md).

The REST operations are embedded in the relevant sections below. The [Sessions](/developer-guide/platform-api/platform-api/sessions.md) guide covers the separate WebSocket contract and close codes.

## Architecture

```mermaid
%%{init: {"theme":"base","fontFamily":"Inter, sans-serif","themeVariables":{"fontFamily":"Inter, sans-serif","fontSize":"16px","background":"#FAF8F6","primaryColor":"#F1EAE7","primaryTextColor":"#1A1817","primaryBorderColor":"#D7D2D0","secondaryColor":"#FDFCFB","tertiaryColor":"#F6F2F0","lineColor":"#575452","textColor":"#3D3937","clusterBkg":"#FDFCFB","clusterBorder":"#E8E2DF","edgeLabelBackground":"#FAF8F6","actorBkg":"#F1EAE7","actorTextColor":"#1A1817","actorBorder":"#D7D2D0","actorLineColor":"#575452","signalColor":"#575452","signalTextColor":"#3D3937","labelBoxBkgColor":"#F1EAE7","labelBoxBorderColor":"#D7D2D0","labelTextColor":"#3D3937","loopTextColor":"#3D3937","noteBkgColor":"#F6F2F0","noteBorderColor":"#D7D2D0","noteTextColor":"#3D3937","activationBkgColor":"#F1EAE7","activationBorderColor":"#D7D2D0","altSectionBkgColor":"#FAF8F6","altSectionColor":"#3D3937"}}}%%
flowchart LR
    subgraph REST["REST API"]
        create["POST /conversations"]
        turn["POST /{id}/turns"]
        detail["GET /{id}"]
        close["DELETE /{id}"]
    end

    subgraph WS["WebSocket"]
        stream["WS /sessions/connect"]
    end

    turn -->|proxy| actor["Conversation Engine"]
    stream -->|streamed turns| actor
    actor <-->|durable state| store["Conversation Store"]
    actor <-->|tool calls| tools["Tools"]
    actor <-->|patient context| wm["World Model"]

    create --> store
    detail --> store
    close --> store
```

***

## Conversation Lifecycle

```mermaid
%%{init: {"theme":"base","fontFamily":"Inter, sans-serif","themeVariables":{"fontFamily":"Inter, sans-serif","fontSize":"16px","background":"#FAF8F6","primaryColor":"#F1EAE7","primaryTextColor":"#1A1817","primaryBorderColor":"#D7D2D0","secondaryColor":"#FDFCFB","tertiaryColor":"#F6F2F0","lineColor":"#575452","textColor":"#3D3937","clusterBkg":"#FDFCFB","clusterBorder":"#E8E2DF","edgeLabelBackground":"#FAF8F6","actorBkg":"#F1EAE7","actorTextColor":"#1A1817","actorBorder":"#D7D2D0","actorLineColor":"#575452","signalColor":"#575452","signalTextColor":"#3D3937","labelBoxBkgColor":"#F1EAE7","labelBoxBorderColor":"#D7D2D0","labelTextColor":"#3D3937","loopTextColor":"#3D3937","noteBkgColor":"#F6F2F0","noteBorderColor":"#D7D2D0","noteTextColor":"#3D3937","activationBkgColor":"#F1EAE7","activationBorderColor":"#D7D2D0","altSectionBkgColor":"#FAF8F6","altSectionColor":"#3D3937"}}}%%
stateDiagram-v2
    [*] --> Active: Recent non-terminal activity
    Active --> Dormant: No update within channel threshold, unless paused
    Dormant --> Active: New activity
    Active --> Closed: Terminal status or DELETE /{id}
    Dormant --> Closed: Terminal status or DELETE /{id}
    Closed --> [*]
```

| State       | What it means                                                                                                                                                                     |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Active**  | The stored status is `paused`, or another non-terminal status with `updated_at` within the channel's activity threshold. Active doesn't mean a turn is running.                   |
| **Dormant** | The stored status is non-terminal, other than `paused`, and the conversation has not been updated within the channel's activity threshold. A later turn can make it active again. |
| **Closed**  | The stored status is `closed`, `completed`, or `failed`. No more turns are accepted.                                                                                              |

The `lifecycle` field is derived when the conversation is read. It is not a stored engine state and must not be used as a lock or delivery acknowledgment.

{% hint style="info" %}
The stored `status` is one of `active`, `paused`, `closed`, `completed`, `in-progress`, or `failed`. A text conversation is `paused` while an operator has taken it over, and its `lifecycle` stays `active` however long it is idle. The separate derived `lifecycle` is `active`, `dormant`, or `closed`. For web conversations, the active-to-dormant recency threshold is 5 minutes.
{% endhint %}

A turn that fails, is cancelled, or is interrupted does not end a text conversation. It keeps the work it already ran, the conversation stays open, and background results it started can still arrive. After a failed turn, send the next turn to the same conversation instead of starting a new one.

### Idle Conversations <a href="#idle-conversations" id="idle-conversations"></a>

A text conversation can keep its history across a long idle gap. When a turn arrives more than 24 hours after the conversation's last activity, the platform reloads the earlier turns when they are available. The agent continues from them, the new turns join the same history, a caller verified earlier in the conversation stays verified, and a locked verification stays locked. When they are not available, the turn starts the conversation over, without its earlier turns, as a new conversation would. If reloading available turns fails, the turn fails instead of starting over.

### What Gets Saved

| Field                | What it contains                                                                                                                                                                                                                                                                                                                                          |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Turns**            | Every user and agent message in conversation order, with its `turn_id`, `turn_index`, channel, Context Graph state, and the hops the turn took, each with the exit condition it took. On a platform-managed call, each line also records where it sits in the recording; see [Voice Call Detail](#voice-call-detail). Tool calls are returned on request. |
| **State**            | The Context Graph state after each turn, returned on the turn response as `context_graph_state`.                                                                                                                                                                                                                                                          |
| **Completion facts** | `completion_reason`, `final_state`, and `duration_seconds` for a conversation that reached a terminal Context Graph state, or that was [closed](#close-a-conversation) through the API or by `force_new`.                                                                                                                                                 |

### Session Timing (WebSocket)

A Sessions WebSocket has idle, user-turn, and credential-expiry timeouts, and none of them closes the durable conversation. See [Connection Limits](/developer-guide/platform-api/platform-api/sessions.md#connection-limits) for each value and what to do after it. The socket does not accept a `stop` client message; use the [close](#close-a-conversation) endpoint to close the durable record.

***

## REST API

### Create a Conversation

{% openapi src="<https://platform.api-us.concurrence.com/v1/openapi.json>" path="/v1/{workspace\_id}/conversations" method="post" %}
<https://platform.api-us.concurrence.com/v1/openapi.json>
{% endopenapi %}

Creates a conversation record and returns it. The conversation starts in a usable state - send your first turn immediately after.

The optional start-time `context` map supplies string and number values declared in the owning service's `accepted_context`. Creation validates and stores the map for tool bindings in the selected Context Graph. It is separate from an individual turn's free-text `context` field. A key the service does not declare, a missing required key, or a value of the wrong type returns `422` with the key in `loc`, for example `["body", "context", "plan_id"]`. Validation errors never echo the submitted value. See [Pass Context to Tool Parameters](/developer-guide/guides/conversation-context.md) for request examples, binding setup, and automatic channel-identity limitations.

For web conversations, the agent's greeting is produced as part of the first turn rather than at creation time: the create call returns the conversation with an empty `turns` array, and the opening exchange happens on your first `POST /turns`. For outbound channels, the create call itself dispatches the agent's opening message (see below).

#### Starting an Outbound Conversation

The create operation defaults to an inbound `web` conversation. Set an outbound channel to have the agent send the first message and return the durable conversation record. Outbound creation supports `sms` and `imessage`.

When `channel` is not `web`, the create dispatches the agent's first message through the messaging channel layer and returns the durable conversation. The routing and sending number are resolved server-side from `use_case_id` - never supplied by the caller. Carrier compliance registration is handled by the platform.

Requires the `Conversation:StartOutbound` permission (member, admin, or owner - **not** viewer or operator). Gates are evaluated in this order:

| Status | Meaning                                                                             |
| ------ | ----------------------------------------------------------------------------------- |
| `403`  | External-user token, or the caller lacks `Conversation:StartOutbound`.              |
| `501`  | `channel` not in `{sms, imessage}` (e.g. `email`, `whatsapp`, `voice`).             |
| `422`  | Missing `recipient`, or missing `use_case_id`.                                      |
| `404`  | Service not in this workspace, or use case not bound to this workspace and service. |
| `503`  | Outbound conversations are not configured.                                          |

An inbound reply from the recipient re-converges onto this same conversation by construction - the platform derives the routing key from `(channel, recipient, use_case_id)` the same way on both the send and receive paths.

**Forcing a fresh conversation.** On thread-keyed channels (`sms`, `imessage`), pass `force_new: true` to close any existing active conversation on the target thread (recipient + use case) before the outbound opener is dispatched, so a brand-new conversation is materialized. The closed conversation records `completion_reason` `closed`, as a [close](#close-a-conversation) does. `force_new` is rejected with `422` on `channel=web` - web conversations always start fresh.

***

### Send a Turn

{% openapi src="<https://platform.api-us.concurrence.com/v1/openapi.json>" path="/v1/{workspace\_id}/conversations/{conversation\_id}/turns" method="post" %}
<https://platform.api-us.concurrence.com/v1/openapi.json>
{% endopenapi %}

Send a message and get the agent's response. The platform loads the durable conversation state, runs the message through the reasoning engine, and persists the resulting turns and state.

The endpoint negotiates two response shapes by `Accept` header. The default is the synchronous JSON response. Pass `Accept: text/event-stream` to receive the reply as it is generated, a sentence at a time, and tool-call telemetry as a typed SSE event stream - see [Streaming Turns](#streaming-turns-sse).

The response echoes your input, gives you the agent's output (can be multiple messages if the agent calls tools), and includes a snapshot of the conversation state. The snapshot's `context_graph_state` is the state after the turn, and the output carries the turn's `state_transitions`, each hop with `from`, `to`, and `exit_condition`. A 409 is returned when the conversation is closed, completed, or failed, when an operator has taken it over, or when an `Idempotency-Key` is reused for a different turn (see [Concurrency](#concurrency) below).

**Empty turns.** An empty `message` with no `content` or media is accepted only as the kickoff of a conversation that has no turns yet, and the agent answers with its greeting. Once the conversation has a turn, a turn with no user input returns `422` with `loc` `["body", "message"]`, on both `POST .../turns` and `POST .../turns/stream`. To collect pending background work, send a [poll](#background-deferred-tool-results) instead.

**Retries and `Idempotency-Key`.** Send a UUID in the `Idempotency-Key` header on every user turn you might retry, and reuse the same value only when you retry that turn. A retry with the same key replays the original turn instead of running it again: you get its reply, or its failure, whether the first attempt went to `POST .../turns` or `POST .../turns/stream`. If that turn ended the conversation, the retry gets the `409` for an ended conversation instead, such as `"Conversation is completed"`; read the turn's reply from the conversation's turns. A key reused for a different turn, such as one with a different message, returns `409` `"Idempotency-Key was reused for a different turn"`. Without a key, every attempt runs as a new turn, so a retry after a timeout or a dropped connection can add a duplicate user turn.

The response also carries a boolean **`background_pending`**. It is `false` on an ordinary turn: `output` is the complete answer. It is `true` when a tool the agent called was handed to a background task - in that case `output` is only an acknowledgement ("Let me check that…"), **not** the final answer. See [Background (deferred) tool results](#background-deferred-tool-results).

**Turn identity.** The turn response carries a stable `turn_id` (UUID), and each message on it carries `turn_id` plus `turn_index` (zero-based ordinal of the user exchange). The same identity appears on the conversation's history turns, so `turn_id` can key durable per-turn artifacts - feedback, annotations - across page reloads and re-fetches. Both fields are `null` on messages that precede the first user exchange (proactive greetings, channel-event preludes). When two turns on one conversation overlap and both are accepted, each response carries its own turn identity.

**Failed turns.** When the model rejects a turn with a 4xx, the turn fails at once with `502` and `"Model rejected the request (HTTP <status>)"`. Any other failure inside a user turn returns `502` `"Agent service failed the turn"`, and a greeting kickoff that fails returns `502` with a detail such as `"Agent response failed"` or `"Agent produced no response"`. A streamed turn that fails ends with a non-retryable `error` frame instead; see [Streaming behavior](#streaming-behavior). None of these includes the upstream response text. A retry with the same key replays a failed user turn, so send a new turn instead; for a model rejection, fix the rejected configuration or content first.

***

### Reply Check <a href="#reply-check" id="reply-check"></a>

Every text reply passes a check before it is sent, on the web API and on SMS, iMessage, WhatsApp, and email. In English and Spanish, it stops:

* Dosing or medication-change advice.
* A claim that the agent contacted, or will contact, emergency services.
* A claim that a booking, cancellation, or message is done when no tool call in the same turn completed a write that backs it.
* A promise to book, reschedule, or cancel, such as "I'll book that for you" or "let me cancel it", when no tool call in the same turn completed a write that backs that claim.
* A promise to act right away, such as "let me take care of that now", when no tool call in the same turn completed a booking, cancellation, or message write.

A question, a negation, or a conditional offer, such as "once you confirm, I'll cancel it", is not treated as a promise.

A write backs a claim only when it succeeded in that turn: a built-in appointment write such as `world_cancel_appointment`, or an integration endpoint, called directly or by a companion skill, whose [declared writes](/developer-guide/platform-api/integrations.md#endpoint-write-declarations) match the claim and whose response reported the write. For an endpoint declared `varies`, the calling skill's `write_kind` decides. A booking write backs a booked or moved claim, a cancellation write a cancelled one, and a reschedule write any of the three. A message claim is backed by a ticket written with `world_ticket_create` or by an endpoint declared `message`. An approval-gated write runs, once approved, in the turn that resumes the conversation, so it backs that turn's reply. A failed call backs no claim, and neither does a write declared `other` or one that declares nothing.

A write whose outcome is unknown, reported as `"outcome": "unknown"`, backs no claim, and a claim about it gets the unknown-outcome correction below. See [Endpoint Retries](/developer-guide/platform-api/integrations.md#endpoint-retries) for when an outcome is unknown and how the platform handles a repeat.

A stopped reply is replaced whole by a correction in English or Spanish:

| Stopped for                                          | Correction (English)                                                                                       |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| A booking or cancellation claim, or a promise to act | "Sorry, I can't confirm that's done yet. Reply here and I'll check on it."                                 |
| A message claim                                      | "Sorry, I misspoke: I haven't passed a message to anyone."                                                 |
| A claim about a write whose outcome is unknown       | "Sorry, I can't confirm that went through, so I won't try it again. You can check with the team directly." |
| A promise to contact emergency services              | "I can't contact emergency services. If this is an emergency, call 911 now.", with the region's number     |
| Dosing or medication-change advice                   | "I can't give advice about medications or doses. Please check with your doctor or pharmacist first."       |

When the person's message in that turn described a medical emergency, the correction is the [emergency script](https://docs.concurrence.com/operations-and-safety/runtime-safety#reply-checks) instead. The JSON `output` and the stream's `message` event carry the correction. On a streamed turn, `token` events carry whole sentences, each sent once the check clears it, and the preview stops at the first sentence the check stops.

A text tool call whose result is an error, such as an integration endpoint that answered 4xx or 5xx, counts as failed. Its tool call reports `succeeded: false`, it does not trigger `navigate_on_completion`, and it backs no claim.

The check can miss a phrasing or stop a correct reply. Declare `writes` on every endpoint whose result the agent confirms, and test confirmation replies in text simulations, which run the same check.

***

### Background (deferred) tool results

A companion skill that runs past the server's blocking window, or a tool bound with `execution: "background"` (see [Execution Model](/developer-guide/platform-api/conversations/voice-agent.md#execution-model)), finishes outside the request. The turn then returns with `background_pending: true` and an acknowledgement in `output`. The agent is told the tool is still running and is instructed not to state its outcome, and a state change that waits on the tool's completion (`navigate_on_completion`) happens when the result arrives. Completion time depends on the tool and its dependencies. If continuation processing succeeds, the resulting answer is appended to durable conversation state; `background_pending` is not a guarantee that a final answer will eventually be produced. Any other tool runs inside the turn, and an integration call the turn waits on gets about 20 seconds in all before it fails as a timeout, so bind a slow call with `execution: "background"` or make it through a companion skill; see [Endpoint Retries](/developer-guide/platform-api/integrations.md#endpoint-retries).

Retrieve it in any of these ways (all on the same conversation, no second connection):

* **Send the next user turn.** A completed background result can be folded into that turn's reply.
* **Poll.** Re-issue `POST /{id}/turns?poll=true` with no message. A poll returns at most one ready answer. When it returns one, the response carries a `delivery` object with `delivery_id`, `request_id`, and `receipt`: render or store the answer, then [acknowledge it](#acknowledge-a-polled-answer). Until you do, a later poll can return the same answer again, so de-duplicate by `delivery_id`. When nothing is ready, `output` is empty and `background_pending` says whether work remains. Send an `Idempotency-Key` with each poll and reuse it if you retry that poll. Poll no more than once every 5 seconds per conversation. A poll does not accept a message body, SSE streaming, or the per-turn wait/suppress controls (`422`).
* **Re-read the conversation.** `GET /{id}` reflects messages that have been durably persisted at read time. It cannot manufacture a missing result for work that is still pending or failed before persistence.

A client that treats a `background_pending: true` turn as complete can miss a later result. Poll (or send the next turn) until the API reports no pending work or an explicit failure or terminal outcome. If a turn's outcome is ambiguous, inspect the durable conversation before retrying.

A companion skill that moves to the background after crossing the blocking window keeps running when the next turn arrives. If a later turn calls the same skill with the same inputs while it is still running, on the web API or any messaging channel, no second run starts: the agent is told the request is still in progress, and the one run's answer arrives as usual. A skill bound with `coupled` lifecycle belongs to the turn that started it, so a later turn starts its own run.

Two optional per-turn request fields tune this behavior for synchronous callers:

* **`wait_for_final`** - when a turn hands work to a background tool, wait inline for that turn's answers instead of returning the acknowledgement immediately. The turn and the wait share one budget of about 170 seconds, so give the request a client timeout of at least 180 seconds. The response carries the answers the turn's background work commits, in order, in place of the acknowledgement; a tool that hands off to another returns both messages. `background_pending: false` means `output` is the turn's whole answer. If time runs out, or an earlier turn's answer is still waiting to be collected, the response returns what arrived, or the acknowledgement, with `background_pending: true`; collect the rest with `poll=true` before sending the next turn. Send a UUID `Idempotency-Key` with every waiting turn: a retry with the same key returns the answers the turn already collected instead of running the turn again. `null` (default) inherits the channel policy (web = do not wait).
* **`suppress_filler`** - when a turn ends `background_pending`, omit the filler/acknowledgement text from `output` so a batch caller never mistakes the acknowledgement for the answer (`background_pending: true` is the poll-later signal). Answers a `wait_for_final` wait collected still come back in `output`. `null` (default) inherits the channel policy.

The streaming operations ignore both fields.

<figure><img src="https://1651797362-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F2YwM0LHtEGLt9S21OyC3%2Fuploads%2Fgit-blob-34847c1abac8138cac4c6b9b26712d46113ea8ae%2Fwait-for-final.svg?alt=media" alt="A turn sent with wait_for_final waits about 170 seconds for its background answers. If they arrive, background_pending is false; if time runs out, it is true and the client polls before its next turn."><figcaption><p>Without <code>wait_for_final</code>, a web turn returns the acknowledgement as soon as the tool moves to the background. <code>suppress_filler</code> leaves the acknowledgement out of <code>output</code>; answers the wait collected still come back.</p></figcaption></figure>

{% hint style="info" %}
Do **not** rely on the event stream to receive a single chat's background results. The unscoped workspace stream (`GET /v1/{workspace_id}/events/stream`) does not carry conversation text. A stream opened with `?conversation_id=` mirrors that conversation's activity as `text.tool_started`, `text.background_result`, and `text.agent_message` events, but the per-conversation contract above (drain / poll / re-read) is the supported delivery path for a chat client. See [SSE Events](/developer-guide/platform-api/conversations/sse-events.md#scope-to-one-conversation).
{% endhint %}

On SMS, iMessage, WhatsApp, and email, nobody polls: the agent follows up in the thread when a background result arrives. See [Long-Running Tool Delivery on Non-Live Channels](/developer-guide/platform-api/conversations/channels.md#long-running-tool-delivery-on-non-live-channels).

#### Acknowledge a Polled Answer

{% openapi src="<https://platform.api-us.concurrence.com/v1/openapi.json>" path="/v1/{workspace\_id}/conversations/{conversation\_id}/turns/{delivery\_id}/ack" method="post" %}
<https://platform.api-us.concurrence.com/v1/openapi.json>
{% endopenapi %}

Send the `request_id` and `receipt` from the poll's `delivery` object once the answer is rendered or stored. A successful acknowledgement returns `204`, and the answer is not returned again. Acknowledging the same answer again also returns `204`. A `request_id` or `receipt` that doesn't match the claim returns `409`.

***

### Streaming Turns (SSE)

For low-latency UIs that display generated tokens and tool-call activity inline, use the dedicated streaming operation, which always returns `text/event-stream` regardless of the `Accept` header. The same stream is also available on the regular turns endpoint through content negotiation.

{% openapi src="<https://platform.api-us.concurrence.com/v1/openapi.json>" path="/v1/{workspace\_id}/conversations/{conversation\_id}/turns/stream" method="post" %}
<https://platform.api-us.concurrence.com/v1/openapi.json>
{% endopenapi %}

Example using the regular turns operation:

```
POST /v1/{workspace_id}/conversations/{conversation_id}/turns
Authorization: Bearer <api_key>
Accept: text/event-stream
Content-Type: application/json

{"message": "I need to schedule an appointment"}
```

The server keeps the connection open and emits a typed sequence of events (`event:` lines) terminated by `done` on success or `error` on failure. Before `done`, a successful turn sends one `state_transition` event per Context Graph hop it took and then a `state` event with the conversation's state, matching what the JSON response returns. Turn ordering and a retry with the same `Idempotency-Key` work as on the JSON path. A disconnect before the terminal event has an ambiguous outcome, so read the durable conversation before retrying.

<figure><img src="https://1651797362-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F2YwM0LHtEGLt9S21OyC3%2Fuploads%2Fgit-blob-8e77400e67cedc0666394246dd5e367d56ed9161%2Fstreamed-turn-events.svg?alt=media" alt="A streamed turn sends token previews, tool-call events when they are on, any artifact, the message that replaces the preview, one state_transition per Context Graph hop, a state event, and done. An error event ends the turn instead of done."><figcaption><p>A turn that stays in its state sends no <code>state_transition</code>. JSON turns return the same hops in <code>state_transitions</code> and the same state in <code>context_graph_state</code>, and the Sessions WebSocket relays the same events as JSON frames.</p></figcaption></figure>

#### Example wire format

```
event: token
data: {"event":"token","text":"I can help. "}

: ping - 2026-04-28 17:02:15.204118+00:00

event: token
data: {"event":"token","text":"I found 3 slots..."}

event: tool_call_started
data: {"event":"tool_call_started","tool_name":"search_appointments","call_id":"c1","input":"{\"date\":\"2026-04-29\"}"}

event: tool_call_completed
data: {"event":"tool_call_completed","tool_name":"search_appointments","call_id":"c1","result":"[...]","succeeded":true,"duration_ms":412.0}

event: message
data: {"event":"message","role":"agent","text":"I can help. I found 3 slots..."}

event: state_transition
data: {"event":"state_transition","from_state":"triage","to_state":"schedule","exit_condition":"Caller wants to book"}

event: state
data: {"event":"state","context_graph_state":{"type":"action","name":"schedule", ...}}

event: done
data: {"event":"done","conversation_id":"c60818df-...","status":"active","turn_count":2,"background_pending":false,"delivery_protocol_version":2,"turn_id":"9b1f4c2e-...","turn_index":1}
```

#### Streaming behavior

* The stream completes when a `done` event is received. Always treat `done` as the signal to close the reader; do not rely on connection-close alone.
* Render `message` events as the authoritative completed agent messages. `token` events are a progressive preview of the reply. When `message` arrives, replace the preview with the `message` text instead of appending it, and commit only the `message` text to the transcript.
* The preview arrives in whole sentences: each `token` event carries the sentences the [reply check](#reply-check) has cleared, and the last one carries the rest of the reply. When the check stops a sentence, no more `token` events follow for that turn, and the `message` event carries the correction rather than the reply.
* With `include_tool_calls=true`, a tool-backed turn sends `tool_call_started` and `tool_call_completed` for each call before its final `message`. They can arrive after all of the turn's `token` events, even for a tool that ran before the reply text, so do not rely on the order of `token` and tool-call events.
* An `artifact` event carries a typed, client-safe result from an integration endpoint set up to return one, before the `message` event; the JSON route returns the same in `artifacts`. A `thinking` event can also appear. Skip any event your client does not use.
* After the final `message`, the turn sends a `state_transition` event for each hop, in order, with `from_state`, `to_state`, and `exit_condition` (null on a hop out of an annotation state), then one `state` event whose `context_graph_state` is the state the JSON route returns on `conversation.context_graph_state`. A turn that stays in its state sends no `state_transition`. History turns and the JSON turn response name the hop fields `from` and `to`, with the same `exit_condition`. Platform SDK 0.114.1 and later yield both events from `streamTurn()`.
* Tool calls can run before the first `token` arrives. Show a pending indicator until the first `token` or `message` event, and keep the response open until `done`.
* Every 15 seconds the stream carries a comment line that starts with `:` (`: ping - <timestamp>`), so proxies and load balancers do not close a turn that is waiting on a slow tool. A comment is not an event and has no `data:` line. Standard SSE parsers skip it, and a hand-written parser must skip it too.
* A mid-stream `error` event is terminal - no `done` follows. Branch on `code` and `retryable`, and retry with backoff only when `retryable` is true. Of the errors a turn itself ends with, only the reply timeout (`status_code: 504`) is retryable; a closed conversation (`409`), a turn that ended without a reply (`500`), and a model rejection (`502`) are final for that turn. A retryable `stream_interrupted` error means the connection to the turn broke rather than the turn failing, so read the conversation before retrying, and retry with the same `Idempotency-Key`.
* If the conversation is closed while a streamed turn is running, for example by a [close](#close-a-conversation) from another client, the stream ends with this terminal frame:

  ```
  event: error
  data: {"event":"error","message":"Conversation is closed","code":"upstream_error","retryable":false,"status_code":409}
  ```

  It is the in-stream form of the `409 Conversation is closed` returned before a stream starts. Treat it as terminal: stop the turn, do not retry, and start a new conversation if the user continues. A close that lands mid-turn is reported on this error frame rather than leaving any already-streamed `token` preview standing as the answer.
* The server waits about 60 seconds for a turn's reply. While the agent is still working on the turn, for example on a slow tool, the wait extends up to about 170 seconds from the request; the JSON route uses the same budget. A streamed turn that times out without a reply ends with a retryable `error` frame (`code: upstream_error`, `retryable: true`, `status_code: 504`) because the turn may still be running; a retry can still receive its reply. A turn that ended but produced no reply ends with a non-retryable `error` frame (`code: upstream_error`, `retryable: false`, `status_code: 500`): a same-key retry replays the ended turn rather than re-running it, so send a new turn instead.
* When the model rejects a streamed turn, the stream ends at once with a non-retryable `error` frame (`code: upstream_error`, `retryable: false`, `status_code: 502`) whose message is `Model rejected the request (HTTP <status>)`. Do not retry it with the same key; fix the rejected configuration or content first.
* When a streamed turn relayed `token` events but the final reply carries no agent message, the stream still sends a `message` event with empty `text` before `done`, so the completed empty `message` replaces the token preview rather than leaving the preview as the answer. A turn that relayed no tokens and produced no message sends no `message` event before `done`.
* If the client disconnects before `done`, do not infer whether the turn failed or completed from the disconnect alone. Read `GET /v1/{workspace_id}/conversations/{conversation_id}` before deciding whether to retry.
* A turn the platform refuses gets an HTTP status **before** the stream begins: `404` for an unknown conversation; `409` for a conversation that has ended or that an operator has taken over, or for an `Idempotency-Key` reused for a different turn; `422` for an invalid request; and `502`, `503`, or `504` when the service can't take the turn. Once the response body starts, status is 200 and failures arrive as `error` events.

***

### Concurrency

Turns on one conversation run one at a time, in the order they reach the server. A turn sent while another is still running is accepted and runs after it, and each response carries its own `turn_id` and `turn_index`, matching the conversation's history. Different conversations run independently.

| Scenario                                                      | What happens                                                                                                                                                                                              |
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Send a user turn while another turn is still running          | Accepted. It runs after the earlier turn. If the earlier turn runs long, the later request can end with the retryable `504` reply timeout; retry it with the same `Idempotency-Key` to collect its reply. |
| Send REST and WebSocket turns to the same conversation        | Both are accepted and run in the order they arrive.                                                                                                                                                       |
| Send the empty kickoff turn while the conversation is busy    | **409 Conflict** - `"Conversation is already active"`. Retry with backoff.                                                                                                                                |
| Reuse an `Idempotency-Key` for a different turn               | **409 Conflict** - `"Idempotency-Key was reused for a different turn"`. Send the new turn with a new key.                                                                                                 |
| Send a turn to a closed, completed, or failed conversation    | **409 Conflict** - `"Conversation is closed"`, `"Conversation is completed"`, or `"Conversation has failed"`.                                                                                             |
| Send a turn while an operator has taken over the conversation | **409 Conflict** - `"Conversation is paused while an operator holds it"`. Send it after the handback.                                                                                                     |
| Close the conversation while a streamed turn is running       | The stream ends with a non-retryable `error` frame, `"Conversation is closed"` with `status_code` 409. See [Streaming behavior](#streaming-behavior).                                                     |
| `GET` the conversation while a turn is processing             | **Works** - read endpoints are never blocked.                                                                                                                                                             |

#### Reading a 409

The `detail` tells the cases apart:

```json
// Ended: final, no more turns are accepted
{ "detail": "Conversation is closed" }
{ "detail": "Conversation is completed" }
{ "detail": "Conversation has failed" }

// An operator has taken over the conversation: send the turn after the handback
{ "detail": "Conversation is paused while an operator holds it" }

// The key belongs to another turn: send this turn with a new key
{ "detail": "Idempotency-Key was reused for a different turn" }

// A kickoff while the conversation is busy: retry with backoff
{ "detail": "Conversation is already active" }
```

A conversation closed while a JSON (non-streaming) turn is running returns the same `409` `"Conversation is closed"` for that turn. The request ends shortly after the close instead of waiting for the reply deadline.

#### Retry strategy

Don't retry these unchanged: a `409` for a conversation that has ended, a `422`, or a `502` whose `detail` is a failed-turn message: `"Model rejected the request (HTTP <status>)"`, `"Agent service failed the turn"`, or `"Agent response failed"`.

Retry a user turn only with its original `Idempotency-Key`, and only after a retryable failure: the `504` reply timeout, a `503`, a dropped connection, or any other `502`. The retry returns the turn's reply if it ran and runs it if it didn't. If that turn ended the conversation, the retry gets the `409` for an ended conversation instead, such as `"Conversation is completed"`; read the conversation's turns for its reply. There is no endpoint that cancels a running turn.

#### What if your HTTP client times out?

If your client times out, the outcome is ambiguous: the server may still complete and persist the turn. Retry with the same `Idempotency-Key` to get that turn's reply without adding a duplicate user turn; if the retry gets the `409` for an ended conversation, read `turns`: the turn's reply is there if the turn finished. If the turn was sent without a key, read the conversation and inspect `turns` before resending.

{% hint style="warning" %}
**Wait for each turn when order matters.** Turns run in the order they reach the server, which can differ from the order your client sent them over separate connections. On WebSocket, wait for the current turn's terminal `done` or `error` event before sending the next `user_text`; buffered frames are not a durable queue or delivery acknowledgment.
{% endhint %}

***

### Browse Conversations

Use the unified runs inventory with `kind=conversation` to browse voice, text, SMS, email, and web activity; there is no separate conversation-list endpoint. Open a returned run's `source_conversation_id` with conversation detail when you need the full transcript. To go the other way, from a conversation to its run, pass the conversation's ID as `source_conversation_id`; see [Filter by Source Conversation](/developer-guide/platform-api/conversations/runs.md#filter-by-source-conversation).

{% openapi src="<https://platform.api-us.concurrence.com/v1/openapi.json>" path="/v1/{workspace\_id}/runs" method="get" %}
<https://platform.api-us.concurrence.com/v1/openapi.json>
{% endopenapi %}

***

### Switch Conversation Channel

Move an active conversation to a different channel. The durable conversation ID is preserved - only the routing changes. The same conversation continues on the new channel.

{% openapi src="<https://platform.api-us.concurrence.com/v1/openapi.json>" path="/v1/{workspace\_id}/conversations/{conversation\_id}/channel" method="post" %}
<https://platform.api-us.concurrence.com/v1/openapi.json>
{% endopenapi %}

Requires the `Conversation:SwitchChannel` permission (member tier and above). External users cannot switch channels.

Only `sms` and `imessage` are supported switch targets; `email`, `whatsapp`, and `voice` return `501`. Voice conversations cannot be switched. A successful switch preserves the conversation's history, and later replies on the new channel continue the same conversation.

#### Error Responses

Gates are evaluated in this order:

| Status | Meaning                                                                                                                         |
| ------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `403`  | External-user token, or the caller lacks `Conversation:SwitchChannel`.                                                          |
| `501`  | Target channel not in `{sms, imessage}` (e.g. `email`, `whatsapp`, `voice`).                                                    |
| `422`  | Missing `recipient` or `use_case_id`, or `dispatch_opener` is `true` but the conversation has no service.                       |
| `404`  | Use case not owned by this workspace, or conversation not found / already closed / not switchable (voice).                      |
| `409`  | Another active conversation already uses this `(channel, recipient)` combination.                                               |
| `503`  | Outbound conversations are not configured.                                                                                      |
| `502`  | The switch was persisted but the optional opener dispatch failed. The switch is durable; retry the opener without re-switching. |

***

## Get Conversation Detail

{% openapi src="<https://platform.api-us.concurrence.com/v1/openapi.json>" path="/v1/{workspace\_id}/conversations/{conversation\_id}" method="get" %}
<https://platform.api-us.concurrence.com/v1/openapi.json>
{% endopenapi %}

Returns the conversation's status, derived `lifecycle`, and turns. When a text conversation reaches a terminal Context Graph state, its status becomes `completed` and the detail records how it ended: `completion_reason`, `final_state`, and `duration_seconds`. A text conversation ended by a [close](#close-a-conversation) or by `force_new` has status `closed` and records the same facts, with `completion_reason` `closed`.

`session_provider` and `manifest` say what ran the conversation, as its first session recorded them when it started: the runtime that handled it (`gpt_realtime`, or `amigo` for a platform-managed call and for every text conversation), and the agent and Context Graph versions, version set, models, build, and voice language it ran with. A platform-managed call records its manifest again after a mid-call speech-to-text failover or language switch, and keeps the last one. See [What Ran a Run](/developer-guide/platform-api/conversations/runs.md#what-ran-a-run). Both are null when the conversation recorded none, such as before a text conversation's first session starts, and an external user never receives them.

Each voice utterance appears once. With `include_tool_calls=true`, each tool call appears on the agent turn that made it. `truncated` is `true` when the conversation recorded more entries than one read takes, so the turns stop before the end of the conversation.

Each message can also carry `state`, the Context Graph state at the message: the state a user message arrived in, or the state after the turn's hops on an agent message. `engine_turn_id` names the engine turn a user message opens or an agent message answers, and one exchange can hold several engine turns, such as a tool call and the reply to its result. A filler line, a text reply, and a channel event have no `engine_turn_id`.

### Voice Call Detail <a href="#voice-call-detail" id="voice-call-detail"></a>

On a platform-managed call, each message in `turns` also says where it sits in the recording:

* `speech_start_ms` and `speech_end_ms` place the message in the call's recording, in milliseconds from its start. A caller line runs from when the caller started speaking to when they stopped. An agent line runs from when its first audio was sent to when its last was, which is ahead of when the caller heard it.
* On an agent line the agent spoke while it worked on the reply, `filler_type` names the kind of filler (`receipt`, `empathy`, or `working`); it is null on the reply itself. `interrupted` says whether the caller cut an agent line off.

Each of these is null when the call recorded nothing for it, such as on an agent line that never played or a caller line the call ended during.

`voice.timeline` lays the lines out on the recording. Its `turns` hold one entry for each message with a recorded start and end, with `seek_to`, `active_start`, and `active_end` in seconds from the start of the recording. Its `segments` hold each line's speech, as `caller_speech`, `agent_speech`, `filler`, or `interrupted_speech`, and, where the call measured it, a `processing_gap` for the wait from a caller line's end to the first reply that started after it. A gap's `e2e_ttfb_ms` is the wait the caller heard, to the first filler or the reply, and `avg_processing_gap_ms`, `max_processing_gap_ms`, and `avg_perceived_latency_ms` summarize the gaps. A `turn_index` in the timeline is a position in this response's `turns`, not a message's own `turn_index`, and `turn_id` names the exchange. Tool calls and hops stay on the turns. The timeline places no silence, and it is null when no line has a recorded start and end. A call reads back the same way however long ago it ended.

{% hint style="info" %}
**Web chat transcript source.** For web/text conversations, render history from this endpoint's `turns[]` array. Each turn has `role`, `text`, and `timestamp`; agent messages appear as `{"role": "agent", "text": "..."}`. Do not use the call detail shape (`/calls/{id}` or `agent_transcript`) for web chat history. Call transcript fields are for call-oriented views and may not contain web/text agent messages.
{% endhint %}

{% hint style="info" %}
**Per-turn channel attribution.** Each turn carries a `channel` field (`web`, `sms`, `imessage`, and others), recording which channel delivered it. After a [channel switch](#switch-conversation-channel), earlier turns keep their original channel and later turns reflect the new one. The field can be `null` on some turns, such as internal ones.
{% endhint %}

### List Conversation Turns

Pages through the same turns the detail returns, for voice or text. The default `sort_by` of `+turn_index` starts with any messages before the first user turn, and `-turn_index` starts with the newest exchange. Messages inside one exchange keep conversation order either way. `limit` accepts up to 200, and `continuation_token` is opaque: pass the returned value unchanged. Pass `include_tool_calls=true` to include tool calls, and check `truncated` as on the detail.

{% openapi src="<https://platform.api-us.concurrence.com/v1/openapi.json>" path="/v1/{workspace\_id}/conversations/{conversation\_id}/turns" method="get" %}
<https://platform.api-us.concurrence.com/v1/openapi.json>
{% endopenapi %}

***

### Close a Conversation

{% openapi src="<https://platform.api-us.concurrence.com/v1/openapi.json>" path="/v1/{workspace\_id}/conversations/{conversation\_id}" method="delete" %}
<https://platform.api-us.concurrence.com/v1/openapi.json>
{% endopenapi %}

Closes the conversation permanently. Subsequent turns return 409, and a turn still running when the close lands ends with `409` `"Conversation is closed"`. The full transcript stays readable through conversation detail, the turns list, and run detail as soon as the close returns.

The close records how the conversation ended: `completion_reason` is `closed`, `duration_seconds` runs from its creation to the close, and `final_state` is the Context Graph state it was in, when known. `final_state` is null when the close can't tell, for example when no turn has run or the conversation was idle for more than 24 hours. Conversation detail and [runs](/developer-guide/platform-api/conversations/runs.md#list-runs) report them, and the run ends at the close.

Closing a conversation that has already ended, with status `closed`, `completed`, or `failed`, returns `204` and keeps its stored status and completion facts, so a `completed` conversation stays `completed`. Closing a text conversation an operator has taken over returns `409` `"Conversation is paused while an operator holds it"`; close it after the handback. If an operator takes over while the close runs, the close can return `409` `"Conversation is paused and was not closed"`; close it again after the handback. A conversation that doesn't exist returns `404`.

### Test Credentials <a href="#test-credentials" id="test-credentials"></a>

A [test credential](/developer-guide/platform-api/workspaces.md#test-credential-ids) writes only to conversations a test session started. A turn, a close, a delivery acknowledgement, a channel switch, or an approval decision on any other conversation returns `403` before anything runs. On the [sessions WebSocket](/developer-guide/platform-api/platform-api/sessions.md#reconnect-and-thread-semantics), a test credential gets its own conversation thread, so it never resumes a conversation an ordinary client started.

***

### Example: Full REST Flow

```bash
# 1. Create
CONV=$(curl -s -X POST https://platform.api-us.concurrence.com/v1/$WS/conversations \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"service_id": "'$SERVICE_ID'"}' | jq -r '.id')

# 2. First turn. Keep the key: a retry of this turn reuses it.
TURN_KEY=$(uuidgen)
curl -X POST https://platform.api-us.concurrence.com/v1/$WS/conversations/$CONV/turns \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $TURN_KEY" \
  --max-time 180 \
  -d '{"message": "I need to schedule an appointment"}'

# 3. Second turn, with a new key
curl -X POST https://platform.api-us.concurrence.com/v1/$WS/conversations/$CONV/turns \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --max-time 180 \
  -d '{"message": "Next Tuesday at 2pm"}'

# 4. Check the full conversation
curl https://platform.api-us.concurrence.com/v1/$WS/conversations/$CONV \
  -H "Authorization: Bearer $TOKEN"

# 5. Close when done
curl -X DELETE https://platform.api-us.concurrence.com/v1/$WS/conversations/$CONV \
  -H "Authorization: Bearer $TOKEN"
```

***

## WebSocket Sessions

Use [Sessions](/developer-guide/platform-api/platform-api/sessions.md) for public bidirectional text conversations. The endpoint resolves one active thread per `(workspace_id, entity_id, service_id)` combination and returns its durable ID in `session.created`. Clients reconnect with the same service and entity rather than passing a conversation ID.

### Connect

```
WS /v1/{workspace_id}/sessions/connect?service_id={id}&entity_id={id}
Sec-WebSocket-Protocol: auth, <api_key_or_jwt>
```

| Parameter         | Location                        | Required | Description                                                                                                                                      |
| ----------------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `service_id`      | query                           | Yes      | Which agent (UUID)                                                                                                                               |
| `entity_id`       | query                           | Yes      | Patient entity for world model context (UUID)                                                                                                    |
| `tool_events`     | query                           | No       | Emit `tool_call_started` / `tool_call_completed` frames. Default `false`.                                                                        |
| `context`         | query                           | No       | Additional context for the opening turn; limited to 5,000 characters.                                                                            |
| `viewport_width`  | query                           | No       | Text width from 20 through 500 characters.                                                                                                       |
| `viewport_height` | query                           | No       | Text height from 5 through 500 characters; useful with `viewport_width`.                                                                         |
| *credential*      | `Sec-WebSocket-Protocol` header | Yes      | Two comma-separated values: the literal `auth` followed by an API key or JWT. The server echoes `auth` as the negotiated subprotocol on success. |

{% hint style="warning" %}
**Credentials never go in the URL.** Query-string tokens are not supported. Browsers can deliver the subprotocol header by passing the token as the `protocols` argument to the `WebSocket` constructor:

```javascript
const ws = new WebSocket(
  `wss://platform.api-us.concurrence.com/v1/${WS}/sessions/connect` +
  `?service_id=${SVC}&entity_id=${ENTITY}`,
  ["auth", TOKEN],
);
```

{% endhint %}

{% hint style="warning" %}
The current TypeScript SDK URL helper exposes `conversationId` and assumes tool events are enabled when omitted. The server ignores `conversation_id` and defaults `tool_events` to `false`. Follow the server contract: omit `conversation_id` and append `tool_events=true` explicitly when tool telemetry is required. The SDK's `sessionConnectAuthProtocols()` helper does match the auth contract.
{% endhint %}

The endpoint does not publish a fixed Origin allowlist, connect-rate limit, concurrent-connection cap, or maximum connection lifetime as part of its application contract. Credentials authorize the connection. Keep credentials out of untrusted browser code, use one socket per active chat, and reconnect with capped exponential backoff and jitter.

#### Close codes

| Code   | Meaning                                                                                           |
| ------ | ------------------------------------------------------------------------------------------------- |
| `1000` | Normal close, including the close after an `idle_timeout` or `credential_expiring` event          |
| `4000` | Invalid request parameters - workspace ID, `service_id`, or `entity_id` missing or malformed UUID |
| `4001` | Authentication failed - missing, invalid, inactive, expired, or wrong-workspace credentials       |
| `4004` | Resource not found - workspace missing or inactive, service not found, or person entity not found |
| `4503` | Conversation thread resolution timed out; retry with backoff                                      |

After a successful handshake, wait for `session.created`, then consume the automatic opening agent turn through its terminal `done` or `error` event. Only then send the first `user_text` message. A browser `open` event alone is not proof that validation succeeded because failures use immediate WebSocket close frames.

For frame shapes, server events, and close codes, see [Sessions](/developer-guide/platform-api/platform-api/sessions.md) and the [Sessions API reference](https://docs.concurrence.com/api-reference/readme/platform/sessions).

### Turn Sequencing

Send client messages as JSON text frames with `{"type":"user_text","text":"..."}`. The server processes one turn at a time. Wait for `done` or `error` before sending another message. A frame sent while the current turn is running can remain buffered by the WebSocket connection, but there is no public durable queue, replay cursor, or message receipt.

{% hint style="warning" %}
**Ambiguous disconnects.** If the socket closes before a turn reaches `done` or `error`, do not blindly resend the user message. Read the durable conversation using the most recent `conversation_id` to determine whether the turn was persisted.
{% endhint %}

### Tool Call Details

Pass `tool_events=true` on a Sessions WebSocket when the client needs streaming tool-call events. For REST turns and conversation detail, pass `include_tool_calls=true` to opt in to tool-call details, including failure details when available. A tool call's `succeeded` is `null` when no result was recorded for it, for example while it still runs, so handle `null` as well as `true` and `false`.

Tool-call output can include sensitive operational data, so it is opt-in rather than included by default.

### Greeting Behavior

| Situation                                 | What happens                                                                                        |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Every successful connection               | The server sends `session.created`, then an opening turn that ends in `done` or `error`.            |
| Reconnect to an active thread             | The same `conversation_id` is returned, followed by an opening turn that ends in `done` or `error`. |
| Connect after the prior thread was closed | A new conversation is created and receives its own opening turn.                                    |

## Outbound Text (SMS and iMessage)

To start an outbound SMS or iMessage conversation from your backend, use the create-conversation endpoint with an outbound `channel` (see [Starting an Outbound Conversation](#starting-an-outbound-conversation)). The platform resolves the sender from `use_case_id`, attempts the opening agent turn, and returns the durable conversation record. Triggers and other configured workflows can invoke related outbound paths. Recipient delivery remains a separate provider outcome, and an inbound reply resumes the active thread when routing succeeds.

### Inbound SMS and WhatsApp

Inbound messages enter through managed channel webhooks. The channel validates provider delivery and resolves the destination use case and bound service. SMS uses phone-based routing; WhatsApp uses a business-scoped recipient identifier that is not interchangeable with a phone number. Messages are serialized onto the current non-terminal conversation or create a new one when needed.

WhatsApp's managed free-form send path requires inbound activity within the preceding 23 hours. This delivery window is separate from conversation retention. Template registration does not enable template sending on the current path. See [WhatsApp Setup](/developer-guide/platform-api/platform-api/whatsapp-setup.md).

SMS suppression applies across the managed business setup and is checked separately from conversation lifecycle. On the supported toll-free path, START/UNSTOP alone does not remove setup-wide suppression. Workspace conversation errors and upstream channel errors have separate contracts; do not assume one universal opt-out status code. See [SMS](/developer-guide/platform-api/conversations/sms.md).

***

## World Model Integration

When an entity is bound, a supported runtime can load selected authorized fields from the current [world model](/developer-guide/platform-api/data-world-model.md) projection. Missing upstream data and projection lag still apply. Clinical and platform tools are available only when the selected service, runtime, Context Graph, and authorization policy expose them; do not assume the voice tool catalog is universal to text.

***

## Signal Processing

Turn-producing signals run through the same high-level reasoning pipeline, although the available signals and delivery behavior depend on the channel:

```mermaid
%%{init: {"theme":"base","fontFamily":"Inter, sans-serif","themeVariables":{"fontFamily":"Inter, sans-serif","fontSize":"16px","background":"#FAF8F6","primaryColor":"#F1EAE7","primaryTextColor":"#1A1817","primaryBorderColor":"#D7D2D0","secondaryColor":"#FDFCFB","tertiaryColor":"#F6F2F0","lineColor":"#575452","textColor":"#3D3937","clusterBkg":"#FDFCFB","clusterBorder":"#E8E2DF","edgeLabelBackground":"#FAF8F6","actorBkg":"#F1EAE7","actorTextColor":"#1A1817","actorBorder":"#D7D2D0","actorLineColor":"#575452","signalColor":"#575452","signalTextColor":"#3D3937","labelBoxBkgColor":"#F1EAE7","labelBoxBorderColor":"#D7D2D0","labelTextColor":"#3D3937","loopTextColor":"#3D3937","noteBkgColor":"#F6F2F0","noteBorderColor":"#D7D2D0","noteTextColor":"#3D3937","activationBkgColor":"#F1EAE7","activationBorderColor":"#D7D2D0","altSectionBkgColor":"#FAF8F6","altSectionColor":"#3D3937"}}}%%
flowchart TD
    signal["Signal arrives"]
    signal --> cut{"Turn boundary?"}
    cut -->|"message / surface submit / timeout"| yes["Yes"]
    cut -->|"delivery status / surface opened"| no["No-op"]
    yes --> nav["Navigate (reasoning engine, 60s timeout)"]
    nav --> respond["Send response"]
    nav --> complete["Terminal state - close"]
    nav --> tool["Tool call, then respond"]
```

| Signal                         | Turn?                     | What happens                                                                                              |
| ------------------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------- |
| `message.inbound`              | Yes                       | Full reasoning engine pass                                                                                |
| `surface.submitted`            | Yes                       | Clears `wait_for` condition                                                                               |
| `review.approved`              | Yes                       | Clears `wait_for`                                                                                         |
| `timeout.idle` / `timeout.max` | Channel runtime dependent | Can end long-lived channel runtimes; this is separate from the Sessions WebSocket transport idle timeout. |
| `delivery.status`              | No                        | Logged only                                                                                               |
| `surface.opened`               | No                        | Logged only                                                                                               |

***

## Intelligence

Every conversation produces an intelligence record:

| Field               | Description              |
| ------------------- | ------------------------ |
| `quality_score`     | 0-100, penalty-based     |
| `turn_count`        | Total turns              |
| `completion_reason` | Why it ended             |
| `final_state`       | Last context graph state |

Workspace events: `text.started` (session created) and `text.completed` (session ended, with duration, turn count, reason, final state).

***

## Switching Between REST and WebSocket

The two transports share durable storage, but their start and resume inputs are not symmetric:

| Question                                                | Answer                                                                                                                                 |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Can REST inspect a WebSocket conversation?              | Yes. Use the `conversation_id` from `session.created`.                                                                                 |
| Can REST continue a WebSocket conversation?             | Yes. Wait for the WebSocket turn to finish, then send a REST turn to that conversation ID. Avoid driving both transports concurrently. |
| Can WebSocket select a REST-created conversation?       | No. `sessions/connect` does not accept a conversation ID; it resolves by workspace, entity, and service.                               |
| Does WebSocket reconnect preserve its own conversation? | Yes, while the thread remains active. Reconnect with the same entity and service. A terminal thread produces a new conversation.       |
| Do I need to re-authenticate?                           | Yes. Each connection or request authenticates independently.                                                                           |

## Graceful Degradation

| Failure                                             | What happens                                                                                                                                                                  |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| WebSocket validation fails                          | The connection opens and immediately closes with `4000`, `4001`, `4004`, or `4503`. No `session.created` frame is sent.                                                       |
| Opening or user turn fails downstream               | The server sends an `error` frame. The socket normally remains open for another turn.                                                                                         |
| A turn, an idle socket, or the credential times out | The server sends `turn_timeout`, `idle_timeout`, or `credential_expiring`. See [Connection Limits](/developer-guide/platform-api/platform-api/sessions.md#connection-limits). |
| Socket closes before a terminal turn event          | Outcome is ambiguous. Read conversation detail before retrying the user message.                                                                                              |


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.concurrence.com/developer-guide/platform-api/conversations.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
