Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/tavus-face-pal-rename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents-plugin-tavus': minor
---

Align the Tavus plugin with the new face/pal API: `faceId`/`palId` options and `TAVUS_FACE_ID`/`TAVUS_PAL_ID` env vars, sending `face_id`/`pal_id` on the wire and auto-creating pals via `/v2/pals` (`createPal`). The old `replicaId`/`personaId` options, `TAVUS_REPLICA_ID`/`TAVUS_PERSONA_ID` env vars, and `createPersona()` keep working as deprecated aliases.
115 changes: 115 additions & 0 deletions plugins/tavus/src/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TavusAPI } from './api.js';

const warnMock = vi.hoisted(() => vi.fn());
vi.mock('./log.js', () => ({ log: () => ({ warn: warnMock, error: vi.fn() }) }));

function mockFetchOk(body: Record<string, unknown>) {
const f = vi.fn().mockResolvedValue({ ok: true, json: async () => body } as Response);
global.fetch = f as unknown as typeof fetch;
return f;
}

function sentBody(f: ReturnType<typeof mockFetchOk>): Record<string, unknown> {
return JSON.parse(String((f.mock.calls[0]![1] as RequestInit).body));
}

describe('Tavus TavusAPI.createConversation', () => {
beforeEach(() => {
warnMock.mockClear();
for (const v of ['TAVUS_FACE_ID', 'TAVUS_PAL_ID', 'TAVUS_REPLICA_ID', 'TAVUS_PERSONA_ID']) {
delete process.env[v];
}
});

afterEach(() => {
vi.restoreAllMocks();
});

it('maps faceId/palId onto the unchanged wire keys without warning', async () => {
const f = mockFetchOk({ conversation_id: 'c1' });
const id = await new TavusAPI({ apiKey: 'k' }).createConversation({
faceId: 'f1',
palId: 'p1',
});
expect(id).toBe('c1');
expect(f).toHaveBeenCalledTimes(1);
const body = sentBody(f);
expect(body.face_id).toBe('f1');
expect(body.pal_id).toBe('p1');
expect(warnMock).not.toHaveBeenCalled();
});

it('still accepts deprecated replicaId/personaId and warns', async () => {
const f = mockFetchOk({ conversation_id: 'c2' });
await new TavusAPI({ apiKey: 'k' }).createConversation({ replicaId: 'r1', personaId: 'x1' });
const body = sentBody(f);
expect(body.face_id).toBe('r1');
expect(body.pal_id).toBe('x1');
const msgs = warnMock.mock.calls.map((c) => String(c[0]));
expect(msgs.some((m) => m.includes('replicaId') && m.includes('faceId'))).toBe(true);
expect(msgs.some((m) => m.includes('personaId') && m.includes('palId'))).toBe(true);
});

it('falls back to TAVUS_FACE_ID / TAVUS_PAL_ID env vars', async () => {
process.env.TAVUS_FACE_ID = 'envf';
process.env.TAVUS_PAL_ID = 'envp';
const f = mockFetchOk({ conversation_id: 'c3' });
await new TavusAPI({ apiKey: 'k' }).createConversation();
const body = sentBody(f);
expect(body.face_id).toBe('envf');
expect(body.pal_id).toBe('envp');
expect(warnMock).not.toHaveBeenCalled();
});

it('auto-creates a pal via /v2/pals (with default_face_id) when no pal is given', async () => {
const f = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ pal_id: 'pal_new' }) } as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ conversation_id: 'c5' }),
} as Response);
global.fetch = f as unknown as typeof fetch;

await new TavusAPI({ apiKey: 'k' }).createConversation({ faceId: 'f1' });

const palUrl = String(f.mock.calls[0]![0]);
const palBody = JSON.parse(String((f.mock.calls[0]![1] as RequestInit).body));
const convBody = JSON.parse(String((f.mock.calls[1]![1] as RequestInit).body));
expect(palUrl).toContain('/pals');
expect(palBody.default_face_id).toBe('f1');
expect(convBody.face_id).toBe('f1');
expect(convBody.pal_id).toBe('pal_new');
});

it('with only palId, skips pal creation and omits face_id (pal carries its own face)', async () => {
const f = mockFetchOk({ conversation_id: 'c4' });
await new TavusAPI({ apiKey: 'k' }).createConversation({ palId: 'p1' });
expect(f).toHaveBeenCalledTimes(1);
expect(String(f.mock.calls[0]![0])).toContain('/conversations');
const body = sentBody(f);
expect(body.pal_id).toBe('p1');
expect(body).not.toHaveProperty('face_id');
});

it('falls back to the default face id when neither face nor pal is provided', async () => {
const f = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ pal_id: 'pal_new' }) } as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ conversation_id: 'c5' }),
} as Response);
global.fetch = f as unknown as typeof fetch;

await new TavusAPI({ apiKey: 'k' }).createConversation();

expect(String(f.mock.calls[0]![0])).toContain('/pals');
const palBody = JSON.parse(String((f.mock.calls[0]![1] as RequestInit).body));
expect(palBody.default_face_id).toBe('r72f7f7f7c8b');
});
});
91 changes: 79 additions & 12 deletions plugins/tavus/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { log } from './log.js';
/** @public */
export const DEFAULT_API_URL = 'https://tavusapi.com/v2';

// Stock face used when the caller provides neither a face nor a pal.
const DEFAULT_FACE_ID = 'r72f7f7f7c8b';

/**
* Exception thrown when the Tavus plugin or Tavus service errors.
*
Expand All @@ -28,16 +31,52 @@ export class TavusException extends Error {

/** @public */
export interface CreateConversationOptions {
/** Tavus replica id. Falls back to `TAVUS_REPLICA_ID`. */
/** Tavus face id. Falls back to `TAVUS_FACE_ID`. */
faceId?: string;
/** Tavus pal id. Falls back to `TAVUS_PAL_ID`; created automatically when omitted. */
palId?: string;
/** @deprecated Use {@link CreateConversationOptions.faceId | faceId} instead. */
replicaId?: string;
/** Tavus persona id. Falls back to `TAVUS_PERSONA_ID`; created automatically when omitted. */
/** @deprecated Use {@link CreateConversationOptions.palId | palId} instead. */
personaId?: string;
/** Conversation properties passed through to Tavus. */
properties?: Record<string, unknown>;
/** Additional fields to merge into the Tavus conversation creation payload. */
extraPayload?: Record<string, unknown>;
}

function resolveRenamedOption(
newValue: string | undefined,
deprecatedValue: string | undefined,
deprecatedName: string,
newName: string,
): string | undefined {
// Prefer the new option; fall back to the deprecated alias and warn when it's used.
if (deprecatedValue) {
log().warn(`\`${deprecatedName}\` is deprecated, use \`${newName}\` instead`);
}
Comment thread
tinalenguyen marked this conversation as resolved.
Outdated
return newValue || deprecatedValue;
}

function deprecatedEnv(deprecatedName: string, newName: string): string | undefined {
// Read a deprecated env var, warning if it's set so callers migrate to `newName`.
const value = process.env[deprecatedName];
if (value) {
log().warn(`\`${deprecatedName}\` is deprecated, use \`${newName}\` instead`);
}
return value;
}

/** @public */
export interface CreatePalOptions {
/** Tavus pal name. Generated automatically when omitted. */
name?: string;
/** Default face id for the pal (required by `/v2/pals`). */
defaultFaceId: string;
/** Additional fields to merge into the Tavus pal creation payload. */
extraPayload?: Record<string, unknown>;
}

/** @public */
export interface CreatePersonaOptions {
/** Tavus persona name. Generated automatically when omitted. */
Expand Down Expand Up @@ -80,21 +119,29 @@ export class TavusAPI {
}

async createConversation(options: CreateConversationOptions = {}): Promise<string> {
const replicaId = options.replicaId || process.env.TAVUS_REPLICA_ID;
if (!replicaId) {
throw new TavusException('TAVUS_REPLICA_ID must be set');
}

let personaId = options.personaId || process.env.TAVUS_PERSONA_ID;
if (!personaId) {
personaId = await this.createPersona();
const faceId =
resolveRenamedOption(options.faceId, options.replicaId, 'replicaId', 'faceId') ||
process.env.TAVUS_FACE_ID ||
deprecatedEnv('TAVUS_REPLICA_ID', 'TAVUS_FACE_ID');

let palId =
resolveRenamedOption(options.palId, options.personaId, 'personaId', 'palId') ||
process.env.TAVUS_PAL_ID ||
deprecatedEnv('TAVUS_PERSONA_ID', 'TAVUS_PAL_ID');
Comment on lines +127 to +130

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Deprecated personaId values are now sent as pal_id on the wire

When a caller passes the deprecated personaId option, resolveRenamedOption at plugins/tavus/src/api.ts:128 maps it to palId, which is sent as pal_id on the wire (plugins/tavus/src/api.ts:138). The old code sent this same value as persona_id. If the Tavus API treats persona IDs and pal IDs as distinct namespaces, existing persona IDs passed via the deprecated option would fail on the new endpoint. This is likely correct if Tavus's API migration accepts old persona IDs as pal IDs, but it's worth confirming with the Tavus API documentation.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@carolin-tavus is this a valid comment?


if (!palId) {
// no pal to reuse, so create one — falling back to the default face
palId = await this.createPal({ defaultFaceId: faceId || DEFAULT_FACE_ID });
Comment thread
tinalenguyen marked this conversation as resolved.
Comment on lines +132 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 New pal is created on every conversation when palId is not cached

When no palId is provided (neither via options nor env), createConversation calls createPal which makes an extra HTTP request to /v2/pals on every invocation. If createConversation is called multiple times for the same face (e.g., in a loop or for multiple sessions), a new pal is created each time. There's no caching of the created pal ID. This may be intentional (each conversation gets its own pal) or it may cause unnecessary API calls and pal proliferation. Worth confirming with the Tavus API's expected usage pattern.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@carolin-tavus is this a valid comment?

}

const payload: Record<string, unknown> = {
replica_id: replicaId,
persona_id: personaId,
pal_id: palId,
properties: options.properties ?? {},
};
// send face_id only when given; otherwise the pal's default_face_id is used
if (faceId) {
payload.face_id = faceId;
}

if (options.extraPayload) {
Object.assign(payload, options.extraPayload);
Expand All @@ -108,7 +155,27 @@ export class TavusAPI {
return responseData.conversation_id;
}

async createPal(options: CreatePalOptions): Promise<string> {
const payload: Record<string, unknown> = {
pal_name: options.name || shortuuid('lk_pal_'),
default_face_id: options.defaultFaceId,
pipeline_mode: 'echo',
layers: {
transport: { transport_type: 'livekit' },
},
};

if (options.extraPayload) {
Object.assign(payload, options.extraPayload);
}

const responseData = (await this.post('pals', payload)) as { pal_id: string };
return responseData.pal_id;
}

/** @deprecated Use {@link TavusAPI.createPal | createPal} instead. */
async createPersona(options: CreatePersonaOptions = {}): Promise<string> {
log().warn('`createPersona` is deprecated, use `createPal` instead');
const payload: Record<string, unknown> = {
persona_name: options.name || shortuuid('lk_persona_'),
pipeline_mode: 'echo',
Expand Down
37 changes: 37 additions & 0 deletions plugins/tavus/src/avatar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { voice } from '@livekit/agents';
import type { Room } from '@livekit/rtc-node';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AvatarSession } from './avatar.js';

describe('Tavus AvatarSession', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('calls base AvatarSession.start first', async () => {
const sentinel = new Error('super-start-called');
const superStartSpy = vi
.spyOn(voice.AvatarSession.prototype, 'start')
.mockRejectedValue(sentinel);

const avatar = new AvatarSession({ apiKey: 'k', faceId: 'f1' });

await expect(
avatar.start(
{ _started: false, output: { audio: null } } as unknown as voice.AgentSession,
{} as unknown as Room,
),
).rejects.toThrow('super-start-called');
expect(superStartSpy).toHaveBeenCalledTimes(1);
});

it('accepts both new (faceId/palId) and deprecated (replicaId/personaId) options', () => {
expect(() => new AvatarSession({ apiKey: 'k', faceId: 'f1', palId: 'p1' })).not.toThrow();
expect(
() => new AvatarSession({ apiKey: 'k', replicaId: 'r1', personaId: 'x1' }),
).not.toThrow();
});
});
17 changes: 14 additions & 3 deletions plugins/tavus/src/avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ const AVATAR_AGENT_NAME = 'tavus-avatar-agent';
* @public
*/
export interface AvatarSessionOptions {
/** Tavus replica id. Falls back to `TAVUS_REPLICA_ID`. */
/** Tavus face id. Falls back to `TAVUS_FACE_ID`. */
faceId?: string;
/** Tavus pal id. Falls back to `TAVUS_PAL_ID`; created automatically when omitted. */
palId?: string;
/** @deprecated Use {@link AvatarSessionOptions.faceId | faceId} instead. */
replicaId?: string;
/** Tavus persona id. Falls back to `TAVUS_PERSONA_ID`; created automatically when omitted. */
/** @deprecated Use {@link AvatarSessionOptions.palId | palId} instead. */
personaId?: string;
/** Override the Tavus API base URL. */
apiUrl?: string;
Expand Down Expand Up @@ -61,6 +65,9 @@ export interface StartOptions {
* @public
*/
export class AvatarSession extends voice.AvatarSession {
private faceId?: string;
private palId?: string;
// Deprecated aliases for faceId/palId; resolved in TavusAPI.createConversation.
private replicaId?: string;
private personaId?: string;
private avatarParticipantIdentity: string;
Expand All @@ -73,6 +80,8 @@ export class AvatarSession extends voice.AvatarSession {

constructor(options: AvatarSessionOptions = {}) {
super();
this.faceId = options.faceId;
this.palId = options.palId;
this.replicaId = options.replicaId;
this.personaId = options.personaId;
this.avatarParticipantIdentity = options.avatarParticipantIdentity || AVATAR_AGENT_IDENTITY;
Expand Down Expand Up @@ -144,8 +153,10 @@ export class AvatarSession extends voice.AvatarSession {

this.#logger.debug('starting avatar session');
this.conversationId = await this.api.createConversation({
personaId: this.personaId,
faceId: this.faceId,
palId: this.palId,
replicaId: this.replicaId,
personaId: this.personaId,
properties: { livekit_ws_url: livekitUrl, livekit_room_token: livekitToken },
});

Expand Down
2 changes: 2 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
"PERPLEXITY_API_KEY",
"TELNYX_API_KEY",
"TAVUS_API_KEY",
"TAVUS_FACE_ID",
"TAVUS_PAL_ID",
"TAVUS_PERSONA_ID",
"TAVUS_REPLICA_ID",
"TOGETHER_API_KEY",
Expand Down
Loading