Connect
Create the conversation, follow it, and join its LiveKit room from a web page or a native app.
The AI persona talks over LiveKit. Your page or app calls the Nuance API to create the conversation, then joins the conversation's room with LiveKit's SDK for its platform: livekit-client in a browser, or Swift, Android, Flutter, React Native, Unity or Rust in a native app. Every step is an HTTP call or a LiveKit call that each of them has. In a web page, the JavaScript SDK does every step on this page for you.
The persona runs in a LiveKit room on Nuance's servers. Your app joins it like any other LiveKit room, even if your own calls run on your own LiveKit server.
How it runs
- Your server mints a session token with your API key and gives it to the app: see Set up a session.
- The app creates the conversation with the token, and follows its events until one gives it the room.
- The app joins the room, publishing the microphone.
- The session starts, and both sides exchange Nuance Live events. The app keeps following the conversation's events.
- Either side ends it, and the app leaves the room.
The app makes no timed calls: one request is always open, and the API answers it when something happens.
Never ship your API key in a page or an app. Anyone can read it out.
1. Create the conversation
POST /conversation, with the session token as a bearer token, creates the conversation and spends the token. Every later call for this conversation uses the same token. The token must start it within 60 seconds, and starts only one.
{ "timezone": "Europe/London", "max_session_seconds": 300 }Send the person's IANA timezone: the persona reads the date and time in that zone, else in UTC. max_session_seconds is optional: the conversation runs for the service's maximum unless you ask for less. The response's id names the conversation from here on.
The conversation never waits in a queue: it starts within a few seconds, or the API turns it away with a 429 (below).
Errors to handle:
| Status | code in the body | Meaning |
|---|---|---|
| 409 | concurrency_limit | Your API keys already run as many conversations at once as your plan allows (active of limit). End one first. |
| 429 | credits_spent | Your API keys have used today's credits and any credits we added to your account (what a credit counts). Today's renew at 00:00 UTC; renews_ says when. |
| 429 | capacity_unavailable | No capacity can take the conversation right now. Nothing was started or spent: try again after retry_, with the same session token while it is under 60 seconds old, else with a new one. |
| 503 | busy | Try again after retry_ (3 if absent). |
Errors lists every answer.
2. Follow the conversation
GET /conversation/{id}/events?after=0 returns the conversation's events, oldest first:
{ "events": [{ "type": "preparing", "id": 1792529097555620 }] }When nothing has happened yet, the API holds the request open for up to 20 seconds, then answers {"events": []}. Ask again straight away, with after set to the id of the last event you saw (or the same after when there were none). Keep one request open from creation to the end: it is what keeps the conversation alive, and one that stops asking gives it up.
| Event | What to do |
|---|---|
preparing | The persona is getting ready, which can take up to about half a minute. Sent once, and not always. |
admitted | Join the room, with livekit_ and token (below). |
started | The persona has started. If seconds_ is set, the conversation has a time limit, measured from this answer. |
ended | The conversation is over, for reason. Nothing follows. |
Ignore event types you don't know, but still pass their id as after.
A request that fails or times out loses nothing: send it again with the same after, after a few seconds. Before admitted, give up after six failures in a row. If the person cancels before then, stop asking and call POST /conversation/{id}/abandon to give its place under your limit back at once. It has no body, but must still send Content-Length: 0: the API refuses a POST without it (411), and some HTTP clients, such as Rust's reqwest, leave it out.
3. Join the room
Join the admitted event's livekit_url with its token using your LiveKit SDK, then publish right away:
- The microphone (source
Microphone). The persona needs it to hear the person. - The camera (source
Camera), if you want the persona to see them. It's optional.
Capture the microphone with echo cancellation on. Without it the persona hears itself through the speaker and answers its own voice. Browsers and LiveKit's SDKs do this for you when they capture the microphone: in Rust, use PlatformAudio (on iOS it uses the voice-processing audio unit); the Swift and Android SDKs turn it on by default. If you capture audio yourself, use your platform's voice-processing input, such as Android's VOICE_COMMUNICATION source.
The persona is the only other participant in the room. LiveKit subscribes to its audio and video tracks automatically: attach them to your page's <video> and <audio> elements, or draw its video in your app's video view. In a browser, start the conversation from a click or a tap: browsers only play audio after the person interacts with the page.
In a browser, with livekit-client, steps 1 to 3 and the nuance_live topic look like this:
// In the browser: join a Nuance Live conversation's room with LiveKit's own
// SDK, and send and receive its events on the `nuance_live` topic.
import { Room, RoomEvent } from "livekit-client";
const NUANCE_API = "https://api.nuancelabs.ai";
const TOPIC = "nuance_live";
/** The most one data packet may carry; a larger event goes as a text stream. */
const MAX_PACKET_BYTES = 64_000;
export type LiveEvent = { type: string } & Record<string, unknown>;
export interface LiveSession {
room: Room;
/** Sends one event; reliable, in order. */
send(event: LiveEvent): Promise<void>;
}
/** POSTs or GETs one API route with the session token, and reads its JSON. */
async function api<T>(token: string, path: string, body?: unknown): Promise<T> {
const response = await fetch(`${NUANCE_API}${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(
`${path} answered ${response.status}: ${await response.text()}`,
);
}
return (await response.json()) as T;
}
interface RouteEvent {
type: string;
id: number;
livekit_url?: string;
token?: string;
}
/**
* Follows the conversation's events route from `after`, calling `onEvent` for
* each; returns once `ended` comes. One request is always open, which keeps
* the conversation alive.
*/
async function follow(
token: string,
id: string,
onEvent: (event: RouteEvent) => void,
): Promise<void> {
let after = 0;
for (;;) {
const { events } = await api<{ events: RouteEvent[] }>(
token,
`/conversation/${id}/events?after=${after}`,
);
for (const event of events) {
after = event.id;
onEvent(event);
if (event.type === "ended") return;
}
}
}
/**
* Creates the conversation with `token`, joins its room with the microphone,
* and hands each Nuance Live event to `onEvent`, a `message.part` series
* joined into the event it carries. Call it from a click, so the browser
* plays the persona's audio.
*/
export async function joinNuanceLive(
token: string,
onEvent: (event: LiveEvent) => void,
): Promise<LiveSession> {
const { id } = await api<{ id: string }>(token, "/conversation", {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
const room = new Room();
const decoder = new TextDecoder();
let parts: string[] = [];
room.on(RoomEvent.DataReceived, (payload, _participant, _kind, topic) => {
if (topic !== TOPIC) return;
const event = JSON.parse(decoder.decode(payload)) as LiveEvent;
if (event.type !== "message.part") {
parts = [];
onEvent(event);
return;
}
// A part out of order drops the event being joined.
if (event.index !== parts.length) {
parts = [];
if (event.index !== 0) return;
}
parts.push(String(event.text));
if (event.index === Number(event.count) - 1) {
const whole = JSON.parse(parts.join("")) as LiveEvent;
parts = [];
onEvent(whole);
}
});
// Play the persona's audio as soon as its track arrives.
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === "audio") document.body.append(track.attach());
});
const admitted = new Promise<RouteEvent>((resolve, reject) => {
follow(token, id, (event) => {
if (event.type === "admitted") resolve(event);
}).then(
() => reject(new Error("The conversation ended before it began")),
reject,
);
});
const { livekit_url, token: roomToken } = await admitted;
await room.connect(String(livekit_url), String(roomToken));
await room.localParticipant.setMicrophoneEnabled(true);
const encoder = new TextEncoder();
return {
room,
async send(event) {
const text = JSON.stringify(event);
const bytes = encoder.encode(text);
if (bytes.length <= MAX_PACKET_BYTES) {
await room.localParticipant.publishData(bytes, {
reliable: true,
topic: TOPIC,
});
} else {
await room.localParticipant.sendText(text, { topic: TOPIC });
}
},
};
}
session.started arrives on the nuance_live topic once the persona can hear the microphone. From then on, follow Events you send and Events you get; the nuance_live topic says how events travel. Ignore other topics.
4. End the conversation
When the person hangs up, send {"type": "session.close"} on the nuance_live topic, wait a few seconds for session.closed (or the events route's ended, if it comes first), then leave the room and stop following the events. If it ended on the persona's side, through session.closed or an ended event, just leave the room.
The API is in preview: routes and events may change before 1.0. We'll tell you before a change that breaks your app.