Skip to the content
Menu: Bring your own brain

Bring your own brain: answer every turn from your own Claude or OpenAI model

A Zentiq avatar normally decides what to say itself. In external turn mode your application decides instead: Zentiq listens to the person, tells you what they said, and the avatar speaks exactly the reply your own model writes, in the persona's face and voice. Use it when your product already has its own model, prompts, tools or memory (a tutoring engine, a sales coach, a support agent) and you want Zentiq's live avatar on top. It works today with the REST API.

How a turn works#

Browser (your page)          Your server                          Zentiq
      |  POST /zentiq/session -->|  POST /v1/sessions/tokens          |
      |                          |  { personaId, autoRespond: false } |
      |<------- token -----------|<-----------------------------------|
      |  startSession(token) ------------------------------------->  | the avatar greets
      |                          |                                    |
      |<== learner.transcript.final { text, turnId } ================| the person finishes speaking
      |  POST /zentiq/turn ----->|                                    |
      |                          |  your model writes the reply       |
      |                          |  POST /v1/sessions/{id}/say        |
      |                          |  { text, turnId } ---------------->| the avatar speaks it
  1. Start the session on your server with autoRespond: false (or turnMode: { mode: "external" }, the same thing). The avatar then never answers on its own: with no reply from you, it waits quietly. It still says the persona's greeting first; send overrides: { greeting: "" } for none, or your own words.
  2. The browser joins with the token: the browser SDK @zentiq/client (startSession) shows the avatar and gives you the session's live connection. Show disclosure.text from the token in your page while disclosure.required is true: people must know they are talking to an AI.
  3. When the person finishes speaking, the session sends an event on its live events channel (topic events):

    JSON
    {
      "v": 1,
      "id": "evt_1790000000000_12",
      "seq": 12,
      "type": "learner.transcript.final",
      "ts": "2026-09-26T09:30:00.000Z",
      "data": { "text": "Can you explain compound interest?", "language": "en", "turnId": "turn_ab12_3" }
    }

    The same channel also carries learner.transcript.partial (words as they are heard), tutor.transcript.final (what the avatar said), and avatar.speaking.start and avatar.speaking.end with the turnId they belong to. Ignore any type you don't use.

  4. Your server asks your model and sends the reply with POST /v1/sessions/{id}/say:

    FieldMeaning
    textWhat the avatar says, up to 2,000 characters. Keep it short: it is spoken.
    turnIdThe turnId of the turn you are answering.
    eventIdOptional. Any unique string: a repeat of the same eventId is ignored, so a retry never speaks twice.
    interruptDefault true: stop anything the avatar is saying first.
    languageOptional. A language from GET /v1/languages, by its English name, for this line only.
    endSessionOptional. true ends the session once this line has been spoken.
  5. End the session with POST /v1/sessions/{id}/end when the person leaves (minutes stop at once), or send the last line with endSession: true.

Your API key stays on your server: the browser only ever holds the session token. Only sessions your API key started can be spoken into.

The browser#

JavaScript
// npm install @zentiq/client  (version 0.2.0; npm 7 or later also installs the package it needs alongside it)
import { startSession } from "@zentiq/client";

const token = await fetch("/zentiq/session", { method: "POST" }).then((r) => r.json());
if (token.disclosure?.required) document.querySelector("#disclosure").textContent = token.disclosure.text;

const session = await startSession({ token: token.sessionToken, url: token.url, video: document.querySelector("#avatar") });

// The live events channel: forward each finished turn to your server.
session.room.on("dataReceived", (payload, _participant, _kind, topic) => {
  if (topic !== "events") return;
  let event;
  try {
    event = JSON.parse(new TextDecoder().decode(payload));
  } catch {
    return;
  }
  if (event.type !== "learner.transcript.final") return;
  fetch("/zentiq/turn", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sessionId: token.sessionId, turnId: event.data.turnId, text: event.data.text }),
  });
});

window.addEventListener("pagehide", () => navigator.sendBeacon("/zentiq/end", JSON.stringify({ sessionId: token.sessionId })));

The server, with Claude#

Node 18 or later. Save as server.mjs, then run ZENTIQ_API_KEY=zq_live_… PERSONA_ID=pa_… ANTHROPIC_API_KEY=… node server.mjs.

JavaScript
// npm install @anthropic-ai/sdk
import http from "node:http";
import Anthropic from "@anthropic-ai/sdk";

const ZENTIQ = "https://app.zentiqai.com/v1";
const SYSTEM =
  "You are Ava, a friendly finance tutor on a live video call. Reply in one to three short spoken sentences, " +
  "with no lists or formatting. Latency-sensitive; begin your visible answer immediately.";
const anthropic = new Anthropic();
const sessions = new Map(); // sessionId -> the conversation so far

async function zentiq(path, body = {}) {
  const res = await fetch(`${ZENTIQ}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.ZENTIQ_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(json.error?.message ?? `Zentiq answered ${res.status}`);
  return json.data;
}

async function reply(history) {
  const response = await anthropic.beta.messages.create({
    model: "claude-opus-5",
    max_tokens: 2048,
    output_config: { effort: "low" }, // short spoken replies: answer fast
    betas: ["server-side-fallback-2026-07-01"],
    fallbacks: "default", // if the model declines, another answers in the same call
    system: SYSTEM,
    messages: history,
  });
  if (response.stop_reason === "refusal") return "Sorry, I can't help with that one. What else can I do for you?";
  return response.content
    .filter((block) => block.type === "text")
    .map((block) => block.text)
    .join("")
    .trim();
}

const readJson = (req) =>
  new Promise((resolve) => {
    let raw = "";
    req.on("data", (chunk) => (raw += chunk));
    req.on("end", () => {
      try {
        resolve(JSON.parse(raw || "{}"));
      } catch {
        resolve({});
      }
    });
  });

http
  .createServer(async (req, res) => {
    const send = (status, body) => {
      res.writeHead(status, { "Content-Type": "application/json" });
      res.end(JSON.stringify(body));
    };
    try {
      if (req.method === "POST" && req.url === "/zentiq/session") {
        const token = await zentiq("/sessions/tokens", { personaId: process.env.PERSONA_ID, autoRespond: false });
        sessions.set(token.sessionId, []);
        return send(200, token);
      }
      if (req.method === "POST" && req.url === "/zentiq/turn") {
        const { sessionId, turnId, text } = await readJson(req);
        const history = sessions.get(sessionId);
        if (!history || typeof text !== "string" || !text.trim()) return send(404, { error: "Unknown session." });
        history.push({ role: "user", content: text });
        const answer = (await reply(history)) || "Sorry, could you say that again?";
        history.push({ role: "assistant", content: answer });
        await zentiq(`/sessions/${encodeURIComponent(sessionId)}/say`, { text: answer.slice(0, 2000), turnId, eventId: `${turnId}:reply` });
        return send(200, { ok: true });
      }
      if (req.method === "POST" && req.url === "/zentiq/end") {
        const { sessionId } = await readJson(req);
        if (sessions.delete(sessionId)) await zentiq(`/sessions/${encodeURIComponent(sessionId)}/end`);
        return send(200, { ok: true });
      }
      send(404, { error: "Not found." });
    } catch (e) {
      send(500, { error: e instanceof Error ? e.message : "Something went wrong." });
    }
  })
  .listen(3000, () => console.log("http://localhost:3000"));

fallbacks: "default" (with its beta flag) lets the API answer from another model when the first declines; remove both if you don't want that. Serve the browser page from the same server (or allow it to call these three routes).

The same server, with an OpenAI model#

Only reply changes. Run with OPENAI_API_KEY instead of ANTHROPIC_API_KEY:

JavaScript
// npm install openai
import OpenAI from "openai";

const openai = new OpenAI();

async function reply(history) {
  const response = await openai.responses.create({
    model: process.env.OPENAI_MODEL ?? "gpt-6-astra",
    instructions: SYSTEM,
    input: history, // [{ role: "user" | "assistant", content: "…" }]
  });
  return response.output_text.trim();
}

Good to know#

  • Speed matters. People expect an answer within a second or two. Keep replies short, use a fast setting of your model, and send the reply as soon as you have it. While you work, the avatar listens quietly.
  • One reply per turn. Answer each turnId once. If the person speaks again before you answer, answer the newest turn; interrupt: true (the default) cuts off anything still being spoken.
  • Say something more at any time. POST /v1/sessions/{id}/say without a turnId speaks now (a reminder, "take your time"), bound to the latest turn.
  • Change instructions mid-session with PATCH /v1/sessions/{id} (systemPrompt, language, variables) if Zentiq also shapes other parts of the session.
  • Transcripts of API sessions are available afterwards from GET /v1/sessions/{id}/transcript, and by webhook (transcript.final) if you register one with includeConversation: true.
  • Keep sessions your own. Only accept turns for sessions your server started (the sessions map above), and limit how often one browser may send turns.
  • The live events channel is the session's real-time connection: session.room in @zentiq/client. Messages on the chat topic are the captions (what onTranscript reports); events is the one above.