Conversations
Create, inspect, continue, switch, and close durable agent conversations over REST or WebSocket while preserving cross-channel history and state.
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.
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.
The REST operations are embedded in the relevant sections below. The Sessions guide covers the separate WebSocket contract and close codes.
Architecture
Conversation Lifecycle
Active
The stored status is non-terminal and updated_at is within the channel's activity threshold. This is a recency signal, not proof that a turn currently holds the conversation lock.
Dormant
The stored status is non-terminal 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.
What Gets Saved
Plan
Natural-language summary of the conversation state, written by an LLM when turns are compressed. Survives platform upgrades without schema migration.
Turns
Last 200 verbatim messages (role, text, timestamp).
Cursor
Channel-specific position marker.
Session Timing (WebSocket)
Idle timeout
5 minutes while waiting for the next client text frame
User-turn timeout
60 seconds
Maximum connection lifetime
No endpoint-specific maximum is part of the public contract
An idle timeout sends an idle_timeout error and closes the WebSocket, but it does not close the durable conversation. A user-turn timeout sends turn_timeout and leaves the socket available for a later turn. The Sessions WebSocket does not accept a stop client message; use the Conversations close endpoint to close the durable record.
REST API
Create a Conversation
API key issued via POST /v1/{workspace_id}/api-keys. Pass the returned api_key value as a Bearer token.
HSM execution channel type.
Determines how the HSM engine communicates with end users. Each kind maps to one or more providers.
webPossible values: Destination address for an outbound conversation (E.164 for sms/imessage). Required for non-web.
Channel-manager use case the outbound conversation is sent through. Required for non-web; channel-manager resolves the sender (FROM) from it (never caller-supplied). Must be owned by this workspace.
Optional context steering what the agent opens with on an outbound conversation.
Outbound thread-keyed channels (sms/imessage) only: close any existing ACTIVE conversation on the computed provider thread (recipient + use case) before dispatching, so the opener materializes a brand-new conversation instead of continuing the old thread. Rejected with 422 on channel=web — every web create already starts a new conversation, so force_new is meaningless there.
falseSuccessful Response
HSM execution channel type.
Determines how the HSM engine communicates with end users. Each kind maps to one or more providers.
0True when the conversation recorded more entries than one read takes (10,000), so the turns stop before the end of the conversation.
falseValidation Error
POST /v1/{workspace_id}/conversations HTTP/1.1
Host: platform.api-us.concurrence.com
Authorization: Bearer YOUR_SECRET_TOKEN
Content-Type: application/json
Accept: */*
Content-Length: 271
{
"service_id": "123e4567-e89b-12d3-a456-426614174000",
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
"channel": "web",
"recipient": "text",
"use_case_id": "123e4567-e89b-12d3-a456-426614174000",
"instruction": "text",
"force_new": false,
"context": {
"ANY_ADDITIONAL_PROPERTY": 1
}
}{
"id": "123e4567-e89b-12d3-a456-426614174000",
"channel_kind": "voice",
"status": "active",
"lifecycle": "active",
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
"service_id": "123e4567-e89b-12d3-a456-426614174000",
"direction": "text",
"turn_count": 0,
"duration_seconds": 1,
"created_at": "text",
"updated_at": "text",
"call_sid": "text",
"caller_id": "text",
"phone_number": "text",
"quality_score": 1,
"has_recording": true,
"completion_reason": "text",
"escalation_status": "text",
"final_state": "text",
"source": "text",
"turns": [
{
"turn_id": "123e4567-e89b-12d3-a456-426614174000",
"turn_index": 1,
"role": "agent",
"text": "text",
"timestamp": "text",
"channel": "voice",
"content": [
{
"type": "text",
"text": "text",
"url": "text",
"media_type": "text",
"provider_id": "text",
"metadata": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
],
"tool_calls": [
{
"tool_name": "text",
"call_id": "text",
"input": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"result": "",
"succeeded": true,
"duration_ms": 1,
"error_message": "text"
}
],
"artifacts": [
{
"type": "text",
"schema_version": "text",
"payload": "[Circular Reference]"
}
],
"selected_action_description": "text",
"state_transitions": [
{
"from": "text",
"to": "text"
}
],
"available_actions": [
{
"description": "text"
}
]
}
],
"truncated": false,
"plan": "text",
"voice": {
"recording_path": "text",
"twilio_recording_sid": "text",
"twilio_recording_duration": 1,
"has_recording": false,
"emotional_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"barge_in_events": [
{
"ANY_ADDITIONAL_PROPERTY": "anything"
}
],
"states_visited": [
"text"
],
"participants": [
{
"ANY_ADDITIONAL_PROPERTY": "anything"
}
],
"config": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"call_analysis": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"forwarding": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"escalation": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"safety": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"quality_breakdown": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"risk_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"latency_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"conversation_metrics": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"tool_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"safety_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"operator_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
}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 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 currently 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:
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. force_new is rejected with 422 on channel=web - web conversations always start fresh.
Send a Turn
Send a user message and receive the agent's response. Set Accept: text/event-stream to receive an SSE stream of typed TurnStreamEvent frames (token, tool_call_started, tool_call_completed, thinking, message, done, error) instead of the synchronous JSON response. For new integrations prefer POST /turns/stream, which is always SSE.
API key issued via POST /v1/{workspace_id}/api-keys. Pass the returned api_key value as a Bearer token.
Include tool call details in response
falsePoll for a background result without sending a user message. Protocol v2 claims at most one ready answer and returns an opaque delivery receipt; render or durably persist the answer, then acknowledge that receipt. Returns empty output when nothing is ready. Must NOT be combined with a request-body message (422) or SSE streaming (422). Poll no more than once every ~5s per conversation.
falseStable UUID for replaying the same logical send or poll after an ambiguous failure. Reuse the same value only for that operation. Omission is backward-compatible but cannot provide client-controlled response-loss replay.
The user's message. Leave it empty (with no content or media) only to kick off a conversation that has no turns yet: the agent answers with its greeting. Once the conversation has a turn, a turn with no user input is a 422.
""Injected into the agent's prompt as caller/patient context for this turn.
Synchronous callers only: when a turn hands work to a background tool (background_pending), await the real answer inline (bounded, ~30s) instead of returning the acknowledgement immediately. On completion the response carries the final answer with background_pending=false; on timeout it returns background_pending=true — resolve it later with poll=true. null (default) inherits the channel policy (web = do not wait). Ignored with poll=true (422).
Synchronous callers only: when a turn ends background_pending, omit the filler/acknowledgement text from output so a batch caller never mistakes the ack for the answer (output is empty; background_pending=true is the poll-later signal). null (default) inherits the channel policy. Ignored with poll=true (422).
Successful Response
Identifier of the user exchange this turn created — or, for poll=true and greeting-kickoff turns (empty message), the exchange the returned messages attach to. A background delivery keeps the originating exchange even when later user turns already exist. Deterministic: matches the turn_id on this conversation's history turns, so it can anchor durable per-turn artifacts (e.g. feedback) across page reloads. Null only when the conversation has no user exchange yet (a poll or kickoff before the first user message).
Whether this turn's output is the final answer, or only an acknowledgement that
work is still running in the background.
false (default): output is the complete agent response for this turn.
true: a tool crossed the server's blocking window and was handed to a background
task, so output is NOT the final answer — the definitive assistant answer is
produced later, out-of-band. Under delivery protocol v2, collect it by re-issuing
POST …/turns with poll=true (a no-message, receipt-backed claim), render or
durably persist the returned answer, then acknowledge its exact delivery receipt.
This is the per-conversation delivery contract — see
the web-integration paved path. (GET /v1/{workspace_id}/events/stream?conversation_id=
mirrors this activity as text.tool_started / text.background_result /
text.agent_message for a view watching this one conversation, but is not the
per-chat delivery path. The stream without conversation_id carries none of it.) A
client that treats a background_pending=true turn as complete without polling and
acknowledging will miss the final answer.
falseVersion of the durable background-delivery protocol supported by the serving agent. Clients may retry polls only after observing version 2.
Validation Error
POST /v1/{workspace_id}/conversations/{conversation_id}/turns HTTP/1.1
Host: platform.api-us.concurrence.com
Authorization: Bearer YOUR_SECRET_TOKEN
Content-Type: application/json
Accept: */*
Content-Length: 300
{
"message": "",
"content": [
{
"type": "text",
"text": "text",
"url": "text",
"media_type": "text",
"provider_id": "text",
"metadata": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
],
"media_url": "text",
"media_type": "text",
"context": "text",
"viewport_width": 1,
"viewport_height": 1,
"wait_for_final": true,
"suppress_filler": true
}{
"turn_id": "123e4567-e89b-12d3-a456-426614174000",
"input": {
"turn_id": "123e4567-e89b-12d3-a456-426614174000",
"turn_index": 1,
"role": "agent",
"text": "text",
"timestamp": "text",
"channel": "voice",
"content": [
{
"type": "text",
"text": "text",
"url": "text",
"media_type": "text",
"provider_id": "text",
"metadata": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
],
"tool_calls": [
{
"tool_name": "text",
"call_id": "text",
"input": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"result": "",
"succeeded": true,
"duration_ms": 1,
"error_message": "text"
}
],
"artifacts": [
{
"type": "text",
"schema_version": "text",
"payload": {
"ANY_ADDITIONAL_PROPERTY": "[Circular Reference]"
}
}
],
"selected_action_description": "text",
"state_transitions": [
{
"from": "text",
"to": "text"
}
],
"available_actions": [
{
"description": "text"
}
]
},
"output": [
{
"turn_id": "123e4567-e89b-12d3-a456-426614174000",
"turn_index": 1,
"role": "agent",
"text": "text",
"timestamp": "text",
"channel": "voice",
"content": [
{
"type": "text",
"text": "text",
"url": "text",
"media_type": "text",
"provider_id": "text",
"metadata": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
],
"tool_calls": [
{
"tool_name": "text",
"call_id": "text",
"input": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"result": "",
"succeeded": true,
"duration_ms": 1,
"error_message": "text"
}
],
"artifacts": [
{
"type": "text",
"schema_version": "text",
"payload": {
"ANY_ADDITIONAL_PROPERTY": "[Circular Reference]"
}
}
],
"selected_action_description": "text",
"state_transitions": [
{
"from": "text",
"to": "text"
}
],
"available_actions": [
{
"description": "text"
}
]
}
],
"tool_calls": [
{
"tool_name": "text",
"call_id": "text",
"input": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"result": "",
"succeeded": true,
"duration_ms": 1,
"error_message": "text"
}
],
"artifacts": [
{
"type": "text",
"schema_version": "text",
"payload": {
"ANY_ADDITIONAL_PROPERTY": "[Circular Reference]"
}
}
],
"conversation": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"status": "active",
"turn_count": 0,
"updated_at": "text",
"context_graph_state": {
"type": "text",
"name": "text",
"objective": "text",
"actions": [
{
"description": "text",
"filler_hint": "text"
}
],
"intra_state_navigation_guidelines": [
"text"
],
"action_guidelines": [
"text"
],
"boundary_constraints": [
"text"
],
"exit_conditions": [
{
"description": "text",
"next_state": "text",
"filler_hint": "text"
}
],
"action_tool_call_specs": [
{
"tool_id": "text",
"additional_instruction": "",
"navigate_on_completion": false,
"progress": {
"deterministic": false,
"phrases": [
"text"
],
"trigger_delay_ms": 1,
"interval_ms": 1,
"mode": "auto",
"progress_class": "lookup",
"expected_latency_ms": 1,
"custom_phrase": "text"
},
"result_persistence": "accumulate",
"delivery": "interrupt",
"failure_delivery": "interrupt",
"failure_delivery_by_class": {
"ANY_ADDITIONAL_PROPERTY": "interrupt"
},
"execution": "blocking",
"lifecycle": "independent",
"context_bindings": [
"text"
],
"context_binding_aliases": {
"ANY_ADDITIONAL_PROPERTY": "text"
},
"audio_fillers": [
"text"
],
"audio_filler_triggered_after": 1
}
],
"exit_condition_tool_call_specs": [
{
"tool_id": "text",
"additional_instruction": "",
"navigate_on_completion": false,
"progress": {
"deterministic": false,
"phrases": [
"text"
],
"trigger_delay_ms": 1,
"interval_ms": 1,
"mode": "auto",
"progress_class": "lookup",
"expected_latency_ms": 1,
"custom_phrase": "text"
},
"result_persistence": "accumulate",
"delivery": "interrupt",
"failure_delivery": "interrupt",
"failure_delivery_by_class": {
"ANY_ADDITIONAL_PROPERTY": "interrupt"
},
"execution": "blocking",
"lifecycle": "independent",
"context_bindings": [
"text"
],
"context_binding_aliases": {
"ANY_ADDITIONAL_PROPERTY": "text"
},
"audio_fillers": [
"text"
],
"audio_filler_triggered_after": 1
}
],
"wait_for": "surface_submission",
"guardrails": [
{
"name": "text",
"description": "text",
"enforcement": "hard"
}
],
"channel_overrides": {
"ANY_ADDITIONAL_PROPERTY": {
"objective": "text",
"action_guidelines": [
"text"
],
"progress": {
"deterministic": false,
"phrases": [
"text"
],
"trigger_delay_ms": 1,
"interval_ms": 1,
"mode": "auto",
"progress_class": "lookup",
"expected_latency_ms": 1,
"custom_phrase": "text"
}
}
},
"surface_spec_template": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"turn_policy": {
"barge_in_enabled": true,
"safety_response": "stay_empathize",
"context_strategy": "full",
"degradation_threshold": -1,
"block_forward_call": false,
"block_forward_call_after_turns": -1,
"stt_eot_threshold": 1,
"stt_eager_eot_threshold": 1,
"stt_eot_timeout_ms": 1
},
"preload": [
{
"tool_id": "text",
"params": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
]
}
},
"background_pending": false,
"delivery": {
"delivery_id": "123e4567-e89b-12d3-a456-426614174000",
"request_id": "123e4567-e89b-12d3-a456-426614174000",
"receipt": "123e4567-e89b-12d3-a456-426614174000"
},
"delivery_protocol_version": 1
}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 token-by-token output and tool-call telemetry as a typed SSE event stream - see Streaming Turns.
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. A 409 is returned when the conversation is closed or another turn is already being processed (see 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.
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 crossed the server's blocking window and 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.
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.
Model rejections. 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 the turn returns 500 "Agent response failed". Neither includes the upstream response text. A retry with the same key replays the failure, so fix the rejected configuration or content and send a new turn.
Background (deferred) tool results
Some tools take longer than the server will hold a turn open (for example a multi-step lookup or an external system call). When that happens the turn returns with background_pending: true and an acknowledgement in output; the tool continues outside the request window. 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.
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=truewith no message - a drain-and-report request that returns completed results and reports whether more work remains. 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.
Two optional per-turn request fields tune this behavior for synchronous callers:
wait_for_final- when a turn hands work to a background tool, await the real answer inline (bounded, ~30s) instead of returning the acknowledgement immediately. On completion the response carries the final answer withbackground_pending: false; on timeout it returnsbackground_pending: true- resolve it later withpoll=true.null(default) inherits the channel policy (web = do not wait).suppress_filler- when a turn endsbackground_pending, omit the filler/acknowledgement text fromoutputso a batch caller never mistakes the acknowledgement for the answer (outputis empty;background_pending: trueis the poll-later signal).null(default) inherits the channel policy.
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.
Streaming variant of POST /turns. Always returns text/event-stream regardless of the Accept header — no JSON fallback. Each frame is a TurnStreamEvent discriminated by the event field (token, tool_call_started, tool_call_completed, thinking, message, done, error). Use this endpoint for new integrations; the Accept-sniffing variant remains for backward compatibility.
API key issued via POST /v1/{workspace_id}/api-keys. Pass the returned api_key value as a Bearer token.
Include tool_call_started / tool_call_completed frames in the stream
falseStable UUID for replaying the same logical streamed user-input turn.
The user's message. Leave it empty (with no content or media) only to kick off a conversation that has no turns yet: the agent answers with its greeting. Once the conversation has a turn, a turn with no user input is a 422.
""Injected into the agent's prompt as caller/patient context for this turn.
Synchronous callers only: when a turn hands work to a background tool (background_pending), await the real answer inline (bounded, ~30s) instead of returning the acknowledgement immediately. On completion the response carries the final answer with background_pending=false; on timeout it returns background_pending=true — resolve it later with poll=true. null (default) inherits the channel policy (web = do not wait). Ignored with poll=true (422).
Synchronous callers only: when a turn ends background_pending, omit the filler/acknowledgement text from output so a batch caller never mistakes the ack for the answer (output is empty; background_pending=true is the poll-later signal). null (default) inherits the channel policy. Ignored with poll=true (422).
SSE stream of TurnStreamEvent frames
Conversation or service not found
Conversation is closed
Conversation is missing or has corrupt service binding
Agent service unavailable
POST /v1/{workspace_id}/conversations/{conversation_id}/turns/stream HTTP/1.1
Host: platform.api-us.concurrence.com
Authorization: Bearer YOUR_SECRET_TOKEN
Content-Type: application/json
Accept: */*
Content-Length: 300
{
"message": "",
"content": [
{
"type": "text",
"text": "text",
"url": "text",
"media_type": "text",
"provider_id": "text",
"metadata": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
],
"media_url": "text",
"media_type": "text",
"context": "text",
"viewport_width": 1,
"viewport_height": 1,
"wait_for_final": true,
"suppress_filler": true
}No content
Example using the regular turns operation:
The server keeps the connection open and emits a typed sequence of events (event: lines) terminated by done on success or error on failure. Concurrency and lock semantics match the JSON path. A disconnect before the terminal event has an ambiguous outcome, so read the durable conversation before retrying.
Example wire format
Streaming behavior
The stream completes when a
doneevent is received. Always treatdoneas the signal to close the reader; do not rely on connection-close alone.Render
messageevents as the authoritative completed agent messages.tokenevents are a progressive preview of the reply. Whenmessagearrives, replace the preview with themessagetext instead of appending it, and commit only themessagetext to the transcript.Tool-backed turns can emit
tool_call_startedandtool_call_completedbefore the finalmessage, but those events can arrive after the turn'stokenevents. Do not rely on the order oftokenand tool-call events.Tool calls can run before the first
tokenarrives. Show a pending indicator until the firsttokenormessageevent, and keep the response open untildone.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 nodata:line. Standard SSE parsers skip it, and a hand-written parser must skip it too.A mid-stream
errorevent is terminal - nodonefollows. Branch oncodeandretryable, and retry with backoff only whenretryableis true.Check
retryablebefore retrying. If the conversation is closed while a streamed turn is running, for example by a close from another client, the stream ends with this terminal frame:It is the in-stream form of the
409 Conversation is closedreturned 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-streamedtokenpreview standing as the answer.A streamed turn that times out without a reply ends with a retryable
errorframe (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-retryableerrorframe (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
errorframe (code: upstream_error,retryable: false,status_code: 502) whose message isModel 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
tokenevents but the final reply carries no agent message, the stream still sends amessageevent with emptytextbeforedone, so the completed emptymessagereplaces the token preview rather than leaving the preview as the answer. A turn that relayed no tokens and produced no message sends nomessageevent beforedone.If the client disconnects before
done, do not infer whether the turn failed or completed from the disconnect alone. ReadGET /v1/{workspace_id}/conversations/{conversation_id}before deciding whether to retry.Standard HTTP status codes (404, 409, 503) are returned before the stream begins. Once the response body starts, status is 200 and failures arrive as
errorevents.
Concurrency
REST turns are serialized per conversation. Only one turn can be in flight at a time for a given conversation. Different conversations can process turns simultaneously - the lock is per-conversation, not global.
Send a turn while the previous turn is still processing
409 Conflict - "Conversation is already active".
Send a REST turn while a WebSocket turn is processing
409 Conflict - "Conversation is already active". An idle open WebSocket does not hold the conversation lock.
Send a turn after the previous turn returned
Works normally.
Send a turn to a closed conversation
409 Conflict - "Conversation is closed".
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.
GET the conversation while a turn is processing
Works - read endpoints are never blocked.
Distinguishing "busy" from "closed"
Both return 409, but the response body differs:
Check the detail string to decide whether to retry or stop.
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
On 409 with "Conversation is already active", retry with bounded exponential backoff and jitter. GET /conversations/{conversation_id} can show whether new turns were persisted, but its derived lifecycle is a recency view rather than live lock ownership. Stop retrying if the conversation becomes terminal.
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. Read the conversation and inspect lifecycle and turns before retrying so you do not create a duplicate user turn.
Lock release timing
Conversation ownership is released when the turn finishes. After an interrupted request, a later turn can still return 409 while cleanup is in progress. The public REST surface does not provide an in-progress turn cancellation endpoint.
Browse Conversations
Use the unified runs inventory with kind=conversation to browse voice, text, SMS, email, and web activity. The retired conversation-list endpoint is no longer part of the public contract. Open a returned run's source_conversation_id with conversation detail when you need the full transcript.
Paginated, newest-first list of runs for the workspace behind the unified Run contract. Federates framework runs (Delta world.runs MV) and conversation runs (Lakebase world.conversations) at read time. Filter by kind (conversation / framework), channel (voice/sms/whatsapp/email/web/imessage — conversation runs only), status (live expands to running + paused), service_id (service UUIDs), and search (entity name, caller ID or phone number). Repeated filter values are ORed within each axis; axes combine with AND. Service filtering applies to conversation and live voice runs before pagination. Historical framework runs have no stored service attribution and are excluded when service_id is set. continuation_token is an opaque page cursor.
API key issued via POST /v1/{workspace_id}/api-keys. Pass the returned api_key value as a Bearer token.
[]50Filter conversation and live voice runs by up to 100 service IDs. Historical framework runs have no stored service attribution and are excluded. Repeated values are ORed; combines with other filters using AND.
[]Case-insensitive substring match on a conversation run's entity name, caller ID or phone number, applied before pagination. Framework runs carry none of these and are excluded when search is set. Combines with other filters using AND.
Successful Response
Validation Error
GET /v1/{workspace_id}/runs HTTP/1.1
Host: platform.api-us.concurrence.com
Authorization: Bearer YOUR_SECRET_TOKEN
Accept: */*
{
"items": [
{
"run_id": "123e4567-e89b-12d3-a456-426614174000",
"workspace_id": "123e4567-e89b-12d3-a456-426614174000",
"kind": "conversation",
"status": "running",
"channel": "voice",
"framework": "claude-agent-sdk",
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
"service_id": "123e4567-e89b-12d3-a456-426614174000",
"started_at": "2026-01-01T00:00:00.000Z",
"ended_at": "2026-01-01T00:00:00.000Z",
"entity_name": "text",
"service_name": "text",
"caller_id": "text",
"phone_number": "text",
"direction": "text",
"turn_count": 1,
"completion_reason": "text",
"source_call_sid": "text",
"source_conversation_id": "123e4567-e89b-12d3-a456-426614174000",
"source_framework_run_id": "123e4567-e89b-12d3-a456-426614174000",
"takeover": {
"eligible": true,
"mode_options": [
"listen"
],
"reason": "text"
}
}
],
"has_more": true,
"continuation_token": null
}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.
Move an active conversation onto a different channel (sms/imessage today). The durable conversation id is preserved — the same conversation continues; only its routing changes. Optionally dispatch a first agent turn on the new channel.
API key issued via POST /v1/{workspace_id}/api-keys. Pass the returned api_key value as a Bearer token.
Target channel to move the conversation to (sms or imessage).
Recipient address on the new channel (E.164). Required for sms/imessage.
Channel-manager use case for the new channel (resolves the sender). Required for sms/imessage.
Why the conversation is being moved (e.g. escalation, customer_request).
If true, immediately drive one agent turn on the new channel (e.g. 'I'll text you now').
falseOptional context steering the opener when dispatch_opener=true.
Successful Response
HSM execution channel type.
Determines how the HSM engine communicates with end users. Each kind maps to one or more providers.
0True when the conversation recorded more entries than one read takes (10,000), so the turns stop before the end of the conversation.
falseValidation Error
POST /v1/{workspace_id}/conversations/{conversation_id}/channel HTTP/1.1
Host: platform.api-us.concurrence.com
Authorization: Bearer YOUR_SECRET_TOKEN
Content-Type: application/json
Accept: */*
Content-Length: 152
{
"channel": "voice",
"recipient": "text",
"use_case_id": "123e4567-e89b-12d3-a456-426614174000",
"reason": "text",
"dispatch_opener": false,
"instruction": "text"
}{
"id": "123e4567-e89b-12d3-a456-426614174000",
"channel_kind": "voice",
"status": "active",
"lifecycle": "active",
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
"service_id": "123e4567-e89b-12d3-a456-426614174000",
"direction": "text",
"turn_count": 0,
"duration_seconds": 1,
"created_at": "text",
"updated_at": "text",
"call_sid": "text",
"caller_id": "text",
"phone_number": "text",
"quality_score": 1,
"has_recording": true,
"completion_reason": "text",
"escalation_status": "text",
"final_state": "text",
"source": "text",
"turns": [
{
"turn_id": "123e4567-e89b-12d3-a456-426614174000",
"turn_index": 1,
"role": "agent",
"text": "text",
"timestamp": "text",
"channel": "voice",
"content": [
{
"type": "text",
"text": "text",
"url": "text",
"media_type": "text",
"provider_id": "text",
"metadata": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
],
"tool_calls": [
{
"tool_name": "text",
"call_id": "text",
"input": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"result": "",
"succeeded": true,
"duration_ms": 1,
"error_message": "text"
}
],
"artifacts": [
{
"type": "text",
"schema_version": "text",
"payload": "[Circular Reference]"
}
],
"selected_action_description": "text",
"state_transitions": [
{
"from": "text",
"to": "text"
}
],
"available_actions": [
{
"description": "text"
}
]
}
],
"truncated": false,
"plan": "text",
"voice": {
"recording_path": "text",
"twilio_recording_sid": "text",
"twilio_recording_duration": 1,
"has_recording": false,
"emotional_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"barge_in_events": [
{
"ANY_ADDITIONAL_PROPERTY": "anything"
}
],
"states_visited": [
"text"
],
"participants": [
{
"ANY_ADDITIONAL_PROPERTY": "anything"
}
],
"config": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"call_analysis": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"forwarding": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"escalation": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"safety": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"quality_breakdown": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"risk_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"latency_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"conversation_metrics": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"tool_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"safety_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"operator_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
}Requires the Conversation:SwitchChannel permission (member tier and above). External users cannot switch channels.
Only sms and imessage are supported switch targets today; email, whatsapp, and voice return 501. Voice conversations cannot be switched. A successful switch preserves history and the compressed plan, and later replies on the new channel continue the same conversation.
Error Responses
Gates are evaluated in this order:
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
API key issued via POST /v1/{workspace_id}/api-keys. Pass the returned api_key value as a Bearer token.
Include per-turn tool_calls[] in the returned turns. Off by default so the payload stays small and (potentially PHI-bearing) tool output is opt-in, matching POST /turns?include_tool_calls=true.
falseSuccessful Response
HSM execution channel type.
Determines how the HSM engine communicates with end users. Each kind maps to one or more providers.
0True when the conversation recorded more entries than one read takes (10,000), so the turns stop before the end of the conversation.
falseValidation Error
GET /v1/{workspace_id}/conversations/{conversation_id} HTTP/1.1
Host: platform.api-us.concurrence.com
Authorization: Bearer YOUR_SECRET_TOKEN
Accept: */*
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"channel_kind": "voice",
"status": "active",
"lifecycle": "active",
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
"service_id": "123e4567-e89b-12d3-a456-426614174000",
"direction": "text",
"turn_count": 0,
"duration_seconds": 1,
"created_at": "text",
"updated_at": "text",
"call_sid": "text",
"caller_id": "text",
"phone_number": "text",
"quality_score": 1,
"has_recording": true,
"completion_reason": "text",
"escalation_status": "text",
"final_state": "text",
"source": "text",
"turns": [
{
"turn_id": "123e4567-e89b-12d3-a456-426614174000",
"turn_index": 1,
"role": "agent",
"text": "text",
"timestamp": "text",
"channel": "voice",
"content": [
{
"type": "text",
"text": "text",
"url": "text",
"media_type": "text",
"provider_id": "text",
"metadata": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
],
"tool_calls": [
{
"tool_name": "text",
"call_id": "text",
"input": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"result": "",
"succeeded": true,
"duration_ms": 1,
"error_message": "text"
}
],
"artifacts": [
{
"type": "text",
"schema_version": "text",
"payload": "[Circular Reference]"
}
],
"selected_action_description": "text",
"state_transitions": [
{
"from": "text",
"to": "text"
}
],
"available_actions": [
{
"description": "text"
}
]
}
],
"truncated": false,
"plan": "text",
"voice": {
"recording_path": "text",
"twilio_recording_sid": "text",
"twilio_recording_duration": 1,
"has_recording": false,
"emotional_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"barge_in_events": [
{
"ANY_ADDITIONAL_PROPERTY": "anything"
}
],
"states_visited": [
"text"
],
"participants": [
{
"ANY_ADDITIONAL_PROPERTY": "anything"
}
],
"config": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"call_analysis": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"forwarding": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"escalation": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"safety": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"quality_breakdown": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"risk_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"latency_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"conversation_metrics": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"tool_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"safety_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"operator_summary": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
}Returns the full conversation including turns and the compressed plan (if frozen).
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.
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.
Page through the turns GET /conversations/{conversation_id} returns, ordered by exchange (turn_index). The default +turn_index starts with the messages before the first user turn; -turn_index starts with the newest exchange and ends with them. Messages inside one exchange keep conversation order either way. Turns are folded from the conversation's journal on each request rather than read from a table, so truncated matches the detail's flag. When it is true, the turns stop at the read bound, before the end of the conversation, in either sort order.
API key issued via POST /v1/{workspace_id}/api-keys. Pass the returned api_key value as a Bearer token.
[]50Include per-turn tool_calls[] in the returned turns. Off by default, matching GET /conversations/{conversation_id}?include_tool_calls=true.
falseSuccessful Response
True when the conversation recorded more entries than one read takes (10,000), so the turns stop before the end of the conversation.
Validation Error
GET /v1/{workspace_id}/conversations/{conversation_id}/turns HTTP/1.1
Host: platform.api-us.concurrence.com
Authorization: Bearer YOUR_SECRET_TOKEN
Accept: */*
{
"items": [
{
"turn_id": "123e4567-e89b-12d3-a456-426614174000",
"turn_index": 1,
"role": "agent",
"text": "text",
"timestamp": "text",
"channel": "voice",
"content": [
{
"type": "text",
"text": "text",
"url": "text",
"media_type": "text",
"provider_id": "text",
"metadata": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}
],
"tool_calls": [
{
"tool_name": "text",
"call_id": "text",
"input": {
"ANY_ADDITIONAL_PROPERTY": "anything"
},
"result": "",
"succeeded": true,
"duration_ms": 1,
"error_message": "text"
}
],
"artifacts": [
{
"type": "text",
"schema_version": "text",
"payload": "[Circular Reference]"
}
],
"selected_action_description": "text",
"state_transitions": [
{
"from": "text",
"to": "text"
}
],
"available_actions": [
{
"description": "text"
}
]
}
],
"has_more": true,
"continuation_token": null,
"truncated": true
}Close a Conversation
API key issued via POST /v1/{workspace_id}/api-keys. Pass the returned api_key value as a Bearer token.
Successful Response
No content
Validation Error
DELETE /v1/{workspace_id}/conversations/{conversation_id} HTTP/1.1
Host: platform.api-us.concurrence.com
Authorization: Bearer YOUR_SECRET_TOKEN
Accept: */*
No content
Closes the conversation permanently. Subsequent turns return 409.
Example: Full REST Flow
WebSocket Sessions
Use Sessions 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
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.
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
1000
Normal close, including the close after an idle_timeout 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 and the Sessions API reference.
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.
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.
Tool-call output can include sensitive operational data, so it is opt-in rather than included by default.
Greeting Behavior
Every successful connection
The server sends session.created, then runs an opening agent turn.
Reconnect to an active thread
The same conversation_id is returned, and another opening agent turn runs.
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). 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.
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.
World Model Integration
When an entity is bound, a supported runtime can load selected authorized fields from the current world model 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:
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:
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:
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
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.
User turn exceeds 60 seconds
The server sends turn_timeout with retryable: true; the socket remains open.
No client frame arrives for 5 minutes
The server sends idle_timeout, then closes the socket normally without closing the durable conversation.
Socket closes before a terminal turn event
Outcome is ambiguous. Read conversation detail before retrying the user message.
Last updated
Was this helpful?

