Skip to main content
POST /v1/mind/chat responds with Content-Type: text/event-stream. The body is a sequence of Server-Sent Events — small text frames the client reads as they arrive.

The wire format

Each frame is an event: line and a data: line (JSON), separated from the next frame by a blank line:
event: meta
data: {"conversation_id":"1f0c…","action":"explain-concept","confidence":1.0}

event: delta
data: {"text":"Photosynthesis "}

event: delta
data: {"text":"is the process "}

event: done
data: {"conversation_id":"1f0c…","action":"explain-concept","revised":null,"remaining":null}

Events

EventDataWhen
meta{ conversation_id, action, confidence }Once, after routing — the resolved lane.
delta{ text }Repeatedly — a chunk of the answer. Concatenate in order.
done{ conversation_id, action, revised, remaining }Once, at the end. revised is a guardrail-corrected final answer (usually null); remaining is the day’s quota left.
message{ text, reason }Instead of deltas, when the turn is blocked (quota, assessment lock, or injection). Followed by done.
Render deltas live for responsiveness. Treat done.revised as authoritative when present — if it differs from the streamed text, replace the rendered answer with it.

Why SSE, not WebSockets

The chat reply is one-way streaming: the server pushes tokens, the client just reads. SSE matches that shape exactly, and avoids the cost of a protocol built for two-way traffic.
SSE (what we use)WebSockets
DirectionServer → client, which is all we needFull duplex (unused here)
TransportPlain HTTP — works with existing auth, HTTP/2, proxies, CDNs, load balancersUpgrade handshake; often needs sticky sessions
InfraNothing newConnection lifecycle, ping/pong, stateful routing
ResumptionApplication-level via conversation_id (survives drops)Must be rebuilt on top of the socket
Because each request is an ordinary HTTP call, the Authorization header and JSON body work the way they do everywhere else, and a dropped stream costs nothing — the conversation lives in the server-side checkpoint, so the client just reconnects with the same conversation_id.
We do not use the browser’s native EventSource: it is GET-only and can’t send an Authorization header or a request body. Use fetch() with a streaming reader (below) — the wire format is still SSE.

Consuming the stream

A small, dependency-free parser. It reads the response body, splits on blank lines, and dispatches each frame by event type.
type ChatHandlers = {
  onMeta?: (d: { conversation_id: string; action: string; confidence: number }) => void;
  onDelta?: (text: string) => void;
  onDone?: (d: { conversation_id: string; revised: string | null; remaining: number | null }) => void;
  onBlocked?: (d: { text: string; reason: string }) => void;
};

export async function streamChat(
  opts: { token: string; body: string; action?: string; conversationId?: string },
  handlers: ChatHandlers,
  baseUrl = "https://staging-be.mind.miva.university",
) {
  const res = await fetch(`${baseUrl}/v1/mind/chat`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${opts.token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      action: opts.action ?? "free-chat",
      body: opts.body,
      context: { conversation_id: opts.conversationId },
    }),
  });

  if (!res.ok || !res.body) throw new Error(`chat failed: ${res.status}`);

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    const frames = buffer.split("\n\n");
    buffer = frames.pop() ?? ""; // keep the trailing partial frame

    for (const frame of frames) {
      if (!frame.trim()) continue;
      const eventLine = frame.split("\n").find((l) => l.startsWith("event:"));
      const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
      if (!dataLine) continue;

      const event = eventLine?.slice(6).trim() ?? "message";
      const data = JSON.parse(dataLine.slice(5).trim());

      if (event === "meta") handlers.onMeta?.(data);
      else if (event === "delta") handlers.onDelta?.(data.text);
      else if (event === "done") handlers.onDone?.(data);
      else if (event === "message") handlers.onBlocked?.(data);
    }
  }
}

Resumption

Keep the conversation_id from the first meta/done and pass it on every subsequent message — MIND loads the full history server-side. To start a new thread, send no conversation_id. If a stream drops mid-answer, re-send the same message with the same conversation_id; nothing is lost.