@pixygon/chatbot-server
v0.3.1
Published
RAG chatbot + analytics for Node + Mongoose + Express hosts.
Readme
@pixygon/chatbot-server
Drop-in RAG chatbot + knowledge base + analytics for Node 22 + Express 5 + Mongoose 8. Multi-tenant by construction. Public anonymous surface included.
+--------------------+ +-------------------------+
| Host Express app |----->| chatbot.routes.private |
| (your auth here) | | chatbot.routes.public |
+--------------------+ +-------------------------+
| |
v v
Host's mongoose chatbot.{rag, analytics}
(models register here) services
|
v
KnowledgeDocument · KnowledgeChunk · ChatConversationWhat it does
- Knowledge base. Operators paste docs (text/url/file). The service chunks
to ~2 kB paragraphs, embeds via OpenAI
text-embedding-3-small(1536-dim), stores chunks per tenant. - Chat (RAG). User asks a question → cosine-sim top-K=5 chunks → renders a system prompt → forwards to the Pixygon AI gateway (configurable model) → returns text + cited sources.
- Analytics. 8 endpoints: overview KPIs, top questions, keyword frequency, cost timeseries, knowledge gaps, document usage, conversation drill-down, semantic clusters. All tenant-scoped.
- Public surface. Anonymous
/public/chat/:tenantSlugfor embedding on marketing/help-center sites. IP rate-limited. - Cost cap. Per-tenant monthly USD ceiling. New messages refused with a
503
CHAT_BUDGET_EXCEEDEDonce exceeded.
Install
npm install @pixygon/chatbot-serverPeer expectations (host already has these):
express≥ 5mongoose≥ 8- Node ≥ 22
Env vars consumed by createChatbot:
| Var | Required | What it's for |
|---|---|---|
| PIXYGON_API_KEY | yes | All AI calls — chat and embeddings — route through the Pixygon AI gateway (POST /v1/ai/api). PixygonServer handles upstream provider selection, billing, and the 20 % platform markup. |
| PIXYGON_API_URL | no | Override gateway base; default https://api.pixygon.com/v1 |
| PIXYGON_CHAT_INPUT_USD_PER_1K | no | Local cost estimate used by the chatbot's cost-cap pre-flight + per-conversation analytics. Not the same as the platform's billing rates. |
| PIXYGON_CHAT_OUTPUT_USD_PER_1K | no | Same — for chat output |
| OPENAI_EMBED_USD_PER_1K | no | Same — for embeddings |
There is no direct-provider bypass — every call flows through Pixygon so per-key margin tracking stays accurate. A single PIXYGON_API_KEY is all you need.
Usage
import mongoose from "mongoose";
import { createChatbot } from "@pixygon/chatbot-server";
import { Tenant } from "./models/Tenant.js";
import { withTenantScope } from "./middleware/requestContext.js";
import { tenantScopedPlugin } from "./models/_plugins/tenantScoped.js";
import { auditLogPlugin } from "./models/_plugins/auditLog.js";
export const chatbot = createChatbot({
mongoose,
tenantParamName: "tenantId", // path param: /tenants/:tenantId/...
tenantField: "tenantId", // the field name on documents
tenantRefName: "Tenant", // mongoose ref name for population
ai: {
pixygonApiKey: process.env.PIXYGON_API_KEY!,
},
// Optional host plugins applied to every chatbot model.
// Use this for tenant-scoped query enforcement, audit log, etc.
plugins: [
(schema, label) =>
schema.plugin(tenantScopedPlugin, { tenantField: "tenantId", label }),
(schema, label) =>
schema.plugin(auditLogPlugin, { entityType: label }),
],
hooks: {
getTenantName: async (id) =>
(await Tenant.findById(id).select("name").lean())?.name ?? null,
getTenantBySlug: async (slug) => {
const t = await Tenant.findOne({ slug, status: "active" })
.select("_id name slug").lean();
return t ? { _id: t._id, name: t.name, slug: t.slug } : null;
},
getCostCap: async (id) =>
(await Tenant.findById(id).select("chatCostCapUsdMonthly").lean())
?.chatCostCapUsdMonthly ?? null,
withTenantScope: (tenantId, fn) => withTenantScope(tenantId, fn),
systemPromptBuilder: (tenantName, contextBlocks) => {
const sources = contextBlocks.length === 0
? "(no relevant sources matched)"
: contextBlocks.map((c, i) => `[Source ${i + 1}]\n${c}`).join("\n\n");
return `You are the ${tenantName} assistant. Use ONLY the sources below as factual basis. If unsure, say so.
=== Sources ===
${sources}
=== End ===`;
},
},
});
// Mount under whatever shape the host uses.
app.use("/v1/tenants/:tenantId", verifyToken, tenantAccess, chatbot.routes.private);
app.use("/v1/public/chat", chatbot.routes.public);A host that uses companyId instead of tenantId swaps both tenantParamName
and tenantField to "companyId". The package adapts.
API surface
Private routes (mounted under /v1/<tenants>/:<id>):
GET /knowledge
POST /knowledge { title, sourceType, sourceText? | url? }
GET /knowledge/:documentId
PUT /knowledge/:documentId { title?, sourceText? }
DELETE /knowledge/:documentId
POST /chat { sessionId, message }
GET /chat/:sessionId
GET /conversations?limit=50
POST /chat/rate { sessionId, turnIndex, rating } // 1 | -1
GET /chat-analytics/overview
GET /chat-analytics/top-questions?limit=20
GET /chat-analytics/keywords?limit=30
GET /chat-analytics/cost-timeseries?days=30
GET /chat-analytics/knowledge-gaps?limit=15
GET /chat-analytics/document-usage
GET /chat-analytics/conversations?normalized=<q>&limit=50
GET /chat-analytics/semantic-clusters?limit=15Public routes (mounted under /v1/public/chat):
POST /:tenantSlug { sessionId, message }
GET /:tenantSlug/:sessionId
POST /:tenantSlug/rate { sessionId, turnIndex, rating }Default IP rate limit: 20 req/min/IP. Override via
createPublicRouter(chatbot, { rateLimitConfig: { windowMs, max } }) if you
need to wire a custom limiter — or use the exported rateLimit helper.
Hooks reference
| Hook | Required | Purpose |
|---|---|---|
| getTenantName(id) | yes | System prompt — "You are the X assistant" |
| getTenantBySlug(slug) | yes (for public surface) | Resolves slug to tenant for anonymous chat |
| getCostCap(id) | yes | Returns monthly USD cap; null = no cap |
| withTenantScope(id, fn) | yes | AsyncLocalStorage wrapper for tenant context |
| systemPromptBuilder(name, blocks) | yes | Builds the LLM system prompt with citations |
plugins is an array of (schema, label) => void — applied to every chatbot
model schema. Use this to attach your host's tenant-scope enforcement, audit
log, soft-delete, or whatever else every model needs.
Direct service calls
The router is convenient but you can call the services directly if needed:
const { text, citations, usage } = await chatbot.rag.respond({
tenantId, sessionId, message: "How do I export SAF-T?",
});
await chatbot.rag.processDocument(documentId); // background embedding
const spend = await chatbot.rag.currentMonthCost(tenantId);
const kpis = await chatbot.analytics.overview(tenantId);
const gaps = await chatbot.analytics.knowledgeGaps(tenantId, 10);
const clusters = await chatbot.analytics.semanticClusters(tenantId, 15);Cost-cap enforcement
When hooks.getCostCap(tenantId) returns a number > 0, rag.respond()
pre-flights the current month's spend. Over the cap → throws
{ status: 503, code: "CHAT_BUDGET_EXCEEDED" }. The host's error handler
should map application errors with a numeric .status to the response.
// somewhere in your error middleware
app.use((err, req, res, next) => {
if (err.status) return res.status(err.status).json({ error: err.code || err.message });
next(err);
});Exports
import {
createChatbot, // main factory
chunkText, // 2 kB paragraph chunker
cosineSimilarity, // dot-product over unit vectors
rateLimit, // express middleware factory
type ChatbotConfig,
type ChatbotHooks,
type Chatbot,
type ChatMessage,
type Citation,
type RespondArgs,
type RespondResult,
type AnalyticsService,
} from "@pixygon/chatbot-server";Troubleshooting
Errors thrown by the gateway include a code and (for HTTP failures) a
status field, surfaced to the host's error handler:
| code | When | Fix |
|---|---|---|
| PIXYGON_AI_UNCONFIGURED | PIXYGON_API_KEY not set | Provision a key on admin.pixygon.io and put it in the host's env |
| PIXYGON_AI_CHAT_FAILED | Gateway returned non-2xx for chat | Check key balance + model availability |
| PIXYGON_EMBED_FAILED | Gateway returned non-2xx for embeddings | Same |
| PIXYGON_EMBED_EMPTY | Gateway returned 2xx but no vector | Unlikely — file an issue if it happens |
| CHAT_BUDGET_EXCEEDED (status 503) | Tenant.chatCostCapUsdMonthly reached for the calendar month | Raise the cap or wait until next month |
When the host echoes err.code in its error response, consumers can
diagnose without scraping logs.
Migration
v0.2.x → v0.3.0 (breaking)
Direct OpenAI routing was removed. All AI calls now flow through the
Pixygon AI gateway unconditionally, so per-API-key margin tracking on
admin.pixygon.io stays accurate.
What to change:
- Drop
openaiApiKeyandopenaiApiUrlfrom yourcreateChatbot({ ai })config — they no longer exist on theAiConfigtype. - Delete
OPENAI_API_KEY/OPENAI_API_URLfrom your host's env (Coolify,.env, secrets store, etc.). - If you used
OPENAI_EMBED_USD_PER_1Kfor the cost-cap estimate, rename toPIXYGON_EMBED_USD_PER_1K— same shape, just a clearer name now that embeddings go through Pixygon.
Before:
ai: {
pixygonApiKey: process.env.PIXYGON_API_KEY!,
openaiApiKey: process.env.OPENAI_API_KEY!, // remove
}After:
ai: { pixygonApiKey: process.env.PIXYGON_API_KEY! }That's the full migration — the API surface (chatbot.rag, chatbot.analytics,
chatbot.routes.*, model exports) is unchanged.
Companion package
@pixygon/chatbot-react ships matching MUI + RTK Query pages
(KnowledgePage / ChatPage / ChatAnalyticsPage / EmbedChatPage /
ChatbotSettings / ChatLauncher). See its README for the React/Vite wire-up.
License
MIT.
