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?

Option A (client-side only) is the fastest way to try the API — everything runs in the browser, but the API key is visible to anyone who opens DevTools. Option B (client + server) is what you ship to production: the browser talks to your server, and only your server knows the key. In both options the 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

Anything in browser code is public. Before shipping a key to the browser, restrict it in the app's publish settings — switch Conversation history over API off (and Voice calls off where unused) so an exposed key can chat but cannot read transcripts. If the key needs history access, keep it on your server and use Option B.

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:
javascript
// 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:

bash
npm install xpectrum
typescript
import { 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:

index.html
<!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.

text
Browser  ──►  YOUR server (/api/chat, key lives here)  ──►  cloud.xpectrum.dev

Step 1 — Put the key in an environment variable

.env
XPECTRUM_API_KEY=xpectrum_XXXXXXXXXXXXXXXX

Add .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:

app/api/chat/route.ts
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:

javascript
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:

app/api/chat/route.ts
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:

server.js
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_

In Next.js, any env var prefixed 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:

typescript
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();
OptionDefaultControls
position"bottom-right"Which corner of the page the bubble sits in
buttonColor"#7C3AED"Trigger button (and accent) color
buttonSize48Trigger button diameter in px
theme"light"Light, dark, or follow the visitor's system
windowWidth / windowHeight400 / 600Chat window size in px
welcomeMessageapp's greetingFirst message shown; falls back to the greeting configured on the app
containerdocument.bodyMount the widget inside a specific element instead of the page corner

Key handling still applies

The widget runs in the browser, so putting the app key in its config exposes it. Two safe ways to ship it: restrict the key in the app's publish settings (history off — the widget never needs it), or point 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

chat.html
<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:

html
<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:

VariableControls
--chat-font / --chat-font-sizeFont family and size for the whole widget
--chat-bg / --chat-textChat window background and default text color
--user-bubble-bg / --user-bubble-textYour user's messages and the Send button
--bot-bubble-bg / --bot-bubble-textThe assistant's messages
--bubble-radiusHow rounded the message bubbles are
css
/* 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:

javascript
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();