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
| Endpoint | Returns | Use it for |
|---|---|---|
POST /v1/search | The passages that match, best first. No written answer. | A search box, "related articles", your own ranking. |
POST /v1/ask | A complete answer with its sources, in one response. | Bots that post one message per answer; back-end jobs. |
POST /v1/chat | The answer as a stream of events, while it is written. | Chat interfaces, where the user should see the answer appear. |
Search
curl -X POST https://api.example.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.example.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:
empty | Meaning |
|---|---|
no_hits | Nothing in the knowledge base matches the question. |
restricted | The topic is in cards that the key is not allowed to read. |
guardrail | The question is outside what the organization allows the assistant to answer. |
no_data_for_period | The 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":[…]}
type | What to do |
|---|---|
meta | The answer has started. Keep answer_id if you log answers. |
sources | The cards the answer is based on. Can come more than once; the last one wins. |
token | A piece of the answer text. Append it to what you show. |
done | The answer is complete. It has the same fields as the response of /v1/ask. |
error | The answer stopped. The stream ends without done. See below. |
followups | After done: suggested next questions you can offer as buttons. |
| anything else | Skip 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.example.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 theLast-Event-IDheader. - 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 newrequest_id.
What the repeated request gets depends on where the answer is:
| Situation | The repeated request receives |
|---|---|
| The answer is still being written, or has just finished | The events after Last-Event-ID, with the same numbers: the stream simply continues. |
| The answer finished some minutes ago | The 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 number | The 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: truemeans 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.example.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
searchandask, limited to the collections the bot's audience may see. - A
request_idper question, and the resume rule above. - A timeout on reading the stream that is longer than 15 seconds, so the
: pingcomments keep it alive. - Answers rendered as Markdown, with references linked to their sources.
- A clear message when
emptyis set, and a way to reach a person. 429handled by waiting forRetry-After.- Ratings sent back, so the knowledge base gets better over time.