npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/sdk

You 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_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.
  • 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-polyfill

Then 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 fails safeUrl stays 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