@kelu/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.
@kelu/sdk is not on npm yet, so npm install @kelu/sdk fails today. Until it is published, embed the website widget or call the REST API directly.
Example
import { Kelu } from "@kelu/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.
Options
new Kelu(options)
| Option | Type | Default | Description |
|---|
knowledgeBaseId | string | required | The knowledge base every request goes to |
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> | none | Returns a reCAPTCHA v3 token, sent on every request. 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 passages. 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 once, at the end, with the cited sources |
onAnswer | (answer: string) => void | Called at the end 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 |
Citation
| Field | Type | Description |
|---|
title | string | Document title |
url | string | Document URL |
snippet | string? | The cited passage |
index | number? | The [n] marker the answer uses for this source. Label with this, not the position in the array |
SearchResult (returned as { results: SearchResult[] })
| Field | Type | Description |
|---|
content | string | The matching passage |
url, title | string | The document it comes from |
score | number | Relevance score. 0 when no reranking ran |
heading_path | string? | Section breadcrumb, for example Authentication › OAuth |
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 |
Runtimes without a global WebSocket (Node.js before 22) always use SSE. If the server goes silent for 60 seconds after accepting a question, chat() fails with timeout. The question is not resent, because it may already have been answered and counted.
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 "@kelu/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 |
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, invalid body, or unknown thread |
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, or a groupIds entry is not allowed for this key |
captcha_required, captcha_failed | no | This deployment requires reCAPTCHA for public keys, and no valid token was sent |
quota_exceeded | no | The workspace has used its monthly question allowance |
not_found | no | feedback() named a message that does not exist |
rate_limited | yes | The key’s rate limit was hit |
unavailable | yes | The model provider is temporarily unavailable |
timeout | yes | The answer took too long |
misconfigured | no | The model provider rejected its credentials. 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 |
An internal knowledge base has no public surface. Requests to one fail with status 403 and an empty code.