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- Start the session on your server with
autoRespond: false(orturnMode: { 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; sendoverrides: { greeting: "" }for none, or your own words. - The browser joins with the token: the browser SDK
@zentiq/client(startSession) shows the avatar and gives you the session's live connection. Showdisclosure.textfrom the token in your page whiledisclosure.requiredis true: people must know they are talking to an AI. 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), andavatar.speaking.startandavatar.speaking.endwith theturnIdthey belong to. Ignore any type you don't use.Your server asks your model and sends the reply with
POST /v1/sessions/{id}/say:Field Meaning textWhat the avatar says, up to 2,000 characters. Keep it short: it is spoken. turnIdThe turnIdof the turn you are answering.eventIdOptional. Any unique string: a repeat of the same eventIdis 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. trueends the session once this line has been spoken.- End the session with
POST /v1/sessions/{id}/endwhen the person leaves (minutes stop at once), or send the last line withendSession: 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#
// 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.
// 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:
// 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
turnIdonce. 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}/saywithout aturnIdspeaks 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 withincludeConversation: true. - Keep sessions your own. Only accept turns for sessions your server started (the
sessionsmap above), and limit how often one browser may send turns. - The live events channel is the session's real-time connection:
session.roomin@zentiq/client. Messages on thechattopic are the captions (whatonTranscriptreports);eventsis the one above.