Skip to content
Sursa Developers

Build a Q&A bot

This guide builds the core of a bot that answers questions from your knowledge base: in a chat tool, in your help center, or inside your product. The assistant finds the relevant knowledge and answers, citing the cards it used; your code sends the question and shows the result.

You need a key with the search and ask scopes. The answer uses only the cards that the key is allowed to read, so the key's role, data profile and collections decide what the bot can talk about. Give a bot for customers a key that only sees public collections.

Three ways to get an answer

EndpointReturnsUse it for
POST /v1/searchThe passages that match, best first. No written answer.A search box, "related articles", your own ranking.
POST /v1/askA complete answer with its sources, in one response.Bots that post one message per answer; back-end jobs.
POST /v1/chatThe answer as a stream of events, while it is written.Chat interfaces, where the user should see the answer appear.
curl -X POST https://api.sursa.com/v1/search \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "how do I return a damaged item", "collection": "support"}'

Each hit is a passage of a card: doc_id and doc_name of the card, heading of the section, text of the passage, pages or table rows where it was found, and the trust and verification of the card.

collection selects the collection to search; without it, the collection named default is used. tags and card_ids narrow the search to cards with those tags or to particular cards.

Ask and get a complete answer

curl -X POST https://api.sursa.com/v1/ask \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "How do I return a damaged item?", "collection": "support"}'

The answer text in answer is Markdown. Its references, such as [1] or [1, 3], match ref of the entries in sources; link each one to the card (doc_id) so the reader can check the answer.

A number in the text that could not be confirmed against the documents can appear as a marker such as ⟦n:a1⟧. verification.items describes each marker: its marker, the reason and the original text. If your code does not handle markers, replace each one with its original text.

When there is no answer

The response is always 200. When the assistant cannot answer, empty says why, and answer holds a short message you can show:

emptyMeaning
no_hitsNothing in the knowledge base matches the question.
restrictedThe topic is in cards that the key is not allowed to read.
guardrailThe question is outside what the organization allows the assistant to answer.
no_data_for_periodThe question asks about a period for which there are no documents.

no_hits is a useful signal: it shows what your users ask that the knowledge base does not yet cover.

Fast and Precise

Pass "quality": "precise" for a more thorough answer to a hard question, at the cost of more time. The default, "fast", suits most questions.

Stream the answer

In a chat interface, show the answer while it is written. POST /v1/chat with "stream": true responds with text/event-stream: one event per message, each with an id: line (its number in the stream, from 1) and a data: line with JSON. The type field of the JSON says what the event is.

id: 1
data: {"type":"meta","sources":3,"timings":{…},"answer_id":"…"}

id: 2
data: {"type":"sources","sources":[{"ref":1,"doc_id":"8c1d…","doc_name":"Returns","text":"…"}]}

id: 3
data: {"type":"token","text":"Send a photo of the item "}

id: 4
data: {"type":"token","text":"within 7 days [1]."}

id: 5
data: {"type":"done","answer":"Send a photo of the item within 7 days [1].","question_id":9812,"sources":[…]}
typeWhat to do
metaThe answer has started. Keep answer_id if you log answers.
sourcesThe cards the answer is based on. Can come more than once; the last one wins.
tokenA piece of the answer text. Append it to what you show.
doneThe answer is complete. It has the same fields as the response of /v1/ask.
errorThe answer stopped. The stream ends without done. See below.
followupsAfter done: suggested next questions you can offer as buttons.
anything elseSkip it. New event types can appear at any time.

A line that starts with : (such as : ping, sent every 15 seconds while the answer is being prepared) is a comment. Skip it.

messages holds the conversation so far, oldest first, ending with the user's question. Send the last few turns so that follow-up questions such as "and for orders from abroad?" are understood.

// Streams an answer and calls onText with each piece of text. Node.js 20+ or a browser.
export async function streamAnswer({ messages, collection, requestId, lastEventId, onText }) {
  const response = await fetch("https://api.sursa.com/v1/chat", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SURSA_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "text/event-stream",
      ...(lastEventId ? { "Last-Event-ID": String(lastEventId) } : {}),
    },
    body: JSON.stringify({ messages, collection, stream: true, request_id: requestId }),
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);

  const decoder = new TextDecoder();
  let buffer = "";
  let event = { id: 0, data: "" };
  let done = null;
  for await (const piece of response.body) {
    buffer += decoder.decode(piece, { stream: true });
    let newline;
    while ((newline = buffer.indexOf("\n")) >= 0) {
      const line = buffer.slice(0, newline).replace(/\r$/, "");
      buffer = buffer.slice(newline + 1);
      if (line.startsWith("id:")) event.id = Number(line.slice(3).trim());
      else if (line.startsWith("data:")) event.data += line.slice(5).trim();
      else if (line === "" && event.data) {
        const message = JSON.parse(event.data);
        if (message.type === "token") onText(message.text, event.id);
        else if (message.type === "done") done = message;
        else if (message.type === "error") throw Object.assign(new Error(message.message), { event: message, id: event.id });
        event = { id: 0, data: "" };
      }
    }
  }
  if (!done) throw new Error("the stream ended before the answer was complete"); // resume it
  return done;
}

Without "stream": true, POST /v1/chat returns the complete answer as JSON, like /v1/ask.

Resume a dropped stream

Connections break: a mobile network changes, a proxy times out, your server restarts. Give every question a request_id and the answer survives the break:

  • With request_id, the answer is prepared independently of the connection. A disconnect does not stop it: it is completed and saved.
  • To resume, send the same request again, with the same request_id, and the number of the last event you received in the Last-Event-ID header.
  • Use one request_id (for example a UUID) per question, and the same one in every retry of that question. A new question gets a new request_id.

What the repeated request gets depends on where the answer is:

SituationThe repeated request receives
The answer is still being written, or has just finishedThe events after Last-Event-ID, with the same numbers: the stream simply continues.
The answer finished some minutes agoThe saved answer as a token with the whole text and a done, numbered from 1. The question is not asked again.
The stream cannot continue from that numberThe answer from the start, numbered from 1.

Your code handles all three cases with one rule: if the first event you receive has a number no greater than Last-Event-ID, the answer starts over; clear what you showed and build it again. Otherwise, append.

import { randomUUID } from "node:crypto";

async function answerWithResume(messages, collection, show) {
  const requestId = randomUUID();
  let text = "";
  let lastId = 0;
  for (let attempt = 1; ; attempt++) {
    let first = true;
    try {
      return await streamAnswer({
        messages, collection, requestId, lastEventId: lastId,
        onText(piece, id) {
          if (first && lastId && id <= lastId) text = ""; // the answer starts over
          first = false;
          text += piece;
          lastId = id;
          show(text);
        },
      });
    } catch (error) {
      const retry = error.event ? error.event.retry === true : true; // a network error, or a server restart
      if (!retry || attempt === 5) throw error;
      await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
    }
  }
}

The Sursa API can run on several servers. A stream continues from Last-Event-ID only on the server that is writing the answer; if your repeated request reaches another one, it receives the saved answer once it is finished, or the answer is written again from the start. The rule above covers both cases, and the question is never counted twice.

Without request_id, closing the connection cancels the answer.

Errors in a stream

Errors before the stream starts are ordinary HTTP errors: 401, 403, 422, or 429 with Retry-After when the organization has used its question limit or has too many answers in progress at once.

Once the stream has started, its HTTP status is always 200, and a problem arrives as an error event:

{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
  • retry: true means the answer stopped because of the server, for example during an update, and you can send the same request again unchanged.
  • Other codes, such as timeout, are listed in the API reference. Treat an unknown code like any other stream error: tell the user, and let them ask again.

Let users rate answers

Every answer has a question_id. Send the user's rating to POST /v1/questions/{question_id}/feedback: 1 for helpful, -1 for not helpful, with an optional reason and note. Ratings help the organization see which cards need work.

curl -X POST https://api.sursa.com/v1/questions/9812/feedback \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value": -1, "reason": "outdated", "note": "The return window is now 14 days."}'

Checklist for a production bot

  • A key with only search and ask, limited to the collections the bot's audience may see.
  • A request_id per question, and the resume rule above.
  • A timeout on reading the stream that is longer than 15 seconds, so the : ping comments keep it alive.
  • Answers rendered as Markdown, with references linked to their sources.
  • A clear message when empty is set, and a way to reach a person.
  • 429 handled by waiting for Retry-After.
  • Ratings sent back, so the knowledge base gets better over time.