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, 403 for a disallowed origin or another knowledge base’s key, 426 if the request is not a WebSocket upgrade.

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 [n] markers were renumbered. 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 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 the widget named by widget_id is turned off or does not allow the page’s origin. message says which
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 (@kelu/sdk, not on npm yet) handles this for you. It reuses one connection, gives up on connecting after 5 seconds, and falls back to SSE.