Conversation model
Both transports expose the same four concepts. Understanding how they nest is most of what you need to integrate.
Conversation ← server-owned, addressed by id
├── settings ← language, channel, widgets, handoff detectors
├── assessment ← evidence, conditions, recommendation
└── messages[] ← persisted history (user + assistant rows)
Turn ← one accepted user message + the assistant output it produced
├── user_message
└── assistant_messages[]
└── content_parts[] ← text | ui_widgetConversation
A conversation is the unit of state. You create one, then send user messages to it; the server keeps the transcript and the assessment, so a request never resends earlier evidence.
GET /conversations/{conversation_id} returns the current snapshot at any time — useful after a reconnect, or when a second device joins the same session.
Turn
A turn is one accepted user input plus the assistant output it produced. It is also the unit of concurrency, and the two transports resolve a second input differently:
| Transport | A user message sent while a turn is active |
|---|---|
| HTTP (blocking and NDJSON) | Rejected with turn_already_active (409). The active turn continues. |
| WebSocket | Accepted. It preempts the active turn, which ends with conversation.turn.interrupted, and starts a new one. turn_already_active never occurs on this transport. |
This is a property of the transport, not of the message: no field on UserMessageInput selects between the two behaviours.
Every turn ends in exactly one of three terminal states:
status | Meaning |
|---|---|
completed | The turn finished normally. |
interrupted | A newer accepted input superseded this turn. |
failed | Processing of the turn failed. |
Messages
Assistant output is an ordered list of content parts, not a single string. A part is either plain text or a ui_widget. There is no top-level content field on assistant messages, and no separate ui_widgets array.
Conversation history and turn/stream payloads use different types for the same messages — history rows are discriminated by role, turn and stream payloads add fields such as can_interrupt and deltas. See Messages and content parts.
Assessment
The assessment is the structured medical state behind the conversation: patient demographics, collected evidence, the differential conditions, and — once the interview concludes — the triage recommendation. It is a conversation-level object, not a per-turn one.
How you obtain it depends on the transport:
- Blocking HTTP — the turn response omits the assessment; call
GET /conversations/{conversation_id}/assessmentafter each turn. - Streaming and WebSocket — a
conversation.assessment.updatedevent carries the full snapshot; replace your local copy with it rather than merging.
Lifecycle and terminal states
Conversation.status (conversation_status on a Turn) tells you whether the conversation still accepts user messages:
status | Meaning |
|---|---|
in_progress | The conversation accepts further user messages. |
completed | The conversation rejects further user messages. |
Two very different situations produce completed, and is_terminated (conversation_terminated on a Turn) distinguishes them:
status | is_terminated | What happened | Rejection code |
|---|---|---|---|
completed | false | The interview finished and produced a recommendation. | conversation_completed |
completed | true | The conversation was permanently terminated — for example an underage patient or a confirmed change of age or sex. | conversation_terminated |
In both cases the only way forward is a new conversation. The explanation for the user arrives in that turn's assistant messages — render it instead of mapping reasons client-side.