Documentation
Conversational Triage API
Build your solution
Messages and content parts

Messages and content parts

Assistant output is modeled as an ordered list of content parts rather than a string plus a side-channel array. Plain text lives in text parts and structured UI in ui_widget parts.

There is no top-level content field on assistant messages and no separate ui_widgets array.

Content parts

ContentPart is a tagged union discriminated by type:

typePayloadPurpose
texttext: stringPlain text segment.
ui_widgetwidget: UIWidgetStructured UI — hints, forms, recommendation cards. See UI widgets.
JSON
[
  { "type": "text", "text": "Do you have a fever?" },
  { "type": "ui_widget", "widget": { "type": "hints", "hints": ["Yes", "No", "Not sure"] } }
]

Rendering plain text or driving a TTS engine means concatenating the text parts in order:

const text = message.content_parts
  .filter((part) => part.type === 'text')
  .map((part) => part.text)
  .join('')

History rows vs turn and stream payloads

The same messages appear in two different type families, and mixing them up is the most common integration bug on this API.

TypeUsed in
UserConversationMessage / AssistantConversationMessageConversation.messages only — the persisted snapshot history.
UserMessage / UserMessageInputBlocking Turn.user_message, the conversation.user_message.created event, and client turn input. Adds voice fields (is_interrupted, played_content).
AssistantMessage / AssistantMessageDeltaBlocking Turn.assistant_messages and completed or in-progress stream segments. Adds can_interrupt and delta.

History is discriminated by role

JSON
"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"
  }
]

Client rule: branch on role. User rows expose content only — no content_parts, no kind. Assistant rows expose content_parts only — no top-level content. Reading the wrong field yields undefined, not a fallback. In OpenAPI this is a oneOf with role as the discriminator.

Message kinds

kind describes an assistant message's role within the turn. Useful for styling and for voice pacing; never required for correctness.

kindDescriptioncan_interrupt
acknowledgementShort acknowledgement of the user's input.false
bridgeConnector phrase introducing what comes next.false
fillerFiller content emitted while work continues.false
introThe stage's primary content — the actual question or information being presented.true
follow_upResponse that steers the conversation toward payload extraction: answers user questions, clarifies, or re-asks.true
read_backConfirmation of information the user volunteered without being asked.true

A single turn commonly produces several assistant messages of different kinds, in order.

can_interrupt is a pure function of kind, not a per-message decision — the mapping above is the whole rule. A voice client can therefore derive its pacing from kind alone: let the short connective kinds finish, allow barge-in on the ones carrying the actual question.

Read-backs

When the user volunteers information the assistant did not ask for — evidence recorded with origin: "spontaneous" — the server confirms it back before continuing, as its own assistant message with kind: "read_back".

Read-backs are always active; the client cannot turn them off. Not every volunteered finding produces one: a finding the assistant could not match to a known concept is never read back, and a finding already read back with the same state is not repeated. Findings the assistant explicitly asked for are included in the content once a read-back is warranted, but never trigger one on their own.

Two properties matter when you render one:

  • The read-back precedes the stage's own reply for that turn. The reply is withheld until the read-back has been emitted, so it never arrives on top of an answer already on screen.
  • The copy is deterministic — server-rendered rather than model-generated, and streamed as a single conversation.assistant_message.delta carrying the whole text. Do not expect it in chunks.

ui_widgets_enabled selects one of two shapes, never both:

ui_widgets_enabledcontent_parts
falseOne text part: a localized lead-in, a blank line, then the findings grouped as markdown.
trueA text part carrying the lead-in only, followed by a ui_widget part holding a read_back widget. The widget is also emitted as a transient conversation.assistant_message.ui_widget event.

Findings are grouped by state in a fixed order — present, absent, then unknown — and empty groups are omitted.

Deltas

On the streaming transports, conversation.assistant_message.delta carries an AssistantMessageDelta — the message id, turn_id, kind, can_interrupt, and a delta text chunk to append.

⚠️

Never treat concatenated deltas as the complete message. Deltas carry text only. Widgets arrive on conversation.assistant_message.ui_widget and in content_parts on the completed event, never in a delta — so a delta-only client silently drops them.

Use deltas for progressive rendering, then reconcile against content_parts on conversation.assistant_message.completed.

PII redaction

Before a user message enters the engine, the server removes personally identifiable information from content — and from played_content when the user interrupted. Read models return only the redacted text, so content in any response is the only safe form of the message.

Full field tables for every message type are in Schemas.

Was this page helpful?