DocsGuidesStreaming with SSE

Streaming with SSE

How to read the event stream from /api/query/stream in your own code, in Node, Python, a browser or curl, with the parsing details and the few pitfalls that catch most people the first time.

01What SSE is

Server-Sent Events is a small format for one-way streaming over HTTP. The server keeps one response open and writes events to it as text; the client reads them as they arrive. Each event is a few lines, event: with its type and data: with its content, ended by a blank line.

It suits a research run: the client sends one question and then only listens, while the run reports each phase, each source it finds and reads, its reasoning, and finally the answer.

NoteThe stream is one-way. You cannot steer a run once it has started; you can only let it finish or close the connection, which stops it.

02The events to handle

Every data: line is one JSON object. The full payloads are in API · Streaming; these are the ones most clients act on.

EventWhat to do with it
startStore request_id. It is the first event.
stepShow label as the current activity; progress (0 to 1) can drive a progress bar.
sourceAdd the source on stage: "discovered", keyed by id; update it on scraped and analyzed, which carry only the id and stage details.
reasoningAppend chunk text to the narration for step_id.
answer_deltaThe whole answer, once, just before final. Show it early if you like.
errorShow message. A final event follows.
finalThe result. Check status, then use answer and sources. Always the last event.

Decide success on final.status, not on the HTTP status: a bad or revoked key is reported inside an HTTP 200 stream. Ignore event types and fields you don't recognise.

03Browser

Don't call ResearchAnything from the browser with your key: anyone who loads the page can read it. Point the browser at your own backend, which relays the stream (see Pattern C).

The browser's EventSource only makes GET requests and can't send a body, so read a fetch() response yourself:

Browser
async function streamQuery(query, onEvent) {
  // Your own backend's relay endpoint, which holds the key.
  const res = await fetch("/stream", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query }),
  });

  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 });

    // Events end with a blank line. The last piece may be incomplete.
    const blocks = buffer.split("\n\n");
    buffer = blocks.pop();

    for (const block of blocks) {
      let event = "message";
      let data = "";
      for (const line of block.split("\n")) {
        if (line.startsWith("event:")) event = line.slice(6).trim();
        else if (line.startsWith("data:")) data += line.slice(5).trim();
      }
      if (data) onEvent(event, JSON.parse(data));
    }
  }
}

const sources = new Map();
streamQuery("Compare lithium-ion and sodium-ion batteries for grid storage.", (event, data) => {
  if (event === "step") showActivity(data.label);
  if (event === "source" && data.stage === "discovered") sources.set(data.id, data);
  if (event === "reasoning" && data.stage === "chunk") appendReasoning(data.step_id, data.chunk);
  if (event === "final") data.status === "ok" ? renderAnswer(data.answer, data.sources) : showErrors(data.errors);
});
CautionKeep the { stream: true } option on TextDecoder.decode(). Without it, characters that span two chunks come out garbled.

04Node

On Node 18 and later, fetch is built in and the response body can be read with for await. On the server you can send the key directly.

Node
async function* streamQuery(query) {
  const res = await fetch("https://researchanything.ai/api/query/stream", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.ESSARION_KEY}`,
      "Content-Type": "application/json",
      "Accept": "text/event-stream",
    },
    body: JSON.stringify({ query }),
  });
  if (res.status === 422) throw new Error(JSON.stringify(await res.json()));

  const decoder = new TextDecoder();
  let buffer = "";
  for await (const bytes of res.body) {
    buffer += decoder.decode(bytes, { stream: true });
    const blocks = buffer.split("\n\n");
    buffer = blocks.pop();
    for (const block of blocks) {
      let event = "message";
      let data = "";
      for (const line of block.split("\n")) {
        if (line.startsWith("event:")) event = line.slice(6).trim();
        else if (line.startsWith("data:")) data += line.slice(5).trim();
      }
      if (data) yield { event, data: JSON.parse(data) };
    }
  }
}

for await (const { event, data } of streamQuery("Compare lithium-ion and sodium-ion batteries for grid storage.")) {
  if (event === "step") console.log("·", data.label);
  if (event === "final") {
    console.log(data.status === "ok" ? data.answer : data.errors);
  }
}

05Python

httpx streams a response and splits it into lines for you; you only have to group lines into events.

Python — httpx
import asyncio
import json
import os

import httpx

async def stream_query(query):
    headers = {
        "Authorization": f"Bearer {os.environ['ESSARION_KEY']}",
        "Accept": "text/event-stream",
    }
    timeout = httpx.Timeout(10.0, read=None)
    async with httpx.AsyncClient(timeout=timeout) as client:
        async with client.stream(
            "POST",
            "https://researchanything.ai/api/query/stream",
            headers=headers,
            json={"query": query},
        ) as resp:
            event, data = "message", []
            async for line in resp.aiter_lines():
                if line.startswith("event:"):
                    event = line[6:].strip()
                elif line.startswith("data:"):
                    data.append(line[5:].strip())
                elif line == "" and data:
                    yield event, json.loads("".join(data))
                    event, data = "message", []

async def main():
    async for event, data in stream_query("Compare lithium-ion and sodium-ion batteries for grid storage."):
        if event == "step":
            print("·", data["label"])
        elif event == "final":
            print(data["answer"] if data["status"] == "ok" else data["errors"])

asyncio.run(main())
TipTurn off the read timeout (read=None) for streaming. Phases can go quiet for a while, and httpx's default five-second timeout would cut the stream off.

06curl

For a quick look from a shell, -N turns off curl's buffering so events print as they land:

cURL
curl -N https://researchanything.ai/api/query/stream \
  -H "Authorization: Bearer $ESSARION_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "Compare lithium-ion and sodium-ion batteries for grid storage."}'

To watch only one kind of event, filter the output, for example the phase labels:

cURL
curl -sN … | grep --line-buffered -A1 '^event: step'

07Disconnects

Connections drop: networks change, laptops sleep, proxies recycle. Plan for what that means here.

So: store request_id from start, keep the events you care about as they arrive, retry at most once after a drop, and then tell the user it failed.

CautionDon't reconnect in a loop. Each attempt is a new run, and a loop can use up a month's allowance in minutes.

08Common pitfalls

Proxy buffering

If you relay the stream through your own server and events arrive in large bursts instead of one at a time, something in between is buffering:

Request time limits

CautionSome hosts and gateways cap how long a request may stay open. A run can outlast a short cap, and the cut connection stops the run.

If your host has a short cap, move the relay to a host that allows long-lived requests, or run the call in a background worker (Pattern B) and let the browser poll your API.

Partial chunks

A single read can end in the middle of an event. Buffer the text, split on the blank line between events, and keep the last, possibly incomplete, piece for the next read, as the examples above do. Skip that and you will see occasional JSON.parse errors on cut-off data.

09Where to go next

Can’t find what you need?

Write to us and a person will answer.