Java Script SDK
Run a Nuance Live conversation in a web page with @nuancelabs/js-sdk, which handles the API calls and the room for you.
@nuancelabs/js-sdk runs a conversation in the browser for you: it creates the conversation, follows its events, joins the room with the microphone, and carries Nuance Live events both ways. Your page attaches the AI persona's video and audio, and sends and handles events.
npm install @nuancelabs/js-sdkUntil 1.0, a new minor version may change the API, and a patch only fixes. npm install saves a caret range (^0.x.y), which takes the patches and never a breaking change.
The SDK is for web pages. In a native app, or to manage the room yourself, follow Connect with LiveKit's SDK for your platform: the API and the events are the same.
Get a session token
The SDK never sees your API key. Give it a token function that asks your server for a session token, minted with POST /session-tokens. It calls the function each time it starts a conversation, since a token starts only one.
The token fixes the session: its prompt, the backend and its tools, and the persona's own tools. See Set up a session. Nuance Live is the default interface, so the token doesn't need to name it.
Start a conversation
// In the browser: start a conversation with the JS SDK, show the persona, and
// send and receive Nuance Live events.
import { createNuanceConversation } from "@nuancelabs/js-sdk";
const video = document.querySelector<HTMLVideoElement>("#avatar-video");
const audio = document.querySelector<HTMLAudioElement>("#avatar-audio");
const transcript = document.querySelector<HTMLElement>("#transcript");
const conversation = createNuanceConversation({
// Your endpoint, which mints a session token with your API key.
token: async () => {
const response = await fetch("/api/nuance-token", { method: "POST" });
if (!response.ok) throw new Error(await response.text());
return ((await response.json()) as { token: string }).token;
},
});
conversation.onChange((event) => {
if (event.type === "tracks-changed" && video && audio) {
conversation.avatar.videoTrack?.attach(video);
conversation.avatar.audioTrack?.attach(audio);
}
// The persona is in view: ask it to open the conversation.
if (event.type === "avatar-presented") {
void conversation.sendInterfaceEvent({
type: "response.create",
response: { instructions: "Greet the user and ask how you can help." },
});
}
// The SDK has left the room; `reason` says why it ended.
if (event.type === "session-ended" && transcript) {
transcript.textContent += `\n(Conversation over: ${event.reason})`;
}
});
// Subscribe before connect() to see the session's first events.
conversation.onInterfaceEvent((event) => {
if (event.type === "session.output_transcript.delta" && transcript) {
transcript.textContent += String(event.delta);
}
});
// Start from a click, so the browser plays the persona's audio.
document.querySelector("#start")?.addEventListener("click", () => {
void conversation.connect();
});
document.querySelector("#hang-up")?.addEventListener("click", () => {
void conversation.disconnect();
});
connect()creates the conversation, waits until the persona is ready, joins the room and turns on the microphone and the camera. Call it from a click or a tap: browsers play audio only after the person interacts with the page.- The persona doesn't speak first. Send
response.createonavatar-presentedto have it open, or wait for the user to speak. disconnect()sendssession.close, leaves the room and stops the microphone and camera.
The camera is on by default, so the browser asks the person for it. Pass mediaDevices: { cameraEnabled: false } to start without it; conversation.user.enableVideo() turns it on later. To keep the persona from using the camera at all, set interface_setup.video.input.camera to false when you mint the token.
Follow the conversation
conversation.onChange reports the conversation's lifecycle:
| Event | What it means |
|---|---|
state-changed | state is connecting, queued (waiting out a busy service before it tries again), connected, disconnected or failed. |
session-created | The conversation exists; conversation. names it. |
avatar-preparing | The persona is getting ready, which can take up to about half a minute. |
tracks-changed | The persona's video or audio track arrived or changed: attach them. |
avatar-ready | The persona's first frame reached the page. |
avatar-presented | The persona is in view and can speak: send response. here to have it open. |
speaking-changed | The user or the persona started or stopped speaking. |
time-remaining | How long the conversation has left, when it has a time limit. |
network-changed | The connection's quality is good, poor or lost. |
session-ended | The conversation is over, for reason. The SDK then leaves the room and stops the microphone and camera. |
When the persona appears
startOn sets when avatar-presented fires:
"first-frame"(the default): as soon as the persona's first frame reaches the page. The video can be soft for its first few seconds while the connection ramps up."full-quality": once the video has reached its full size and held it, or after about 12 seconds. Cover the persona untilavatar-presented. The session waits for it too, so a call the person leaves before the persona is presented is not recorded.
Green screen
Pass greenScreen: true to createNuanceConversation and the persona arrives over solid green (#00FF00): key the green out of its video (on a canvas, or with a shader) and draw it over your page's own background. It is offered by some deployments only; elsewhere the conversation is refused and connect() throws an ApiError with code green_screen_not_offered. Some personas can't be drawn over green, so a conversation that asks for the green screen on one of them is refused too: choose another persona, or leave it off. greenScreen needs 0.37.0 or later.
Nuance Live events
conversation.onInterfaceEvent hands your page each event the session sends on the nuance_live topic, parsed, with any message.part series already joined. conversation.sendInterfaceEvent sends one, as a text stream when it is too large for a packet. The events are the same as on any connection: see Events you send and Events you get.
- Subscribe before
connect()to seesession.startedand the session's first events. conversation.interfaceis"nuance_live".- The SDK answers the room's clock sync for you.
Tools and appends
With a responses delegation, the backend calls your tools and your page runs them. The SDK reads the calls from the delegation's events and sends your results back:
// In the page: run the backend's calls of your tools, change which tools it
// may call, and tell the persona what happens in the app.
import type {
Conversation,
NuanceLiveTool,
ToolCall,
ToolOutput,
} from "@nuancelabs/js-sdk";
const GET_WEATHER: NuanceLiveTool = {
type: "function",
name: "get_weather",
description: "Looks up the current weather in a city.",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
};
const BOOK_TABLE: NuanceLiveTool = {
type: "function",
name: "book_table",
description: "Books a table at the user's saved restaurant.",
parameters: {
type: "object",
properties: { time: { type: "string" } },
required: ["time"],
},
annotations: { destructiveHint: true },
};
/** Your app's own lookup and booking. */
declare function lookUpWeather(
city: string,
): Promise<{ sky: string; temperature_c: number }>;
declare function bookTable(time: string): Promise<{ booked_for: string }>;
/** One call's result, or an error the persona can tell the user. */
async function run(call: ToolCall): Promise<ToolOutput> {
const args = call.arguments ?? {};
try {
if (call.name === "get_weather" && typeof args.city === "string") {
return await lookUpWeather(args.city);
}
if (call.name === "book_table" && typeof args.time === "string") {
return await bookTable(args.time);
}
} catch {
return { error: { message: `${call.name} didn't answer` } };
}
return { error: { message: `Can't run ${call.name} with these arguments` } };
}
export function runTools(conversation: Conversation): void {
// Answer every call, with an error when it can't run: the backend goes on
// only once each call of its step has a result.
conversation.onToolCall(async (call) => {
await conversation.sendToolResult(call.callId, await run(call));
});
}
/** The user signed out: booking can't run until they're back. */
export async function onSignedOut(conversation: Conversation): Promise<void> {
await conversation.setTools([GET_WEATHER]);
await conversation.append("thinking", "The user just signed out.");
}
/** They signed in again: send every tool that can run, exactly as minted. */
export async function onSignedIn(conversation: Conversation): Promise<void> {
await conversation.setTools([GET_WEATHER, BOOK_TABLE]);
}
onToolCallhands you each call as the backend makes it:name,argumentsparsed from the backend's JSON (nullwhen it isn't an object), and thecallIdits result names. Check the arguments against the tool'sparametersbefore you use them, and answer every call, with an error when it can't run: the backend goes on only once each call of its step has a result.sendToolResult(callId, output)answers one call with a string, an object (sent as JSON text) orinput_textandinput_imageparts. A step's calls are answered together: the SDK sends the step's results once the step has finished and every call in it has a result, then asks the backend to go on, once. A failed call answers with an error, such as{ error: { message: "Card declined" } }. See The tool round trip for what the backend does with each.setTools(tools)replaces the tools the backend may call, from its next step on. Pass every tool that can run now, each as you listed it in the token. See Tools that aren't available right now.append(kind, content)adds a line without asking the persona to speak:"thinking"for quiet context,"instructions"for something it acts on in a turn of its own,"commentary"for something to tell the user. Pass{ whileSpeaking: "revise" }to cut a reply it has started and carry on with the line. See Appends.
Handle errors
connect() rejects when the conversation can't start. Test the error with instanceof, and act on its fields, never on its message:
| Error | When |
|---|---|
ApiError | The API refused, with its status, code and retry: a used or expired token (401), all credits spent, today's and any we added (429 credits_). See Errors. |
ActiveSessionError | The API answered 409: your API keys already run as many conversations at once as your plan allows (concurrency_). End one first: list your active conversations and end one from your server. |
MediaAccessError | The microphone or camera couldn't be opened: device says which, reason why (permission-denied, device-not-found, device-in-use). |
PolicyCurfewError | New conversations are paused after repeated policy violations; retry says for how long, when known. |
LegalRequiredError | The account that made your API key hasn't accepted the API Developer Agreement and the Acceptable Use Policy on the platform, or it failed Nuance's age check. |
GuestLimitError | Only for the Nuance app's guests: a guest limit is used up, and reason says which (used, device or network). A conversation your session token starts is not a guest's. |
MaintenanceError | The service is under maintenance; its message is safe to show. |
The SDK retries a busy service and short capacity on its own before it gives up.
React
@nuancelabs/js-sdk/react runs one conversation in a component:
// In a React app: one conversation in a component, with the SDK's hook.
import { useNuanceConversation } from "@nuancelabs/js-sdk/react";
async function fetchToken(): Promise<string> {
const response = await fetch("/api/nuance-token", { method: "POST" });
if (!response.ok) throw new Error(await response.text());
return ((await response.json()) as { token: string }).token;
}
export function AvatarCall() {
const { state, error, videoRef, audioRef, start, end } =
useNuanceConversation({ token: fetchToken });
const live = state === "connecting" || state === "connected";
return (
<div>
<video ref={videoRef} autoPlay playsInline />
<audio ref={audioRef} autoPlay />
{live ? (
<button type="button" onClick={end}>
Hang up
</button>
) : (
<button type="button" onClick={start}>
Start
</button>
)}
{error && <p>Couldn't start the conversation.</p>}
</div>
);
}
start() starts a conversation, ending any current one; end() ends it. conversation is the latest conversation, for anything the hook doesn't cover, such as onInterfaceEvent. The hook leaves the room when the session ends and when the component unmounts.