The WebSocket transport gives the same answers as chat, over one connection you keep open. The SDK uses it by default and falls back to SSE when it cannot connect.

Connect

wss://app.kelu.dev/api/v1/public/knowledge-bases/:knowledgeBaseId/ws?client_key=kl_pk_...
On a server, use ?client_id=kl_ci_...&client_secret=kl_cs_... instead. If the handshake is refused you get a plain HTTP error instead of a connection: 401 for missing or wrong credentials or a key for an internal knowledge base, 403 for a disallowed origin, another knowledge base’s key or an archived knowledge base, 426 if the request is not a WebSocket upgrade. Both 401 and a disallowed origin carry code: "UNAUTHORIZED", so check the status. Browsers do not show your code the status of a refused handshake, only that the connection failed.

Ask a question

Send a chat frame:
{ "type": "chat", "query": "How do I rotate a client key?" }
FieldRequiredDescription
typeYesAlways "chat"
queryYesThe question. Max 4,000 characters
thread_idNoContinue a conversation
captcha_tokenWhen CAPTCHA is onA reCAPTCHA v3 token, for public keys. See CAPTCHA
group_idsNoAnswer only from these source groups
userNo{ "id": "...", "email": "..." }, for analytics. id is used when both are sent
widget_idNoA saved widget’s id. The answer uses that widget’s voice. The question must also pass that widget’s Enabled switch and Allowed origins, checked against the page that opened the connection

Receive the answer

token frames stream the text. One done frame ends the answer:
{ "type": "token", "content": "Open Settings and " }
{ "type": "token", "content": "click Revoke [1]." }
{ "type": "done", "thread_id": "9b1f...", "message_id": "c3a2...", "citations": [{ "index": 1, "title": "Client Keys", "url": "https://docs.example.com/keys", "snippet": "..." }], "powered_by": "Powered by Kelu", "powered_by_url": "https://kelu.dev" }
On the done frame:
  • citations lists the sources the answer cited, each with index, title, url and snippet. The field is left out when the answer cited nothing.
  • 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 text changed after it streamed, for example when the [n] markers were renumbered or pictures from the cited pages were added. When it is there, show it instead of the text you built from token frames. See Renumbered answers.

Errors

A failed question gets one error frame instead of done:
{ "type": "error", "code": "captcha_required", "message": "captcha_token is required for this knowledge base", "retryable": false, "request_id": "5f0c..." }
codeMeaning
bad_requestInvalid JSON, a type other than chat, empty query, a question over 4,000 characters, a thread_id that does not exist or is from another knowledge base, or a widget_id that is not valid or names no widget of this knowledge base
captcha_requiredPublic key, CAPTCHA is on, and no captcha_token was sent
captcha_failedThe token did not verify
rate_limitedThe key’s per-minute limit was reached
forbiddenA group_ids entry is not a source group of this knowledge base or not one of the key’s allowed groups, or the widget named by widget_id is turned off or does not allow the page’s origin. message says which. Also sent when the knowledge base was archived, and then the connection closes
unauthorizedThe key was revoked. The connection closes
anything elseThe answer failed. Same codes as chat errors
retryable says whether sending the same question again can work. After an error frame the connection stays open for the next question, except where noted.

Connection rules

  • Questions are answered one at a time, in the order you send them.
  • The server pings every 30 seconds. It closes the connection after 60 seconds with no frame or pong from you. Browsers answer pings for you.
  • A single frame can be up to 64 KB.
  • The key is checked again on every question, so a revoked key stops working mid-connection.

Browser example

const ws = new WebSocket(
  "wss://app.kelu.dev/api/v1/public/knowledge-bases/KB_ID/ws?client_key=kl_pk_..."
);
let text = "";

ws.onopen = () => ws.send(JSON.stringify({ type: "chat", query: "How do I rotate a client key?" }));

ws.onmessage = (e) => {
  const frame = JSON.parse(e.data);
  if (frame.type === "token") text += frame.content;
  if (frame.type === "done") render(frame.answer ?? text, frame.citations ?? []);
  if (frame.type === "error") console.error(frame.code, frame.message);
};
The web SDK (@keluhq/sdk) handles this for you. Frames do not say which question they answer, so it gives each question in flight a connection of its own and keeps one open for the next question. A cancelled question’s connection is closed, so the rest of its answer cannot reach a later one. It gives up on connecting after 5 seconds and falls back to SSE.