qkit-chatbot-console
v1.1.1
Published
QKIT Nexus embeddable admin console runtime
Maintainers
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() │
└─────────────────────────────┘ └───────────────────┘- Your backend holds a Console API Key and exchanges it, per agent login, for a short-lived console token.
- Your frontend embeds the console loader once and gives it a
getToken()function that calls your backend for that token. - 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 fetchescore.iife.jsfrom 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-consoleimport { 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.coreUrlto your own URL before the firstmount()orwatchUnread()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:
src/loader.tshas aDEFAULT_CORE_URLconstant — an exact, version-pinned unpkg URL, e.g.https://unpkg.com/[email protected]/dist/core.iife.js— baked intodist/loader.iife.jsat build time.- On
mount()(orwatchUnread()), the loader injects a<script>tag pointing at that URL (or atQKitConsole.coreUrl, if you overrode it — see Self-hosting) and resolves once the core attaches its global. - Because the outer embed tag in your page is
@^1(see Embed once), your browser fetches whateverdist/loader.iife.jsis current for the1.xline on your next page load. If QKIT has published a new1.x.ysince your last visit, that new loader carries aDEFAULT_CORE_URLpointing 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'sintegrityattribute. A version-pinned unpkg URL doesn't have a place to source that hash from dynamically, so this build does not setintegrityon 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
localStorageand 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 nolocalStoragefallback and no equivalent of the oldqkit_console_lkgkey. - 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_URLpointing at it — the same as any other release (see CONTRIBUTING.md). Sites recover once their@^1tag resolves to that new loader on a subsequent page load, which depends on unpkg's and your visitors' browsers' own caching of the pinned@^1request — there is no fixed upper bound like the old 60-second manifest cache, and a customer who pinned an exact loader version instead of@^1won'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.
