@keluhq/sdk
v0.1.1
Published
Official TypeScript SDK for Kelu — RAG chat with citations over crawled documentation.
Readme
@keluhq/sdk
Official TypeScript/JavaScript SDK for Kelu — an AI knowledge assistant that answers questions with RAG-based streaming chat and citations over your crawled documentation.
Works in browsers, React Native (with a streaming-fetch polyfill), and Node.js v18+.
Full reference: https://docs.kelu.dev/sdks/web
Installation
npm install @keluhq/sdkYou need a knowledge base ID and a client key. Open the knowledge base in the Kelu dashboard and go to Integrations:
- Client Keys creates a public key (
kl_pk_…) for browsers or a secret pair (kl_ci_…+kl_cs_…) for servers. - Developer SDKs shows starter code with your knowledge base ID and a public key filled in. The ID is also the part of the dashboard address after
/knowledge-bases/.
Quickstart
Browser (public key)
A public key is safe to ship in client-side code.
import { Kelu } from "@keluhq/sdk";
const kelu = new Kelu({
knowledgeBaseId: "YOUR_KNOWLEDGE_BASE_ID",
clientKey: "kl_pk_...",
});
const { answer, citations, threadId } = await kelu.chat(
"How do I set up authentication?",
);
console.log(answer); // Markdown, with [n] markers
console.log(citations); // [{ title, url, snippet?, index? }]CAPTCHA
On app.kelu.dev, a public key 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 then 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 twice.
- If Google's script is blocked (an ad blocker, a Content Security Policy) or takes longer than 12 seconds, the call throws
captcha_failedwithretryable: true, and the question is not sent. - With a Content Security Policy, allow
https://www.google.com/recaptcha/andhttps://www.gstatic.com/recaptcha/inscript-src;https://www.google.com/recaptcha/andhttps://recaptcha.google.com/recaptcha/inframe-src; andhttps://www.google.com/recaptcha/,https://app.kelu.devandwss://app.kelu.devinconnect-src. Google's own list is in the reCAPTCHA FAQ. - To mint tokens yourself, pass
getCaptchaToken. It replaces the automatic one. - 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.
React — streaming chat component
Tokens stream in as they arrive. renderMarkdown turns the answer into safe HTML. For a finished panel instead, use @keluhq/react.
import { useState, useCallback } from "react";
import { Kelu, renderMarkdown, type Citation } from "@keluhq/sdk";
const kelu = new Kelu({
knowledgeBaseId: "YOUR_KNOWLEDGE_BASE_ID",
clientKey: "kl_pk_...",
});
export function ChatBox() {
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState("");
const [citations, setCitations] = useState<Citation[]>([]);
const [threadId, setThreadId] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const handleSubmit = useCallback(async () => {
setAnswer("");
setCitations([]);
setLoading(true);
try {
const result = await kelu.chat(query, {
...(threadId ? { threadId } : {}), // continue the conversation
onToken: (t) => setAnswer((prev) => prev + t),
onAnswer: (a) => setAnswer(a), // finished text, when the server rewrote it
onCitations: (c) => setCitations(c),
});
setThreadId(result.threadId); // persist for follow-up questions
} finally {
setLoading(false);
}
}, [query, threadId]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button onClick={handleSubmit} disabled={loading}>Ask</button>
{answer && <div dangerouslySetInnerHTML={{ __html: renderMarkdown(answer) }} />}
{citations.length > 0 && (
<ol>
{citations.map((c) => (
<li key={c.index ?? c.url}>
[{c.index}] <a href={c.url}>{c.title}</a>
</li>
))}
</ol>
)}
</div>
);
}Label each citation with its index, not its position in the list: the list holds only the sources the answer cited, so the numbers can skip.
React Native
A public key needs a reCAPTCHA v3 token on app.kelu.dev, and React Native has no browser page to run reCAPTCHA in, so the client cannot get one for you there. Run reCAPTCHA v3 in a WebView and pass its token through getCaptchaToken, or send questions through your own server, which calls Kelu with a secret pair.
React Native's fetch does not stream. "auto" transport streams over React Native's WebSocket. To stream over SSE too, install a polyfill:
npm install react-native-fetch-api web-streams-polyfillThen in your entry file (before any SDK calls):
// index.js
import "web-streams-polyfill/polyfill";
import "react-native-fetch-api/polyfill";Without the polyfill the SSE path still works — the full response is
buffered and the resolved ChatResult is still correct, but onToken will
not fire incrementally.
Node.js backend (client credentials)
Use clientId + clientSecret for server-to-server calls. A secret pair never needs a captcha token, and always uses SSE, so the secret travels in a header and never in a WebSocket URL that access logs would keep. Never expose these in browser or mobile code — the SDK will emit a console.warn if it detects a browser environment.
import { Kelu } from "@keluhq/sdk";
const kelu = new Kelu({
knowledgeBaseId: "YOUR_KNOWLEDGE_BASE_ID",
clientId: process.env.KELU_CLIENT_ID!, // kl_ci_…
clientSecret: process.env.KELU_CLIENT_SECRET!, // kl_cs_…
});
// RAG chat
const { answer, citations, messageId } = await kelu.chat(
"Summarise the rate-limiting strategy",
);
// Hybrid search (returns matching chunks + scores)
const { results } = await kelu.search("rate limits", { limit: 5 });
for (const r of results) {
console.log(r.title, r.score, r.url);
}
// Rate the answer
await kelu.feedback({ messageId, rating: "up", comment: "Spot on!" });Showing the answer
answer, and each piece passed to onToken, is Markdown. Its [n] markers match Citation.index. The SDK exports the renderer the Kelu widget and KeluChat use:
| 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 |
| 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 |
- All text is escaped, so the result is safe for
innerHTML. A link whose URL failssafeUrlstays plain text. - Links open in a new tab.
[n]becomes<sup class="kl-md-cite">n</sup>. - Headings move down two levels (
#to<h3>,##to<h4>). - A code block comes with a Copy button (
button.kl-copy) that 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 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
By default (transport: "auto") the SDK uses a WebSocket connection for chat, with automatic fallback to SSE fetch when WebSocket is unavailable or fails to connect.
// Default — WebSocket with SSE fallback
const kelu = new Kelu({ knowledgeBaseId, clientKey });
// WebSocket only (no fallback; throws on connection failure)
const kelu = new Kelu({ knowledgeBaseId, clientKey, transport: "ws" });
// SSE fetch only
const kelu = new Kelu({ knowledgeBaseId, clientKey, transport: "sse" });A 5-second connect timeout applies; on expiry the instance falls back to SSE (in "auto" mode) for all subsequent calls.
React Native: WebSocket is available globally in React Native, so "auto" will use it. Pass transport: "sse" if you need to force the SSE path.
Node.js 22+: WebSocket is available globally; earlier versions fall back to SSE automatically.
Secret pair: always SSE, whatever transport says, so the secret is sent as a header and never in a WebSocket URL.
API Reference
new Kelu(options)
| Option | Type | Required | Description |
|---|---|---|---|
| knowledgeBaseId | string | Yes | Your knowledge base ID (see Installation) |
| clientKey | string | Browser/RN | Public key (kl_pk_...) |
| clientId | string | Server | Client ID (kl_ci_...) |
| clientSecret | string | Server | Client secret (kl_cs_...) |
| baseUrl | string | No | Override the API base URL (default https://app.kelu.dev) |
| transport | "auto" \| "ws" \| "sse" | No | Chat transport (default "auto") |
| widgetId | string | No | Widget integration the answers are for; sent with every chat so its configured voice applies |
| getCaptchaToken | () => Promise<string> | No | Your own reCAPTCHA v3 token source (X-Captcha-Token), replacing the automatic one. See CAPTCHA. Return "" to send none |
kelu.chat(query, opts?)
Returns Promise<ChatResult>.
| Option | Type | Description |
|---|---|---|
| threadId | string | Continue an existing conversation |
| user | { email?, id? } | End-user identity |
| signal | AbortSignal | Cancel the request |
| onToken | (token: string) => void | Streaming token callback |
| onAnswer | (answer: string) => void | The whole finished answer, sent at the end only when it differs from what streamed (renumbered [n] markers, added pictures). Replaces the tokens — re-render from it |
| onCitations | (citations: Citation[]) => void | Citations callback. Over SSE it can fire twice — the sources retrieval found, then the ones the answer cited — and the last call wins. Over WebSocket it fires once |
| groupIds | string[] | Restrict retrieval to these source-group IDs |
| getCaptchaToken | () => Promise<string> | Per-call captcha token; wins over the client's |
ChatResult
| Field | Type | Description |
|---|---|---|
| answer | string | The full answer in Markdown, with final citation numbers |
| citations | Citation[] | The sources the answer cited: { title, url, snippet?, index? } |
| threadId | string | Pass back as threadId to continue the conversation |
| messageId | string | Pass to feedback() |
| powered_by, powered_by_url | string? | Attribution text and link (Powered by Kelu, https://kelu.dev). Show them unless your plan lets you hide branding |
threadId and messageId are "" when the workspace keeps no
data (zero retention): there is no thread to continue and no message to rate.
kelu.search(query, opts?)
Returns Promise<SearchResponse>: { results, powered_by?, powered_by_url? }.
| Option | Type | Description |
|---|---|---|
| limit | number | Max results to return (default 10, max 100) |
| groupIds | string[] | Restrict search to these source-group IDs |
Each SearchResult has content, url, title, score (reranker relevance, 0 when no reranking ran) and heading_path? (section breadcrumb, for example Authentication › OAuth).
kelu.feedback(payload)
Returns Promise<void>.
| Field | Type | Description |
|---|---|---|
| messageId | string | ID of the message to rate |
| rating | "up" \| "down" | Whether the answer was useful. A second rating replaces the first |
| comment | string | Optional free-text |
kelu.clearFeedback(messageId)
Returns Promise<void>. Withdraws the rating on a message (pressing a selected
thumb again). Clearing a message that was never rated is not an error.
VERSION
The installed version, for example "0.1.0". Quote it when you report a problem.
import { VERSION } from "@keluhq/sdk";KeluError
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, or the AI provider account is out of credit |
| 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. 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.
License
MIT
