Docs

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

EventPayload fieldsWhat to do
message.startrole, messageId, optional messageTimestampStart a message with this role and messageId.
message.contentmessageId, contentAppend content to the message with the same messageId.
message.stopmessageIdFinalize that message. The turn and the consultation continue.
guardianguardianId, messageRun your approved safety behavior and log the stable guardianId. See guardian events.
conversation.offtopicoptional messageIdRecord that the patient's message was classified as off-topic, and keep reading the stream.
conversation.endoptional messageId, optional reasonRecord that the assistant ended the conversation, with its reason, and return the patient to your flow.
skill_code.ctaskillCode, widget, optional textPresent 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.completeartifacts, with optional longSummary, shortSummary, and soapNote IDsStore each non-null ID and retrieve the consultation documents. An absent or null key means that document wasn't generated.
errorerrorCode, message, optional retryAfterSecondsHandle 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.

EventTypeScript typePython classPython fields
message.startMessageStartEventMessageStartEventrole, message_id, message_timestamp
message.contentMessageContentEventMessageContentEventmessage_id, content
message.stopMessageStopEventMessageStopEventmessage_id
guardianGuardianEventGuardianEventguardian_id, message
conversation.offtopicConversationOfftopicEventConversationOfftopicEventmessage_id
conversation.endConversationEndEventConversationEndEventmessage_id, reason
skill_code.ctaSkillCodeCtaEventSkillCodeCtaEventskill_code, widget, text
ai_consultation.completeAiConsultationCompleteEventAiConsultationCompleteEventartifacts.long_summary, artifacts.short_summary, artifacts.soap_note
errorStreamErrorEventStreamErrorEventerror_code, message, retry_after_seconds
Any other nameUnknownChatStreamEvent, { type, data }UnknownChatStreamEventtype, data
  • Unknown events. An event name the SDK doesn't know, or a documented event missing a required field, arrives as UnknownChatStreamEvent and never throws. In TypeScript, check isKnownChatStreamEvent(event) before you switch on event.type, so each case narrows. In Python, narrow with isinstance. Python events come from doctronic.types; model_dump(by_alias=True) gives the camelCase wire shape back.
  • A whole turn. await stream.finalMessages() (TypeScript) or stream.final_messages() (Python) reads to the end and returns the messages assembled by messageId, with the role from message.start, plus every other event in arrival order.
  • Cancellation. In TypeScript, abort the signal you passed to chats.stream() or call stream.abort(). In Python, call stream.close(), which is safe from another thread or task; the reader then raises APIUserAbortError. Breaking out of the loop, or leaving the Python with block, also closes the connection.
  • Errors. Errors before the stream starts throw typed errors, such as ConflictError for 409 error.partner.chat_stream_in_progress. Errors after it starts arrive as StreamErrorEvent. 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 each message.content to the message with the same ID, never to whichever message is newest.
  • Use the role from message.start. Render each message with the role its message.start supplied.
  • 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 POST with a JSON body, so the browser's native EventSource, which sends only GET, can't open it.
  • A chat runs one stream at a time.

On this page