Nuance LabsDocs

JavaScript 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-sdk

Until 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.create on avatar-presented to have it open, or wait for the user to speak.
  • disconnect() sends session.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:

EventWhat it means
state-changedstate is connecting, queued (waiting out a busy service before it tries again), connected, disconnected or failed.
session-createdThe conversation exists; conversation.id names it.
avatar-preparingThe persona is getting ready, which can take up to about half a minute.
tracks-changedThe persona's video or audio track arrived or changed: attach them.
avatar-readyThe persona's first frame reached the page.
avatar-presentedThe persona is in view and can speak: send response.create here to have it open.
speaking-changedThe user or the persona started or stopped speaking.
time-remainingHow long the conversation has left, when it has a time limit.
network-changedThe connection's quality is good, poor or lost.
session-endedThe 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 until avatar-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 see session.started and the session's first events.
  • conversation.interface is "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]);
}
  • onToolCall hands you each call as the backend makes it: name, arguments parsed from the backend's JSON (null when it isn't an object), and the callId its result names. Check the arguments against the tool's parameters before 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) or input_text and input_image parts. 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:

ErrorWhen
ApiErrorThe API refused, with its status, code and retryAfterSeconds: a used or expired token (401), all credits spent, today's and any we added (429 credits_spent). See Errors.
ActiveSessionErrorThe API answered 409: your API keys already run as many conversations at once as your plan allows (concurrency_limit). End one first: list your active conversations and end one from your server.
MediaAccessErrorThe microphone or camera couldn't be opened: device says which, reason why (permission-denied, device-not-found, device-in-use).
PolicyCurfewErrorNew conversations are paused after repeated policy violations; retryAfterSeconds says for how long, when known.
LegalRequiredErrorThe 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.
GuestLimitErrorOnly 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.
MaintenanceErrorThe 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.

On this page