@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
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)
| Option | Type | Default | Description |
|---|
knowledgeBaseId | string | required | The 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/ |
clientKey | string | none | Public key (kl_pk_…), for browsers and React Native |
clientId, clientSecret | string | none | Secret key pair (kl_ci_…, kl_cs_…), for servers only |
baseUrl | string | https://app.kelu.dev | API base URL. The WebSocket URL follows it |
transport | "auto" | "ws" | "sse" | "auto" | How answers stream. See Transport |
widgetId | string | none | A 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> | automatic | Returns 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
| Method | Returns | Description |
|---|
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
| Option | Type | Description |
|---|
threadId | string | Continue an earlier conversation |
groupIds | string[] | 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 |
signal | AbortSignal | Cancel the request |
onToken | (token: string) => void | Called for each piece of the answer as it streams |
onCitations | (citations: Citation[]) => void | Called 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) => void | Called 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
| Option | Type | Description |
|---|
limit | number | Results to return. Default 10, max 100 |
groupIds | string[] | Search only these source groups |
Types
ChatResult
| Field | Type | Description |
|---|
answer | string | The full answer, with final citation numbers |
citations | Citation[] | The sources the answer cited |
threadId | string | Pass back as threadId to continue the conversation |
messageId | string | Pass to feedback(). Empty when the workspace keeps no chat data |
powered_by, powered_by_url | string? | 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
| Field | Type | Description |
|---|
title | string | Document title |
url | string | Document URL |
snippet | string? | The cited text |
index | number? | The [n] marker the answer uses for this source. Label with this, not the position in the array |
SearchResponse
| Field | Type | Description |
|---|
results | SearchResult[] | The matches, best first |
powered_by, powered_by_url | string? | 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
| Field | Type | Description |
|---|
content | string | The matching text |
url, title | string | The document it comes from |
score | number | Relevance score from the reranker. 0 when no reranking ran, or when the reranker judged the result irrelevant |
heading_path | string? | 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); },
});
| Function | Returns | Description |
|---|
renderMarkdown(text) | string | HTML 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) | string | The same for one line: bold, italic, strikethrough, inline code, links, images and [n] markers |
escapeHtml(text) | string | Escapes &, <, > and " |
safeUrl(url) | string | null | The 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
| Mode | Behaviour |
|---|
"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();
}
| Property | Type | Description |
|---|
message | string | A sentence you can show the reader |
code | string | One of the codes below. Empty for a network failure |
status | number | HTTP status. 0 for a network failure. 200 when the answer failed after it started streaming over SSE |
retryable | boolean | Whether asking again could succeed |
requestId | string? | Quote it when you contact support |
retryAfterSeconds | number? | How long to wait, when rate limited |
| Code | Retryable | Meaning |
|---|
bad_request | no | Empty 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 |
unauthorized | no | The key is missing, invalid or revoked, or the page’s origin is not on the key’s allowlist (status 403) |
forbidden | no | The 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_failed | no | A 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_exceeded | no | The workspace has used its monthly question allowance |
not_found | no | feedback() 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_limited | yes | The key’s rate limit was hit, or the AI provider is busy |
unavailable | yes | The model provider is temporarily unavailable |
timeout | yes | The answer took too long |
misconfigured | no | The model provider rejected its credentials, or its account is out of credit. An admin has to fix it |
content_filtered | yes | The model withheld part of the answer under a safety filter |
truncated | yes | The answer was cut off |
cancelled | yes | The request was cancelled before the answer was ready |
internal | yes | Any other server error |
connection_closed | yes | The connection dropped before the answer arrived |
aborted | yes | You 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.