Ask a question and get the answer streamed back, with citations.
POST https://app.kelu.dev/api/v1/public/knowledge-bases/:knowledgeBaseId/chat
Auth: client key. Public keys also need a CAPTCHA token when CAPTCHA is on. Response: text/event-stream.

Request body

FieldTypeRequiredDescription
querystringYesThe question. Max 4,000 characters
thread_idstringNoContinue a conversation. Use the thread_id from an earlier answer
top_knumberNoHow many passages to retrieve. Default 10, capped at 100
group_idsstring[]NoAnswer only from these source groups
user{ id?, email? }NoWho is asking, for analytics. id is used when both are sent
widget_idstringNoA saved widget’s id. The answer uses that widget’s voice. The request must also pass that widget’s Enabled switch and Allowed origins
curl -N https://app.kelu.dev/api/v1/public/knowledge-bases/KB_ID/chat \
  -H "X-Client-Id: kl_ci_..." \
  -H "X-Client-Secret: kl_cs_..." \
  -H "Content-Type: application/json" \
  -d '{"query": "How do I rotate a client key?"}'

Response stream

Each event is one data: line holding a JSON object. There are no event: names.
data: {"citations":[{"chunk_id":"7c1e...","document_id":"a4d2...","url":"https://docs.example.com/keys","title":"Client Keys","heading_path":["Security","Client Keys"],"excerpt":"To revoke a key, open Settings ...","index":1}],"done":false}

data: {"delta":"Open Settings and ","done":false}

data: {"delta":"click Revoke [1].","done":false}

data: {"done":true,"thread_id":"9b1f...","message_id":"c3a2...","citations":[{"chunk_id":"7c1e...","document_id":"a4d2...","url":"https://docs.example.com/keys","title":"Client Keys","heading_path":["Security","Client Keys"],"excerpt":"To revoke a key, open Settings ...","index":1}],"powered_by":"Powered by Kelu","powered_by_url":"https://kelu.dev"}
FrameFieldsWhat to do
Firstcitations, done: falseThe pages found for this question. Show them while the answer streams. Absent when nothing relevant was found
Middledelta, done: falseThe next piece of answer text. Append it
Lastdone: true, thread_id, citations, message_id, answerSee below
On the last frame:
  • citations is the list the answer actually cited. It replaces the first list. It can be empty.
  • thread_id continues the conversation. Send it back as thread_id.
  • message_id is what you rate. message_id and thread_id are both absent when the workspace uses Zero data retention.
  • answer is only sent when the [n] markers were renumbered. When it is there, show it instead of the text you built from delta. See Renumbered answers.
Each citation has index, title, url, document_id, chunk_id, heading_path (section breadcrumb) and excerpt. An [n] in the answer points at the citation whose index is n.

Renumbered answers

The answer usually cites only some of the pages it was shown. Kelu then renumbers the cited sources from 1 and rewrites the markers to match. The text has already streamed by then, so the fixed text comes on the last frame as answer.
streamed   Open Settings and click Revoke [2]. Over HTTP, call DELETE [3].
answer     Open Settings and click Revoke [1]. Over HTTP, call DELETE [2].

Reading the stream

const res = await fetch(url, { method: "POST", headers, body: JSON.stringify({ query }) });
if (!res.ok) throw new Error((await res.json()).error); // refused before streaming

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "", text = "", citations = [];

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  const events = buffer.split("\n\n");
  buffer = events.pop(); // keep a half-received event for the next read
  for (const event of events) {
    if (!event.startsWith("data: ")) continue;
    const frame = JSON.parse(event.slice(6));
    if (frame.error) throw new Error(frame.error);
    if (frame.delta) text += frame.delta;
    if (frame.citations) citations = frame.citations; // the last list wins
    if (frame.done) {
      return { text: frame.answer ?? text, citations, threadId: frame.thread_id, messageId: frame.message_id };
    }
  }
}

Continue a conversation

Send thread_id in the body, or post to the thread:
POST https://app.kelu.dev/api/v1/public/threads/:threadId/chat
Same body and same stream. Earlier turns are taken into account, so a follow-up like “what about SSO?” is understood. On this route user is ignored. widget_id works as on the first question: send it again with each follow-up to keep the widget’s voice and rules.
  • A thread from another knowledge base, or one that no longer exists, returns 404.
  • A thread_id that is not a valid id is ignored, and a new conversation starts.
  • With Zero data retention, conversations are not stored, so no thread_id is returned. Each question starts a new conversation.

Errors

Refused before the answer starts: you get a normal JSON error with a 4xx status. For example 403 for a bad group_ids or a missing CAPTCHA token. With widget_id, the widget’s own rules can refuse the question too:
StatuscodeMeaning
403WIDGET_DISABLEDThe widget’s Enabled switch is off
403ORIGIN_NOT_ALLOWEDThe page’s origin is not in the widget’s Allowed origins
404NOT_FOUNDNo such widget, or it belongs to another knowledge base
400BAD_REQUESTwidget_id is not a valid id
Failed after the stream opened: the status is already 200, so you get one data: frame with the error, and then the stream ends. No done frame follows.
data: {"error":"This took too long to answer. Please try again, or ask something more specific.","error_code":"timeout","code":"TIMEOUT","retryable":true,"request_id":"5f0c..."}
error_codeMeaningRetry?
quota_exceededThe workspace used its monthly question allowance, or the AI provider account is out of creditNo
misconfiguredThe AI provider is not set up correctlyNo
rate_limitedThe AI provider is busyYes
timeout, unavailable, internalTemporary failureYes
content_filtered, truncated, cancelledThe answer was blocked, cut short, or cancelledYes
error is written for the reader, so you can show it as is.

Good to know

  • Restricted documents are never used.
  • When the knowledge base does not cover the question, the answer says so instead of guessing.
  • The WebSocket transport streams the same answers over one connection. The SDK uses it by default.