Streaming
Parse the chat stream's Server-Sent Events, act on every event type, and stay compatible as new events ship.
POST /chats/{chatId}/stream/
answers each patient turn with Server-Sent Events (SSE). Every frame has an event name and a
camelCase JSON data payload whose type matches the event name.
event: message.start
data: {"type":"message.start","role":"assistant","messageId":"ac_hist_01K6JGHM5S4W7T9Y2B3C4D5E6F"}
event: message.content
data: {"type":"message.content","messageId":"ac_hist_01K6JGHM5S4W7T9Y2B3C4D5E6F","content":"I can help with that."}
event: message.stop
data: {"type":"message.stop","messageId":"ac_hist_01K6JGHM5S4W7T9Y2B3C4D5E6F"}
The connection stays open for the assistant's whole turn and closes when the turn ends. For what each ending means, see end states.
Read the stream
With the SDKs, call chats.stream(): it sends the turn, parses the frames, and
yields typed events. Without an SDK, read the response body with fetch()
(TypeScript) or httpx (Python) and parse the frames yourself with the parsers below.
The request carries your API token, so open the stream from
your backend. To show the consultation in a
browser or app, relay the events your interface needs over your own channel, such as an SSE or
WebSocket endpoint on your backend. A native EventSource works against your own GET endpoint.
Event reference
| Event | Payload fields | What to do |
|---|---|---|
message.start | role, messageId, optional messageTimestamp | Start a message with this role and messageId. |
message.content | messageId, content | Append content to the message with the same messageId. |
message.stop | messageId | Finalize that message. The turn and the consultation continue. |
guardian | guardianId, message | Run your approved safety behavior and log the stable guardianId. See guardian events. |
conversation.offtopic | optional messageId | Record that the patient's message was classified as off-topic, and keep reading the stream. |
conversation.end | optional messageId, optional reason | Record that the assistant ended the conversation, with its reason, and return the patient to your flow. |
skill_code.cta | skillCode, widget, optional text | Present the end-of-flow action you mapped to this skillCode and widget, using text as the label when present. Show a safe fallback for values you haven't mapped. |
ai_consultation.complete | artifacts, with optional longSummary, shortSummary, and soapNote IDs | Store each non-null ID and retrieve the consultation documents. An absent or null key means that document wasn't generated. |
error | errorCode, message, optional retryAfterSeconds | Handle it apart from message text. See stream errors. |
Calls to action covers mapping skillCode and widget values.
The non-message events look like this on the wire:
event: guardian
data: {"type":"guardian","guardianId":"emergency_breathing","message":"Please call emergency services now."}
event: conversation.offtopic
data: {"type":"conversation.offtopic","messageId":"ac_hist_01K6JGHM5S4W7T9Y2B3C4D5E6F"}
event: conversation.end
data: {"type":"conversation.end","messageId":"ac_hist_01K6JGHM5S4W7T9Y2B3C4D5E6F","reason":"AGENT_STOP_CODE"}
event: skill_code.cta
data: {"type":"skill_code.cta","skillCode":"109","widget":"cta_doctor_visit_in_person_widget","text":"See doctor in person"}
event: ai_consultation.complete
data: {"type":"ai_consultation.complete","artifacts":{"longSummary":"art_01K6JGGM2G2DB4Y9KB9H9MJJ6Q","shortSummary":"art_01K6JGGP7Q4R8S2T5V7W9X1Y3","soapNote":"art_01K6JGGR9Z6B2C4D6F8H0J2K4M"}}
event: error
data: {"type":"error","errorCode":"error.rate_limited","message":"The service is busy right now. Please retry in a few moments.","retryAfterSeconds":7}
Parse the stream
These parsers run on your backend. They handle frames split across network chunks, CRLF, LF, and
CR line endings, comment lines, multiline data fields, cancellation, and event names they
don't know.
export type SseFrame = { event: string; data: string };
const FRAME_BOUNDARY = /\r\n\r\n|\n\n|\r\r/;
export function decodeFrame(raw: string): SseFrame | null {
let event = 'message';
const data: string[] = [];
for (const line of raw.split(/\r\n|\r|\n/)) {
if (!line || line.startsWith(':')) continue; // Blank line or comment.
const separator = line.indexOf(':');
const field = separator === -1 ? line : line.slice(0, separator);
let value = separator === -1 ? '' : line.slice(separator + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'event') event = value;
if (field === 'data') data.push(value);
}
return data.length ? { event, data: data.join('\n') } : null;
}
export async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<SseFrame> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finished = false;
try {
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
let match: RegExpExecArray | null;
while ((match = FRAME_BOUNDARY.exec(buffer))) {
const frame = decodeFrame(buffer.slice(0, match.index));
buffer = buffer.slice(match.index + match[0].length);
if (frame) yield frame;
}
if (done) {
finished = true;
break;
}
}
// Every complete frame ends with a blank line. Leftover text means the stream was cut off
// mid-frame, so drop it rather than parse a partial event.
} finally {
// Runs when the caller breaks out of the loop or aborts: release the connection.
if (!finished) await reader.cancel('SSE consumer stopped').catch(() => undefined);
reader.releaseLock();
}
}
const baseUrl = process.env.DOCTRONIC_API_URL ?? 'https://staging-partners.doctronic.ai/api/v1';
type Message = { role: string; content: string };
export async function streamTurn(
chatId: string,
userId: string,
userInput: string,
signal: AbortSignal, // Abort on caller disconnect, a cancel action, or your timeout.
): Promise<void> {
const response = await fetch(`${baseUrl}/chats/${chatId}/stream/`, {
method: 'POST',
signal,
headers: {
Authorization: `Bearer ${process.env.DOCTRONIC_API_TOKEN}`,
'X-Doctronic-User-ID': userId,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify({
userEvent: { type: 'user_message', userInput },
timezone: 'America/New_York',
}),
});
if (!response.ok || !response.body) {
// Errors before the stream starts arrive as ordinary JSON responses.
throw new Error(`Stream failed: ${response.status} ${await response.text()}`);
}
const messages = new Map<string, Message>();
for await (const frame of parseSse(response.body)) {
const event = JSON.parse(frame.data) as Record<string, unknown>;
switch (frame.event) {
case 'message.start':
messages.set(String(event.messageId), { role: String(event.role), content: '' });
break;
case 'message.content': {
const message = messages.get(String(event.messageId));
if (message) message.content += String(event.content ?? '');
else console.warn('message.content before message.start', event.messageId);
break;
}
case 'message.stop':
console.log('Message complete', messages.get(String(event.messageId)));
break;
case 'guardian':
console.log('Run the approved safety behavior', event.guardianId);
break;
case 'conversation.offtopic':
case 'conversation.end':
case 'skill_code.cta':
console.log('Workflow event', frame.event, event);
break;
case 'ai_consultation.complete':
console.log('Store artifact IDs', event.artifacts);
break;
case 'error':
console.log('Stream error', event.errorCode, event.retryAfterSeconds);
break;
default:
// New event names are additive. Ignore what this client doesn't use yet.
break;
}
}
}Replace each console.log or print with your own handling. The event reference above says what
each one needs.
SDK event types
chats.stream() yields one typed event per frame. TypeScript events keep the wire field names;
Python events are pydantic models with snake_case fields.
| Event | TypeScript type | Python class | Python fields |
|---|---|---|---|
message.start | MessageStartEvent | MessageStartEvent | role, message_id, message_timestamp |
message.content | MessageContentEvent | MessageContentEvent | message_id, content |
message.stop | MessageStopEvent | MessageStopEvent | message_id |
guardian | GuardianEvent | GuardianEvent | guardian_id, message |
conversation.offtopic | ConversationOfftopicEvent | ConversationOfftopicEvent | message_id |
conversation.end | ConversationEndEvent | ConversationEndEvent | message_id, reason |
skill_code.cta | SkillCodeCtaEvent | SkillCodeCtaEvent | skill_code, widget, text |
ai_consultation.complete | AiConsultationCompleteEvent | AiConsultationCompleteEvent | artifacts.long_summary, artifacts.short_summary, artifacts.soap_note |
error | StreamErrorEvent | StreamErrorEvent | error_code, message, retry_after_seconds |
| Any other name | UnknownChatStreamEvent, { type, data } | UnknownChatStreamEvent | type, data |
- Unknown events. An event name the SDK doesn't know, or a documented event missing a required
field, arrives as
UnknownChatStreamEventand never throws. In TypeScript, checkisKnownChatStreamEvent(event)before you switch onevent.type, so each case narrows. In Python, narrow withisinstance. Python events come fromdoctronic.types;model_dump(by_alias=True)gives the camelCase wire shape back. - A whole turn.
await stream.finalMessages()(TypeScript) orstream.final_messages()(Python) reads to the end and returns the messages assembled bymessageId, with the role frommessage.start, plus every other event in arrival order. - Cancellation. In TypeScript, abort the
signalyou passed tochats.stream()or callstream.abort(). In Python, callstream.close(), which is safe from another thread or task; the reader then raisesAPIUserAbortError. Breaking out of the loop, or leaving the Pythonwithblock, also closes the connection. - Errors. Errors before the stream starts throw typed errors, such as
ConflictErrorfor409 error.partner.chat_stream_in_progress. Errors after it starts arrive asStreamErrorEvent. The SDKs never retry a turn.
Compatibility rules
- Ignore event names you don't recognize. Doctronic adds new events over time. Published event names are stable: a replacement ships alongside the old name, and the old name is marked deprecated rather than silently renamed.
- Key message chunks by
messageId. Append eachmessage.contentto the message with the same ID, never to whichever message is newest. - Use the role from
message.start. Render each message with the role itsmessage.startsupplied. - Read state from event types and their documented fields. Safety, conversation end, and completion each have their own event. Do not infer any of them from assistant text or from the connection closing.
- Process events in the order they arrive within one stream. Control events can arrive between message events, so handle each one independently of message assembly.
- Do not automatically resend a turn after a stream breaks. The stream takes no idempotency key; see stream errors for safe recovery.
Limits
- The endpoint accepts only
POSTwith a JSON body, so the browser's nativeEventSource, which sends onlyGET, can't open it. - A chat runs one stream at a time.