JavaScript Guide
Step-by-step: call the API from the browser only, or keep your key secret behind your own server — then build and style your own chat UI.
ℹ Which option do I need?
xpectrum package is the recommended way — plain-fetch versions follow for projects that can't use it.Option A — Client-side JavaScript only
⚠ The key is exposed
Step 1 — Get your app key
In the Xpectrum console, open your app → API Access → create an API key. It looks like xpectrum_XXXXXXXXXXXXXXXX.
ℹ Who is 'user'?
user decides whose conversation history a request belongs to. If your site has login, use your logged-in user's id. If not, generate a random id once per browser and reuse it — each visitor gets their own private history and can never see another visitor's conversations:// One id per browser, created on first visit and reused after that
let userId = localStorage.getItem("xp_user_id");
if (!userId) {
userId = "anon-" + crypto.randomUUID();
localStorage.setItem("xp_user_id", userId);
}Step 2 — Use the SDK (recommended)
The SDK streams by default and handles SSE parsing, conversation tracking, errors, and aborts for you:
npm install xpectrumimport { XpectrumChat } from "xpectrum";
const chat = new XpectrumChat({
baseUrl: "https://cloud.xpectrum.dev/v1",
apiKey: "xpectrum_XXXXXXXXXXXXXXXX", // still visible in the browser — prototypes only
user: userId, // from Step 1
});
let threadId: string | undefined;
async function send(question: string) {
const result = await chat.stream(question, {
threadId, // continues the same conversation
onToken: (delta, full) => {
// called for every token — render the reply as it arrives
document.getElementById("answer")!.textContent = full;
},
});
threadId = result.threadId; // save for the next message
}That's the whole integration — streaming replies with multi-turn memory. The rest of this option is only for pages that can't use npm packages.
Step 3 — No build tools? Plain fetch + SSE
The same thing with zero dependencies — save as index.html and open it in a browser. The reply streams in word by word:
<!DOCTYPE html>
<html>
<body>
<input id="q" placeholder="Ask something..." />
<button onclick="send()">Send</button>
<p id="answer"></p>
<script>
const API_KEY = "xpectrum_XXXXXXXXXXXXXXXX"; // visible to users! prototypes only
let userId = localStorage.getItem("xp_user_id");
if (!userId) {
userId = "anon-" + crypto.randomUUID();
localStorage.setItem("xp_user_id", userId);
}
let threadId = null; // keeps multi-turn memory
async function send() {
const question = document.getElementById("q").value;
const answerEl = document.getElementById("answer");
answerEl.textContent = "";
const res = await fetch("https://cloud.xpectrum.dev/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": "Bearer " + API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: [{ role: "user", content: question }],
user: userId,
thread_id: threadId,
stream: true,
}),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE events are separated by a blank line
const events = buffer.split("\n\n");
buffer = events.pop(); // keep the incomplete tail
for (const event of events) {
if (!event.startsWith("data: ")) continue; // skips ": ping" comments
const payload = event.slice(6);
if (payload === "[DONE]") return;
const chunk = JSON.parse(payload);
threadId = chunk.thread_id || threadId;
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) answerEl.textContent += delta;
}
}
}
</script>
</body>
</html>Option B — Client + server (key stays secret)
The browser calls your endpoint (/api/chat); your server adds the API key and forwards the request to Xpectrum. The key never leaves the server.
Browser ──► YOUR server (/api/chat, key lives here) ──► cloud.xpectrum.devStep 1 — Put the key in an environment variable
XPECTRUM_API_KEY=xpectrum_XXXXXXXXXXXXXXXXAdd .env to .gitignore so the key is never committed.
Step 2 — Proxy with the SDK (simplest)
The SDK runs in Node too. This route streams from Xpectrum internally and returns the finished reply as JSON, which keeps the client trivial:
import { XpectrumChat } from "xpectrum";
const chat = new XpectrumChat({
baseUrl: "https://cloud.xpectrum.dev/v1",
apiKey: process.env.XPECTRUM_API_KEY!, // secret, server-only
user: "user-123", // from your auth session / cookie — not from the request body
});
export async function POST(req: Request) {
const body = await req.json();
const result = await chat.send(body.messages, {
threadId: body.thread_id,
});
return Response.json({
content: result.content,
thread_id: result.threadId,
});
}The browser then calls it with a plain fetch — no key anywhere in client code:
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [{ role: "user", content: question }],
thread_id: threadId,
}),
});
const data = await res.json();
threadId = data.thread_id;
answerEl.textContent = data.content;Step 3 — Want token-by-token streaming in the browser? Pass the stream through
To show the reply word by word, the proxy must forward the SSE stream untouched — a raw-fetch pass-through does that in a few lines:
export async function POST(req: Request) {
const body = await req.json();
const upstream = await fetch("https://cloud.xpectrum.dev/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.XPECTRUM_API_KEY}`, // no NEXT_PUBLIC_ prefix!
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: body.messages,
thread_id: body.thread_id,
stream: true,
user: "user-123", // from your auth session / cookie — not from the request body
}),
});
// Forwards the SSE stream to the browser untouched
return new Response(upstream.body, {
status: upstream.status,
headers: { "Content-Type": upstream.headers.get("content-type") ?? "application/json" },
});
}Or the same proxy with Node.js + Express:
require("dotenv").config();
const express = require("express");
const app = express();
app.use(express.json());
app.use(express.static("public")); // serves your index.html
app.post("/api/chat", async (req, res) => {
const upstream = await fetch("https://cloud.xpectrum.dev/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.XPECTRUM_API_KEY, // secret, server-only
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: req.body.messages,
thread_id: req.body.thread_id,
stream: true,
// With login: your session's user id. Without login: an id from a
// session cookie you set. Never trust an id sent by the browser —
// that would let one visitor read another visitor's history.
user: "user-123",
}),
});
res.status(upstream.status);
res.set("Content-Type", upstream.headers.get("content-type"));
const reader = upstream.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
res.end();
});
app.listen(3000, () => console.log("http://localhost:3000"));The browser reads the streamed response exactly like Option A, Step 3 — same SSE parsing, just with your URL (/api/chat) and no Authorization header.
⚠ Never NEXT_PUBLIC_
NEXT_PUBLIC_ is bundled into browser code. Keep the key as XPECTRUM_API_KEY and read it only in the route handler.Style your chat UI — fonts, colors & branding
Easiest — the SDK's drop-in ChatWidget
If you don't want to build a UI at all, xpectrum ships a ready-made floating chat bubble. One call gives you the button, the chat window, streaming replies, and conversation memory — you just pick where it sits and what colors it uses:
import { ChatWidget } from "xpectrum";
const widget = new ChatWidget({
baseUrl: "https://cloud.xpectrum.dev/v1",
apiKey: "xpectrum_XXXXXXXXXXXXXXXX",
user: userId, // same rules as above: login id or per-browser id
position: "bottom-right", // or "bottom-left"
buttonColor: "#2563eb", // your brand color
buttonSize: 56, // px
theme: "auto", // "light" | "dark" | "auto"
windowWidth: 400, // px
windowHeight: 600, // px
welcomeMessage: "Hi! How can I help?",
});
// widget.open(); widget.close(); widget.destroy();| Option | Default | Controls |
|---|---|---|
| position | "bottom-right" | Which corner of the page the bubble sits in |
| buttonColor | "#7C3AED" | Trigger button (and accent) color |
| buttonSize | 48 | Trigger button diameter in px |
| theme | "light" | Light, dark, or follow the visitor's system |
| windowWidth / windowHeight | 400 / 600 | Chat window size in px |
| welcomeMessage | app's greeting | First message shown; falls back to the greeting configured on the app |
| container | document.body | Mount the widget inside a specific element instead of the page corner |
ℹ Key handling still applies
baseUrl at your Option B proxy and keep the real key on the server.Full control — build your own UI
Need fonts, layouts, or branding beyond the widget's options? The API returns plain data, so the look of your chat is 100% your CSS. The pattern below puts every visual choice in CSS variables — change a font or color in one place and the whole UI follows.
Step 1 — A minimal chat UI
<style>
:root {
/* ── change these to restyle everything ── */
--chat-font: "Inter", system-ui, sans-serif;
--chat-font-size: 15px;
--chat-bg: #ffffff;
--chat-text: #111827;
--user-bubble-bg: #2563eb; /* your brand color */
--user-bubble-text: #ffffff;
--bot-bubble-bg: #f3f4f6;
--bot-bubble-text: #111827;
--bubble-radius: 16px;
}
.chat {
font-family: var(--chat-font);
font-size: var(--chat-font-size);
background: var(--chat-bg);
color: var(--chat-text);
max-width: 420px;
height: 560px;
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
}
.messages { flex: 1; overflow-y: auto; padding: 16px; }
.bubble {
max-width: 80%;
padding: 10px 14px;
margin-bottom: 8px;
border-radius: var(--bubble-radius);
line-height: 1.5;
white-space: pre-wrap;
}
.bubble.user {
background: var(--user-bubble-bg);
color: var(--user-bubble-text);
margin-left: auto;
}
.bubble.bot {
background: var(--bot-bubble-bg);
color: var(--bot-bubble-text);
}
.composer { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #e5e7eb; }
.composer input { flex: 1; padding: 10px; border: 1px solid #e5e7eb; border-radius: 8px; font: inherit; }
.composer button { padding: 10px 16px; border: 0; border-radius: 8px; background: var(--user-bubble-bg); color: #fff; cursor: pointer; }
</style>
<div class="chat">
<div class="messages" id="messages"></div>
<div class="composer">
<input id="q" placeholder="Type a message..." />
<button onclick="send()">Send</button>
</div>
</div>
<script>
let threadId = null;
function addBubble(role, text) {
const el = document.createElement("div");
el.className = "bubble " + role;
el.textContent = text;
document.getElementById("messages").appendChild(el);
el.scrollIntoView();
return el;
}
async function send() {
const input = document.getElementById("q");
const question = input.value.trim();
if (!question) return;
input.value = "";
addBubble("user", question);
const botEl = addBubble("bot", "…");
const res = await fetch("/api/chat", { // Option B endpoint
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [{ role: "user", content: question }],
thread_id: threadId,
stream: true,
}),
});
botEl.textContent = "";
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop();
for (const event of events) {
if (!event.startsWith("data: ")) continue;
const payload = event.slice(6);
if (payload === "[DONE]") return;
const chunk = JSON.parse(payload);
threadId = chunk.thread_id || threadId;
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) { botEl.textContent += delta; botEl.scrollIntoView(); }
}
}
}
</script>Step 2 — Change the font
Load any font (e.g. from Google Fonts) and point --chat-font at it:
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
--chat-font: "Poppins", sans-serif;
--chat-font-size: 16px; /* bigger text */
}
</style>Step 3 — Change the colors
Every color is one variable:
| Variable | Controls |
|---|---|
| --chat-font / --chat-font-size | Font family and size for the whole widget |
| --chat-bg / --chat-text | Chat window background and default text color |
| --user-bubble-bg / --user-bubble-text | Your user's messages and the Send button |
| --bot-bubble-bg / --bot-bubble-text | The assistant's messages |
| --bubble-radius | How rounded the message bubbles are |
/* dark theme example */
:root {
--chat-bg: #0b1220;
--chat-text: #e5e7eb;
--user-bubble-bg: #7c3aed;
--user-bubble-text: #ffffff;
--bot-bubble-bg: #1f2937;
--bot-bubble-text: #e5e7eb;
--bubble-radius: 8px;
}Step 4 — Pull branding from the app itself
GET /models returns the agent's name, title, greeting and starter questions — use it so the UI updates when the agent's settings change, with no redeploy:
const { data } = await (await fetch("/api/models")).json(); // proxy GET /models the same way
const agent = data[0];
document.querySelector(".chat-title").textContent = agent.title || agent.name;
if (agent.greeting) addBubble("bot", agent.greeting);
for (const q of agent.starter_questions) addStarterChip(q);
// or with the SDK: const agent = await chat.getAgent();