Skip to content

Chat API

The web app, the Office add-in and your own scripts talk to a conversation over the same WebSocket. This page describes that protocol: how to connect, what you receive while an agent works, how to tell your own requests from the ones sent elsewhere, and what you can count on when a request ends.

To create a conversation and read its messages, use the REST routes under /api/v1/chat/conversations.

Connect

Open a WebSocket on the conversation, with a session token (the access_token that POST /api/v1/auth/login returns):

wss://<your-xtm-one>/api/v1/chat/conversations/<conversation_id>/ws?token=<access_token>

Only the conversation's owner receives the requests described under Requests you did not send. Send {"type": "ping"} every 25 seconds or so; the platform answers {"type": "pong"}, and while a request runs it sends one itself after about 20 seconds without other frames.

Send a message

Send the message as JSON:

{"content": "Summarize the last incident report"}

The platform answers with a sequence of frames, all JSON objects with a type:

Frame Meaning
thinking Your message was received. It is sent before the request starts running.
turn_started Your message started a request of its own; turn_id identifies it. A message folded into a running request gets folded_into_active_turn instead (below).
status Progress: status says which step (preparing, tool_start, thinking_text, streaming...).
stream A piece of the answer as it is written (content).
done The answer: content and message_id, the stored assistant message.
error The request ended without an answer (content says why).
cancelled The request was stopped.

A done with "intermediate": true is part of the answer that was already delivered while the agent keeps working: the request is not over. Send {"type": "cancel"} to stop a request that is running.

Messages that mention several agents

To have several agents answer one message (@mentions), add their ids to the message, in the order they should answer:

{"content": "@mail-desk @deal-desk what do you think?", "mentioned_agent_ids": ["<agent id>", "<agent id>"]}

The conversation's agent answers first, then each mentioned agent, all in the same request: every frame carries the same turn_id. Each reply but the last arrives as a done with "intermediate": true, and a status frame with "status": "mention_answering" (agent_name, position, total) announces the next agent. The last reply is the request's final done. On such a request every reply carries answered_by (agent_id, agent_name, and agent_icon when the agent has one) naming the agent that wrote it - the conversation's agent included, except the General Assistant, whose replies carry none - and a note written in place of an agent that did not answer carries "mention_failed": true. Over REST, the replies after the first are in mention_messages.

A request you sent can become such a request without you mentioning anyone: a message another place folds into it (below) may mention agents, and they answer inside your request, after its agent. To tell your agent's reply from theirs, compare answered_by.agent_id with the conversation's agent: voice mode speaks only its agent's reply and shows the others as text with their name, and the Office add-in, like the web app, labels every reply with the agent that wrote it.

Messages that reference other conversations

To have the agent read other conversations (references with @), add their ids to the message, on the WebSocket and in the REST body alike:

{"content": "@q3-threat-report what did we decide there?", "referenced_conversation_ids": ["<conversation id>"]}

GET /api/v1/chat/conversation-references lists the conversations the caller can reference, as the composer's list does: every word of q, separated by spaces, hyphens or underscores (q3-threat like q3 threat), must appear in the title; limit (1 to 20, default 8) bounds the list and exclude leaves one conversation out. Each entry has the conversation's id, title, key (what the composer inserts after @), updated_at, is_own and, on a conversation shared with the caller, owner_name.

Only the conversations the sender can open are kept, at most 5, and never the conversation the message is sent in. The stored user message carries what it referenced in conversation_refs (conversation_id, title, key). A REST request that answers a held tool call with decisions stores no new message, so it is refused with a 422 when it also names conversations, as it is with files. The embedded assistant uses the same field on POST /api/v1/platform/chat/messages and lists the candidates with GET /api/v1/platform/chat/conversation-references, whose entries carry no owner_name.

Requests you did not send

A conversation can be open in several places: another browser tab, another device, voice mode, a script calling the REST API, or a background task reporting back. Every open WebSocket of the conversation's owner receives what the others do, so each one can show the same answer as it is written. Two fields tell those frames apart:

Field Where Meaning
relayed Every frame of a request this WebSocket did not send, and user_message frames true: the request was sent somewhere else. Frames of your own requests never carry it.
turn_id turn_started, and every thinking, status, stream, done, cancelled and error frame of a request, once it runs Identifies the request. Your own frames and the copies other WebSockets receive carry the same value. The thinking that acknowledges your message arrives before the request runs and carries none.

A WebSocket that connects while a request is running first receives a thinking frame flagged "resumed": true, then the steps, the reasoning and the answer written so far, then the rest as it happens. A user_message frame (message) is a user message stored from another place.

If your script only cares about its own messages, ignore every frame flagged relayed, except for the request your message was folded into (below).

Messages folded into a running request

A conversation runs one request at a time. A message you send while another request is running is folded into it: that request takes your message into account, and you receive:

{"type": "folded_into_active_turn", "conversation_id": "<id>", "folded": true, "turn_id": "<turn id>"}

turn_id names the request that answers your message: its frames reach you flagged relayed, with that turn_id, after this frame. The agents your message mentions answer it in that request, after the agents of that request, or in the next request when it ends before it can ask them. The REST route that sends a message answers 202 with "status": "folded_into_active_turn" and the same turn_id in that case. Rarely, the message is kept for the next request instead ("folded": false on the WebSocket, "status": "queued_for_next_turn" over REST). turn_id is then present only when a request is running, and that request picks the message up.

How a request ends

Every request you are shown ends with a done, cancelled or error frame, including one sent somewhere else, even when the connection to the platform's message broker was briefly lost or the process running the request stopped. When the platform itself had to end it, the error frame carries a reason:

reason Meaning
interrupted The request was stopped before it produced its answer (a server restart, for instance).
lost The request is over, but how it ended could not be delivered. Read the conversation to see what it stored.

A request still running when a normal server restart stops waiting for it stores an assistant message before that error goes out: it says the request was interrupted and names the tool calls that completed, the ones still running and the last completed action. Read back, the message carries "interrupted": true and the completed calls in tool_call_trace. The REST request that started it gets a 503 with Retry-After; read the conversation before sending the message again.

A request that ended before storing its answer, or whose agent failed, leaves its tool work to the next message of the conversation, over the WebSocket or REST: the tool calls it made and their results reach the agent before that message, so it continues instead of calling the same tools again. This holds for 24 hours.

A request stopped from the WebSocket that runs it can end with two cancelled frames: the acknowledgement of the stop, then, once the stop is stored, a second one carrying message_id. In the same way, a request whose agent failed can end with an error (without reason), then, once that reply is stored, a done carrying message_id. Both frames mean the request is over; the second one says what to read back.

Keep a safety check

If your script waits for an answer, also read GET /api/v1/chat/conversations/<conversation_id> when the request stays silent for a long time: is_processing is false once no request is running, and the stored messages show the answer. The platform guarantees the end frame; the check covers a network that drops frames on your side.

Next step

Go back to Chat to see how the web app shows the same conversation.