@keluhq/sdk is the JavaScript client for Kelu. It streams answers with citations, runs search and records feedback. It works in browsers, React Native and Node.js 18 or later, and ships ESM and CommonJS builds with types.

Install

npm install @keluhq/sdk
VERSION is the installed version, for example "0.1.0". Quote it when you report a problem:
import { VERSION } from "@keluhq/sdk";

Example

import { Kelu } from "@keluhq/sdk";

const kelu = new Kelu({
  knowledgeBaseId: "YOUR_KNOWLEDGE_BASE_ID",
  clientKey: "kl_pk_...",
});

const { answer, citations, threadId, messageId } = await kelu.chat(
  "How do I authenticate?",
  { onToken: (t) => render(t) },
);

await kelu.chat("And with SSO?", { threadId });     // follow-up, same conversation
await kelu.feedback({ messageId, rating: "up" });   // rate the first answer
On a server, pass clientId and clientSecret instead of clientKey. Create either kind of key under the knowledge base’s Integrations → Client Keys. The Developer SDKs tab beside it shows this code with your knowledge base ID and a public key filled in.

CAPTCHA

On app.kelu.dev, a public key (kl_pk_…) must send a reCAPTCHA v3 token with every chat, search and feedback request. In a browser, the client does this for you. On the first request it asks Kelu whether the key needs a token, loads Google’s reCAPTCHA script if it does, and from then on sends a fresh token with each request. There is nothing to set up.
  • Google shows its reCAPTCHA badge in a corner of the page. You may hide it only if your page shows Google’s reCAPTCHA notice instead.
  • A page that already runs reCAPTCHA with a site key of its own keeps working. The client adds an invisible reCAPTCHA client for Kelu’s key instead of loading Google’s script a second time.
  • If Google’s script cannot load, for example because an ad blocker or a Content Security Policy blocks it, or it takes longer than 12 seconds, the call throws captcha_failed with retryable: true, and the question is not sent.
  • With a Content Security Policy, allow https://www.google.com/recaptcha/ and https://www.gstatic.com/recaptcha/ in script-src; https://www.google.com/recaptcha/ and https://recaptcha.google.com/recaptcha/ in frame-src; and https://www.google.com/recaptcha/, https://app.kelu.dev and wss://app.kelu.dev in connect-src. Google’s own list is in the reCAPTCHA FAQ.
  • To mint tokens yourself, pass getCaptchaToken. It replaces the automatic one. Return "" from it to send no token.
  • Rather not load Google’s script on your page? Send questions from your own server, which calls Kelu with a secret pair and needs no token. Never put the secret pair in browser code: anyone could copy it from the page and spend your question allowance, with no captcha or origin check to stop them.
  • A secret pair never needs a token. Code with no browser page, such as a server, React Native or a web worker, gets no automatic token. React Native has no browser to run reCAPTCHA in, so with a public key on app.kelu.dev, send questions through your own server, which calls Kelu with a secret pair.

Options

new Kelu(options)
OptionTypeDefaultDescription
knowledgeBaseIdstringrequiredThe knowledge base every request goes to. Find it, with code that has it filled in, under the knowledge base’s Integrations → Developer SDKs. It is also the part of the dashboard address after /knowledge-bases/
clientKeystringnonePublic key (kl_pk_…), for browsers and React Native
clientId, clientSecretstringnoneSecret key pair (kl_ci_…, kl_cs_…), for servers only
baseUrlstringhttps://app.kelu.devAPI base URL. The WebSocket URL follows it
transport"auto" | "ws" | "sse""auto"How answers stream. See Transport
widgetIdstringnoneA widget integration id. Answers use that widget’s configured voice, and each question must pass that widget’s Enabled switch and Allowed origins
getCaptchaToken() => Promise<string>automaticReturns a reCAPTCHA v3 token, sent on every request. Leave it out: in a browser, a public key gets its tokens automatically. See CAPTCHA. Return "" to send none
A clientSecret in a browser bundle is visible to every visitor. The client logs a warning when it finds one in a browser. Use clientKey there.

Methods

MethodReturnsDescription
chat(query, options?)Promise<ChatResult>Ask a question and stream the answer. Resolves when the answer is complete
search(query, options?)Promise<SearchResponse>The best-matching pages, each with its best-matching chunk (a section of the page). No answer is generated
feedback({ messageId, rating, comment? })Promise<void>Rate an answer "up" or "down". A second rating replaces the first
clearFeedback(messageId)Promise<void>Remove the rating on an answer

chat() options

OptionTypeDescription
threadIdstringContinue an earlier conversation
groupIdsstring[]Answer only from these source groups. Can narrow the key’s allowed groups, never widen them
user{ email?, id? }Who is asking. The conversation is attributed to id, or to email when there is no id
signalAbortSignalCancel the request
onToken(token: string) => voidCalled for each piece of the answer as it streams
onCitations(citations: Citation[]) => voidCalled at the end with the cited sources. Over SSE it is also called first, before the answer streams, with the pages found, when there are any
onAnswer(answer: string) => voidCalled at the end when the text changed after it streamed, for example when Kelu renumbered the citation markers. Replace the streamed text with this full answer
getCaptchaToken() => Promise<string>Overrides the client option for this call

search() options

OptionTypeDescription
limitnumberResults to return. Default 10, max 100
groupIdsstring[]Search only these source groups

Types

ChatResult
FieldTypeDescription
answerstringThe full answer, with final citation numbers
citationsCitation[]The sources the answer cited
threadIdstringPass back as threadId to continue the conversation
messageIdstringPass to feedback(). Empty when the workspace keeps no chat data
powered_by, powered_by_urlstring?Attribution text and link, Powered by Kelu and https://kelu.dev. Show them unless the plan lets you hide branding (hide_branding_allowed from widget-config)
Citation
FieldTypeDescription
titlestringDocument title
urlstringDocument URL
snippetstring?The cited text
indexnumber?The [n] marker the answer uses for this source. Label with this, not the position in the array
SearchResponse
FieldTypeDescription
resultsSearchResult[]The matches, best first
powered_by, powered_by_urlstring?Attribution text and link, Powered by Kelu and https://kelu.dev. Show them unless the plan lets you hide branding (hide_branding_allowed from widget-config)
SearchResult
FieldTypeDescription
contentstringThe matching text
url, titlestringThe document it comes from
scorenumberRelevance score from the reranker. 0 when no reranking ran, or when the reranker judged the result irrelevant
heading_pathstring?Section breadcrumb, for example Authentication › OAuth

Showing the answer

answer, and each piece passed to onToken, is Markdown. Its [n] markers match Citation.index. The SDK exports the renderer the widget and KeluChat use, so the answer looks the same everywhere:
import { renderMarkdown } from "@keluhq/sdk";

let text = "";
await kelu.chat("How do I authenticate?", {
  onToken: (t) => { text += t; el.innerHTML = renderMarkdown(text); },
  onAnswer: (full) => { text = full; el.innerHTML = renderMarkdown(text); },
});
FunctionReturnsDescription
renderMarkdown(text)stringHTML for a whole answer: headings, lists, code blocks, tables, quotes, rules, links, images and [n] markers. Safe to call on a half-streamed answer: an unclosed code block shows as one in progress
renderInline(text)stringThe same for one line: bold, italic, strikethrough, inline code, links, images and [n] markers
escapeHtml(text)stringEscapes &, <, > and "
safeUrl(url)string | nullThe trimmed URL if it starts with http://, https://, mailto:, tel:, / or #, otherwise null
What the HTML looks like:
  • All text is escaped, so the result is safe for innerHTML. A link whose URL fails safeUrl stays plain text.
  • Links open in a new tab (target="_blank" rel="noopener noreferrer").
  • [n] becomes <sup class="kl-md-cite">n</sup>.
  • Headings move down two levels, # to <h3> and ## to <h4>, so an answer never outranks your page’s headings.
  • A code block comes with a Copy button (button.kl-copy). It does nothing until you handle the click:
el.addEventListener("click", (e) => {
  const button = (e.target as Element).closest(".kl-copy");
  const code = button?.closest(".kl-code")?.querySelector("code")?.textContent;
  if (code) navigator.clipboard.writeText(code);
});
No styles come with it. Style the classes kl-code, kl-code-bar, kl-md-lang, kl-copy, kl-md-cite, kl-md-table, kl-md-img and kl-md-imglink yourself.

Transport

ModeBehaviour
"auto" (default)Uses a WebSocket. If it fails to connect within 5 seconds, this question and every later one on the client go over SSE
"ws"WebSocket only. A connection failure throws
"sse"SSE only
A secret pair always uses SSE, whatever transport says, so the secret travels in a header and never in a WebSocket URL, where access logs would keep it. Runtimes without a global WebSocket (Node.js before 22) always use SSE too. Over a WebSocket, if the server goes silent for 60 seconds after accepting a question, chat() fails with timeout, and in "auto" mode later questions on that client go over SSE. The question is not resent, because it may already have been answered and counted. SSE has no such timer.

React Native

"auto" streams over React Native’s WebSocket. On the SSE path, React Native’s fetch does not stream: onToken and onCitations do not fire, but the resolved ChatResult is complete. To stream over SSE, import a polyfill before any SDK call:
npm install react-native-fetch-api web-streams-polyfill
// index.js
import "web-streams-polyfill/polyfill";
import "react-native-fetch-api/polyfill";

Errors

Every failure throws KeluError.
import { KeluError } from "@keluhq/sdk";

try {
  await kelu.chat("...");
} catch (err) {
  if (err instanceof KeluError && err.retryable) showRetryButton();
}
PropertyTypeDescription
messagestringA sentence you can show the reader
codestringOne of the codes below. Empty for a network failure
statusnumberHTTP status. 0 for a network failure. 200 when the answer failed after it started streaming over SSE
retryablebooleanWhether asking again could succeed
requestIdstring?Quote it when you contact support
retryAfterSecondsnumber?How long to wait, when rate limited
CodeRetryableMeaning
bad_requestnoEmpty question, question over 4,000 characters, or invalid body. Over SSE, also a threadId or widgetId that is not a valid UUID. Over a WebSocket, also a threadId or widgetId this knowledge base does not have, or one that is not a valid UUID
unauthorizednoThe key is missing, invalid or revoked, or the page’s origin is not on the key’s allowlist (status 403)
forbiddennoThe key belongs to another knowledge base, the knowledge base is archived, or a groupIds entry is not allowed for this key. Over a WebSocket, also a widget that is switched off, or a page outside that widget’s allowed origins
captcha_required, captcha_failednoA public key sent no reCAPTCHA token, or one that failed the check. captcha_failed is retryable when the browser could not get a token: Google’s script was blocked or too slow. See CAPTCHA
quota_exceedednoThe workspace has used its monthly question allowance
not_foundnofeedback() named a message that does not exist, or, over SSE, threadId names a conversation this knowledge base does not have or widgetId names no widget of it
rate_limitedyesThe key’s rate limit was hit, or the AI provider is busy
unavailableyesThe model provider is temporarily unavailable
timeoutyesThe answer took too long
misconfigurednoThe model provider rejected its credentials, or its account is out of credit. An admin has to fix it
content_filteredyesThe model withheld part of the answer under a safety filter
truncatedyesThe answer was cut off
cancelledyesThe request was cancelled before the answer was ready
internalyesAny other server error
connection_closedyesThe connection dropped before the answer arrived
abortedyesYou cancelled through signal after the question went out over a WebSocket. Cancelling earlier throws with an empty code. Over SSE, cancelling while waiting for data throws connection_closed, and cancelling from inside a callback such as onToken ends the call with the partial answer instead of throwing
An internal knowledge base has no public surface. Requests to one fail with status 403 and an empty code. Over SSE, a widget that is switched off, or a page outside that widget’s allowed origins, fails the same way. With transport: "ws", a key the server refuses when the socket opens (missing, invalid, wrong origin, internal or archived knowledge base) fails the WebSocket handshake. That throws with status 0 and an empty code. "auto" falls back to SSE and reports the codes above.