Realtime (WebSocket)
wss://api.infermedica.com/api/ct/v2/realtimeThe WebSocket transport provides a live conversation session with incremental assistant output and conversation-level state updates over a single long-lived connection. It exposes the same conversation model as HTTP v2 — a conversation created over WebSocket can be read over HTTP and vice versa.
Use it for voice channels, for interruption handling, and for sessions where reconnect-and-resume matters more than request/response simplicity.
Authentication
Send the bearer token in the Authorization header when establishing the connection. If authentication fails before the upgrade completes, the server closes the socket with code 1008 (policy violation) — there is no HTTP error body to read.
Connection lifecycle
- Connect with a valid
Authorizationheader. The server accepts the upgrade. - Send
conversation.createorconversation.resumeas the first frame. - Receive
conversation.createdorconversation.resumedwith the full conversation snapshot. - Exchange live conversation events. The server sends
pingevery 30 seconds; reply withpong. - Either side may close the socket. Reconnect later with
conversation.resume.
Handshake failures are explicit:
| Situation | What the server does |
|---|---|
First frame that is not a valid conversation.create or conversation.resume — unparseable, or failing field validation | error with code invalid_payload, then close 1007. |
| A parseable first frame of any other type | error with code invalid_message_type, then a normal 1000 close. |
| Resuming a conversation that does not exist | error with code conversation_not_found, then close 1000. |
conversation.create seeding evidence the knowledge base does not know | error with code unknown_evidence, then close 1000. |
| Connection rejected by the rate limiter | error with code rate_limit_exceeded, then close 1008. |
Client events
Only conversation.create and conversation.resume are valid as the first frame. After the handshake you may send conversation.user_message.create, conversation.settings.update, and pong.
conversation.create
Opens a new conversation. conversation is a ConversationInput — the same shape as the HTTP create body.
{
"type": "conversation.create",
"conversation": {
"settings": {
"language": "en",
"channel": "text",
"ui_widgets_enabled": false,
"opening_message_enabled": false
},
"assessment": {
"age": { "value": 35, "unit": "year" },
"sex": "female",
"relationship": "self",
"evidence": []
}
}
}conversation.resume
Resumes an existing conversation by id.
{
"type": "conversation.resume",
"conversation_id": "68d9a84d-711c-4a55-a9dd-da6363e58e38"
}conversation.user_message.create
Creates a user message and starts a turn. user_message is a UserMessageInput.
{
"type": "conversation.user_message.create",
"user_message": {
"id": "68d9a84d-711c-4a55-a9dd-da6363e58e38",
"content": "I have a headache",
"is_interrupted": true,
"played_content": "How long have you had the",
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
}
}When the user did not interrupt, omit is_interrupted and played_content — or set is_interrupted to false and omit played_content.
is_interrupted and played_content are a record of what the user actually heard, not a control flag: they tell the engine how much of the previous reply landed. Preemption happens because of the transport, not because of these fields — sending a message mid-turn preempts the active turn whether or not you set them.
conversation.settings.update
Partial settings update; omitted fields keep their current value.
{
"type": "conversation.settings.update",
"settings": { "language": "pl", "channel": "voice" }
}pong
Reply to the server's ping.
{ "type": "pong" }Server events
The server emits the shared streaming events — the same payloads as HTTP NDJSON — plus four transport-only events documented here.
conversation.created
Acknowledges conversation.create. Always the first server event after a successful create handshake.
{
"type": "conversation.created",
"conversation": {
"id": "68d9a84d-711c-4a55-a9dd-da6363e58e38",
"status": "in_progress",
"settings": { "...": "..." },
"assessment": { "...": "..." },
"messages": [],
"is_terminated": false
}
}| Field | Type | Description |
|---|---|---|
conversation | Conversation | Full snapshot of the newly created conversation. |
conversation.resumed
Acknowledges conversation.resume with the full snapshot, including history.
{
"type": "conversation.resumed",
"conversation": {
"id": "68d9a84d-711c-4a55-a9dd-da6363e58e38",
"status": "in_progress",
"settings": { "...": "..." },
"assessment": { "...": "..." },
"messages": [
{
"role": "user",
"content": "I have a headache",
"id": "68d9a84d-711c-4a55-a9dd-da6363e58e38",
"turn_id": "a1b2c3d4-0000-0000-0000-000000000000"
},
{
"role": "assistant",
"id": "6f605890-ff28-4b6d-a17f-d410f9faf558",
"kind": "follow_up",
"content_parts": [
{ "type": "text", "text": "How long have you had it?" }
],
"turn_id": "a1b2c3d4-0000-0000-0000-000000000000"
}
],
"is_terminated": false
}
}conversation.settings.updated
Acknowledges conversation.settings.update with the effective settings after the merge.
{
"type": "conversation.settings.updated",
"settings": {
"language": "pl",
"channel": "voice",
"ui_widgets_enabled": false,
"opening_message_enabled": false,
"risk_factors_enabled": true,
"geographic_risk_factors_enabled": false,
"handoff_policies": [],
"max_message_length": 1500
}
}ping
Keep-alive heartbeat sent every 30 seconds. Reply with pong.
{ "type": "ping" }This heartbeat is not enforced: a missing pong does not close the connection, and no close code reports one. Keep replying anyway — the exchange is what tells you the socket is still live, and enforcement may be added.
What actually drops a dead connection is the protocol-level ping/pong of the underlying WebSocket layer, which is separate from these frames. Most client libraries answer protocol pings automatically; leave that behaviour enabled, or the server will close a connection that looks healthy to your application.
Conversation flow
An end-to-end session: handshake, one or more non-terminal turns, a terminal turn that closes with conversation.completed, then close.
Client Server
│ │
├─ conversation.create ────────────────────────────────────▶│
│◀─────────────────────────────────── conversation.created ─┤
│ │
├─ conversation.user_message.create ───────────────────────▶│
│◀────────────────────── conversation.user_message.created ─┤
│◀─────────────────── conversation.assistant_message.delta ─┤ (×N)
│◀─────────────── conversation.assistant_message.ui_widget ─┤ (×0..N, transient)
│◀─────────────── conversation.assistant_message.completed ─┤
│ (delta … completed repeat per assistant message) │
│◀──────────────────────── conversation.assessment.updated ─┤
│◀──────────────────────── conversation.handoff.requested ──┤ (optional)
│◀──────────────────────────── conversation.turn.completed ─┤
│ │
│ (repeat for each non-terminal turn) │
│ (ping every 30 s, pong reply, while connected) │
│ │
├─ conversation.user_message.create ───────────────────────▶│ ← final turn
│◀────────────────────── conversation.user_message.created ─┤
│◀─────────────────── conversation.assistant_message.delta ─┤ (×N)
│◀─────────────── conversation.assistant_message.completed ─┤
│◀──────────────────────── conversation.assessment.updated ─┤
│◀───────────────────────────────── conversation.completed ─┤
│◀──────────────────────────── conversation.turn.completed ─┤
│ │
├─ [close 1000] ───────────────────────────────────────────▶│For a conversation.resume first frame, swap conversation.create / conversation.created for conversation.resume / conversation.resumed; the rest is identical.
Client rules
Widgets may arrive out of band. When ui_widgets_enabled is true, widget events may arrive before, between, or after text deltas for the same assistant_message.id — for example a recommendation triage card before the intro text. The completed event always carries the final ordered content_parts.
Widget events are transient and not replayed on reconnect. A client reconnecting mid-turn should ignore transient events until the next terminal turn event, then reconcile from the snapshot and the completed messages.
A user message sent mid-turn preempts the active turn. The server accepts it, ends the running turn with conversation.turn.interrupted, and starts a new one. turn_already_active never occurs on this transport, so send barge-in immediately rather than gating on the in-flight turn — that gate is an HTTP concern.
A turn that does not reach a terminal recommendation may end with conversation.turn.interrupted or conversation.turn.failed instead of conversation.turn.completed.
Error event
The shared error event delivers recoverable protocol or processing errors. The connection stays open after error unless the server also closes the socket. A frame the server cannot accept after the handshake is reported this way and the socket stays open — the 1007 close applies to the first frame only.
{ "type": "error", "error": { "code": "message_too_long", "message": "..." } }The codes most commonly emitted on this transport are invalid_payload, invalid_message_type, conversation_not_found, unknown_evidence, message_too_long, rate_limit_exceeded, and turn_failed. The full list is in Error codes.
Close codes
| Code | Meaning |
|---|---|
1000 | Normal closure. |
1001 | Server is going away (shutdown in progress). |
1007 | Invalid payload — the first frame was not a valid handshake event. |
1008 | Policy violation, including failed authentication and connection-time rate-limit rejection. |
1011 | Internal server error. |
These are the only codes the server sends. A close outside this list came from the WebSocket layer or the network, not from Conversational Triage.