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

qkit-chatbot-console

v1.1.1

Published

QKIT Nexus embeddable admin console runtime

Readme

qkit-chatbot-console

An embeddable admin console for QKIT Nexus chatbots — the inbox your support agents use to see live sessions, take over from the AI, and reply. You mount it inside your own admin panel (behind whatever login your product already has); QKIT Nexus is only responsible for what happens inside the mounted element.

This console talks to the same backend as qkit-chatbot-widget, but the token it uses is admin-scoped — it can read and send messages for real visitors. Treat the credential that produces it accordingly. Read Security: never expose your Console API Key before you write any code.


How it fits together

┌────────────────────────┐        ┌─────────────────────────────┐        ┌───────────────────┐
│ Your backend           │  POST  │ QKIT Nexus backend          │        │ Your admin panel  │
│ (holds the Console     ├───────►│ /v1/console/auth/exchange   │        │ (browser)         │
│ API Key, never sent    │        │ validates X-Console-Key,    │        │                   │
│ to the browser)        │        │ mints a 15-min console      │        │ <script> loader   │
└────────────────────────┘        │ token scoped to one agent   │        │ → mount()         │
                                  └─────────────────────────────┘        └───────────────────┘
  1. Your backend holds a Console API Key and exchanges it, per agent login, for a short-lived console token.
  2. Your frontend embeds the console loader once and gives it a getToken() function that calls your backend for that token.
  3. The loader resolves the actual console UI (the "core") and injects it into a Shadow DOM root. Installed from npm, the core ships inside the package and is lazy-loaded from your own bundle; embedded via a <script> tag, the loader fetches core.iife.js from a CDN URL baked into the loader itself.

Before you start: create a Console API Key

Console API Keys are created on the QKIT Nexus Dashboard (nexus.qkit.vn): open your chatbot's settings and go to the Console API Keys tab (you need write access to the chatbot). When creating a key you set:

  • Name — a label so you can tell keys apart later.
  • Allowed origins — the exact origins of the admin panel pages that will embed the console, e.g. https://admin.yourcompany.com — see Origin enforcement for why this list can't be empty.
  • Optionally a rate limit (token exchanges per minute) and an expiry date.

The raw key (ck_live_...) is shown exactly once, at creation time — copy it straight into your backend's secret manager. It cannot be retrieved again; if you lose it, revoke it and create a new one. The same tab is where you edit a key's allowed origins and revoke keys later.


1. Embed once

Add the loader to the page in your admin panel where the inbox should live. Give the container an explicit height — the console fills exactly the box you give it and scrolls its own panels internally (the message list, a long settings form, the leads list) rather than growing the page, but only if that box has a real height to fill in the first place:

<script src="https://unpkg.com/qkit-chatbot-console@^1/dist/loader.iife.js"></script>
<div id="qkit-console" style="height: 640px;"></div>
<script>
  window.QKitConsole.mount({
    container: "#qkit-console",
    getToken: () => fetch("/api/qkit-console-token", { credentials: "include" })
      .then((res) => res.json())
      .then((data) => data.consoleToken),
  });
</script>

height: 640px above is a reasonable default for a dedicated inbox page; a flex/grid layout that gives the container a definite size (flex: 1 inside a sized column, height: 100% inside an ancestor that itself resolves to a real height, etc.) works just as well — what matters is that some ancestor in the chain resolves to a real pixel height, not auto. If you don't give it one, the console falls back to min-height: 560px so it doesn't collapse to nothing, but that's a floor, not a fix: content taller than 560px in a still-unbounded container grows the page again, the same original problem just bounded to a much smaller default. Don't rely on the fallback for a real embed.

@^1 pins the major version and lets npm/unpkg serve any 1.x.y — never pin an exact patch (@1.4.2), since unpkg serves pinned versions immutably and you'd freeze yourself out of fixes.

If your admin panel has a bundler, you can install the loader from npm instead of using the script tag:

npm install qkit-chatbot-console
import { QKitConsole } from "qkit-chatbot-console";

The npm path does not hit the CDN: the installed package already contains the exact core matching its own version, so the loader lazy-loads it from your bundle (your bundler code-splits it into its own chunk) and only falls back to the CDN if that chunk fails to load. To get console updates on the npm path, update the package like any other dependency. The CDN fetch described below applies to the <script>-tag path, which has no local copy.

This script tag is the only thing that ends up frozen into your page. dist/loader.iife.js is a few kilobytes: it knows the CDN URL of the current console build and how to inject it. All of the actual UI — the inbox, the conversation view, the socket connection — lives in a separate bundle (dist/core.iife.js) that the loader fetches from that URL at runtime. Because the outer script tag above is pinned to @^1 rather than an exact version, every embedded site picks up a new loader (and therefore a new core URL) automatically once QKIT publishes one — see Auto-update for exactly what "publishes one" requires now that there's no backend manifest in the loop. You never edit this snippet again for an update — see CONTRIBUTING.md for the release process.

Self-hosting. To override where the core is loaded from, set QKitConsole.coreUrl to your own URL before the first mount() or watchUnread() call.


Security: never expose your Console API Key

The Console API Key (ck_live_...) is the credential your backend uses to call POST /v1/console/auth/exchange. It is sent as the X-Console-Key header and is never meant to be seen by a browser.

The Console API Key must never reach the browser, a frontend bundle, a client-side environment variable, or any request your visitors' or agents' browsers can inspect. It must live only in your backend's server environment.

What an attacker gets if it leaks: your Console API Key alone lets anyone mint console tokens for your tenant, impersonate any agent role (agent or manager), read every visitor conversation, and send messages into live chats as your support team — for as long as the key is valid, no extra authentication required. Treat it like a database password, not like a public client ID. If one leaks, revoke it immediately — the "Console API Keys" tab in your chatbot's settings on the QKIT Nexus Dashboard lists and revokes them — and issue a new one.

The console token it produces is comparatively low-risk: it is scoped to one agent, expires in 15 minutes, and — see below — only works from an Origin your key explicitly allowed. That's the token your frontend is allowed to hold, and only in memory.

Origin enforcement

Every Console API Key is created with a non-empty allowedOrigins list (the origins of the admin panel pages that are allowed to embed the console). Every /v1/console/* request except the token exchange itself must carry an Origin header that is present in the key's allowlist — this is fail-closed: a missing Origin header, or a key created with an empty allowlist, is rejected outright, not treated as "no restriction." POST /v1/console/auth/exchange is the one exception, because it's a server-to-server call authenticated by X-Console-Key, not by a browser-supplied Origin.

If you see 403 Console key has no allowed origins or 403 Origin not allowed for this console key, it's almost always because the Console API Key's allowedOrigins doesn't include the exact origin the admin panel page is served from — not a bug in this package. Fix it in the same Console API Keys tab on the Dashboard where the key was created.

Similarly, 429 Too many token exchanges for this console key means you've hit the key's per-minute rate limit on POST /v1/console/auth/exchange (set at key creation). In production this shouldn't happen — the console only asks for a token every ~15 minutes per agent — so it usually points at a loop in your own token endpoint calling the exchange more often than agents actually need it.


2. The token endpoint, on your own backend

Your backend is the only thing that ever holds the Console API Key. It exposes an endpoint your frontend can call (behind your normal authentication) that exchanges that key, server-to-server, for a console token scoped to the logged-in agent.

Complete, runnable example (Node + Express):

import express from "express";

const app = express();
app.use(express.json());

// Loaded from your own secret manager / environment — NEVER hardcoded,
// NEVER checked into source control, NEVER sent to the browser.
const CONSOLE_API_KEY = process.env.QKIT_CONSOLE_API_KEY;
const QKIT_API_BASE_URL = process.env.QKIT_API_BASE_URL ?? "https://api.nexus.qkit.vn";

// Your own auth middleware — replace with whatever already protects your
// admin panel. `req.agent` below stands in for "the logged-in support agent".
function requireAgentSession(req, res, next) {
  if (!req.agent) return res.status(401).json({ error: "not signed in" });
  next();
}

app.post("/api/qkit-console-token", requireAgentSession, async (req, res) => {
  const response = await fetch(`${QKIT_API_BASE_URL}/v1/console/auth/exchange`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Console-Key": CONSOLE_API_KEY,
    },
    body: JSON.stringify({
      externalUserId: req.agent.id,
      displayName: req.agent.name,
      email: req.agent.email,
      role: req.agent.isManager ? "manager" : "agent",
    }),
  });

  if (!response.ok) {
    return res.status(502).json({ error: "token exchange failed" });
  }

  // Unlike the other /v1/console/* routes, auth/exchange returns its body
  // directly (no `{ data: ... }` envelope): { consoleToken, expiresIn }.
  const { consoleToken } = await response.json();
  res.json({ consoleToken });
});

app.listen(3000);

Notes on the request body — POST /v1/console/auth/exchange:

| Field | Required | Notes | | -------------- | :------: | -------------------------------------------------------------| | externalUserId | Yes | Your own stable identifier for the agent (max 190 chars). | | displayName | Yes | Shown in the console UI (max 255 chars). | | email | No | Display only — never used as a login identifier. | | role | Yes | "agent" (inbox only) or "manager" (inbox, settings, leads, analytics). |

The response's consoleToken is a JWT that expires in 15 minutes (expiresIn: 900). Your frontend's getToken() (below) is called again whenever the console needs a fresh one — you don't need to build any refresh scheduling yourself.


3. mount() options

import { QKitConsole } from "qkit-chatbot-console";
// or, via the script tag, window.QKitConsole

const handle = await QKitConsole.mount({
  container: "#qkit-console",
  getToken: () => fetch("/api/qkit-console-token", { credentials: "include" })
    .then((r) => r.json())
    .then((d) => d.consoleToken),
});

// Later, e.g. when the agent navigates away from the inbox page:
handle.unmount();

QKitConsoleOptions, taken from src/types.ts:

| Option | Type | Required | Description | | ----------------- | ------------------------------------------------------------ | :------: | ----------- | | container | string \| HTMLElement | Yes | CSS selector or element the console mounts into. The console renders inside a Shadow DOM root attached to this element, so your page's CSS can't reshape it and its CSS can't leak onto your page. Give it an explicit height — see Embed once — so the console scrolls internally instead of growing your page. | | getToken | () => Promise<string> | Yes | Returns a console token, i.e. calls your own backend endpoint from step 2. Called again automatically whenever the cached token is near expiry or a request comes back 401. | | apiBaseUrl | string | No | Overrides the QKIT Nexus API base URL. Defaults to https://api.nexus.qkit.vn. | | locale | "vi" \| "en" | No | UI language. Defaults to auto-detect from the browser's navigator.language (vi for Vietnamese, otherwise en). | | theme | "light" \| "dark" \| "auto" | No | Defaults to "auto". | | onUnreadChange | (count: number, isCountCapped: boolean) => void | No | Fires whenever the mounted console's unread count (or its capped-ness) changes. Sourced from the same authoritative read as watchUnread()'s onChange — see below for what the two arguments mean. | | onError | (error: QKitConsoleError) => void | No | Fires on errors the console can't resolve on its own (e.g. socket failures). QKitConsoleError is { code: string; message: string; cause?: unknown }. |

mount() returns a ConsoleHandle ({ unmount(): void }). Calling mount() again on the same container tears down the previous instance first (closes its socket, runs cleanup) rather than leaking it — but prefer calling unmount() yourself when you know the console is going away, e.g. on an SPA route change.


4. Unread badges

Most admin panels have the inbox on one page but want an unread badge visible everywhere (a sidebar link, a tab title, a nav item). There are two ways to get that, and they have very different weight — pick based on where the badge lives, not just which one is more convenient to call.

Real gzipped sizes as of this build, so you can see the difference yourself: core.iife.js is ~24 KB gzipped (70.3 KB minified — Preact plus the whole inbox UI); unread.iife.js is ~1.4 KB gzipped (2.7 KB minified, poll-only, no Preact, no socket); loader.iife.js is ~1.2 KB gzipped.

On the page that already hosts the console: QKitConsole.watchUnread()

import { QKitConsole } from "qkit-chatbot-console";

const watcher = await QKitConsole.watchUnread({
  getToken: () => fetch("/api/qkit-console-token", { credentials: "include" })
    .then((r) => r.json())
    .then((d) => d.consoleToken),
  onChange: (count, isCountCapped) => {
    const badge = document.querySelector("#inbox-badge");
    if (count === 0) {
      badge.hidden = true;
      return;
    }
    badge.hidden = false;
    // isCountCapped: the backend's unread-summary read is page-limited, so
    // when it's true `count` is a lower bound, not an exact total — render
    // it as "N+" rather than implying precision you don't have.
    badge.textContent = isCountCapped ? `${count}+` : String(count);
  },
});

// Later, e.g. when the badge leaves the DOM:
watcher.stop();

This is not lightweight. QKitConsole.watchUnread() goes through the same loadCore() the loader uses for mount(), so calling it injects the full core.iife.js bundle (~24 KB gzipped, all of Preact and the inbox UI) just like mounting the console does — watchUnread's polling logic itself is cheap, but getting to it costs the whole core. That's a reasonable trade only on a page that has already paid for the core, i.e. the same page where you called mount() (the core is already loaded and cached, so a second watchUnread() call there is free) — not on every other page of your admin panel.

On every other page: the standalone unread.iife.js bundle

For a badge that needs to sit in the nav on pages that don't otherwise load the console, use the dedicated small bundle instead of the loader:

<script src="https://unpkg.com/qkit-chatbot-console@^1/dist/unread.iife.js"></script>
<script>
  const watcher = QKitConsoleUnread.watchUnread({
    getToken: () => fetch("/api/qkit-console-token", { credentials: "include" })
      .then((r) => r.json())
      .then((d) => d.consoleToken),
    onChange: (count, isCountCapped) => {
      const badge = document.querySelector("#inbox-badge");
      badge.hidden = count === 0;
      badge.textContent = isCountCapped ? `${count}+` : String(count);
    },
  });

  // Later, e.g. when the badge leaves the DOM:
  watcher.stop();
</script>

This bundle exposes its own global, window.QKitConsoleUnread — deliberately not window.QKitConsole, so the two never collide if a page somehow loads both. It never pulls in Preact, the console's component tree, or a socket connection — it is the actual poll-only startUnreadWatcher implementation, just reachable from a plain <script> tag with no bundler. This is the path for "badge on every page."

WatchUnreadOptions — the argument shape for both QKitConsole.watchUnread() and QKitConsoleUnread.watchUnread():

| Option | Type | Required | Description | | ----------------- | ------------------------------------------------------ | :------: | ----------- | | getToken | () => Promise<string> | Yes | Same contract as mount()'s getToken. | | onChange | (count: number, isCountCapped: boolean) => void | Yes | See the badge-rendering note above. | | apiBaseUrl | string | No | Same default as mount(). | | pollIntervalMs | number | No | Defaults to 60000 (60 seconds). This is poll-only — there is no socket, so the badge lags reality by up to one interval. Don't set this low expecting near-real-time updates; if you need that, mount the full console on that page instead. |

Both return { stop(): void }. Call stop() when the badge is no longer on screen — it clears its polling timer and drops the cached token.


5. Auto-update

You embed the loader once; the actual console UI updates when QKIT publishes a new version. Here's the mechanism:

  1. src/loader.ts has a DEFAULT_CORE_URL constant — an exact, version-pinned unpkg URL, e.g. https://unpkg.com/[email protected]/dist/core.iife.js — baked into dist/loader.iife.js at build time.
  2. On mount() (or watchUnread()), the loader injects a <script> tag pointing at that URL (or at QKitConsole.coreUrl, if you overrode it — see Self-hosting) and resolves once the core attaches its global.
  3. Because the outer embed tag in your page is @^1 (see Embed once), your browser fetches whatever dist/loader.iife.js is current for the 1.x line on your next page load. If QKIT has published a new 1.x.y since your last visit, that new loader carries a DEFAULT_CORE_URL pointing at the matching new core build, and you pick both up together with no change to your embed.

There is no backend manifest in this flow any more, and consequently:

  • No integrity/SRI pin. The old design set a sha384-... hash from the backend as the injected script's integrity attribute. A version-pinned unpkg URL doesn't have a place to source that hash from dynamically, so this build does not set integrity on the core script tag. If you need it, self-host the core (see Self-hosting) and add your own SRI hash yourself.
  • No last-known-good fallback. The old loader cached the last manifest that loaded successfully in localStorage and fell back to it if a later manifest request failed. That existed only to survive the manifest endpoint being unreachable; a hardcoded CDN URL has no equivalent failure mode to fall back from, so this build has no localStorage fallback and no equivalent of the old qkit_console_lkg key.
  • No instant, centralized rollback. The old design let QKIT revert a bad core release by flipping a backend environment variable, which every embedded site picked up within about a minute with zero action from you. That is gone. How QKIT actually rolls back a bad core release now: publish a new patch version with the fix (or the revert) and a DEFAULT_CORE_URL pointing at it — the same as any other release (see CONTRIBUTING.md). Sites recover once their @^1 tag resolves to that new loader on a subsequent page load, which depends on unpkg's and your visitors' browsers' own caching of the pinned @^1 request — there is no fixed upper bound like the old 60-second manifest cache, and a customer who pinned an exact loader version instead of @^1 won't recover at all without re-embedding.

Contributing

Maintainer documentation — the release process (building, versioning, what npm publish actually needs to happen for a core-only change now) and package layout conventions — lives in CONTRIBUTING.md.


License

MIT © QKIT Software.