Interactive Avatar: LiveKit Plugin reference

Complete reference for the Synthesia plugin for LiveKit Agents, available for Python (livekit-plugins-synthesia) and Node.js (@livekit/agents-plugin-synthesia).

Use this page when you're already building and need exact signatures, defaults, and failure behavior. For the mental model behind these objects, see Concepts; for runnable code, see the Quickstarts.

In Python, all symbols live in livekit.plugins.synthesia. In Node.js, import the plugin as import * as synthesia from '@livekit/agents-plugin-synthesia'. Examples that follow use Python naming unless a Node.js block is shown. In Node.js, parameters and methods use camelCase (avatarIds, swapAvatar()), and every duration is in milliseconds, not seconds.

Requirements

Python

  • Python 3.10 or later. Tested on 3.10, 3.11 and 3.12.
  • livekit-agents 1.8.2 or later, the plugin's only dependency, installed automatically.

Node.js

  • @livekit/agents at the same version as the plugin, and @livekit/rtc-node 1.1.0 or later. Both are peer dependencies, so install them in your agent project if you haven't already.
  • livekit-server-sdk 2.13.3 or later, installed automatically with the plugin.

Install the plugin:

pip install "livekit-plugins-synthesia~=1.8"
npm install @livekit/agents-plugin-synthesia

If you're following LiveKit's docs, livekit-agents[synthesia]~=1.8 installs the same package.

Environment variables

Read when the corresponding argument isn't passed explicitly.

VariableUsed for
SYNTHESIA_API_KEYYour Synthesia workspace API key
SYNTHESIA_API_URLAPI base URL. Defaults to https://developers.synthesia.io
LIVEKIT_URLYour LiveKit project URL
LIVEKIT_API_KEYLiveKit API key
LIVEKIT_API_SECRETLiveKit API secret

All five are secrets and belong in your agent's environment or a secret manager. SYNTHESIA_API_KEY is workspace-bound and must never reach frontend code.

LIVEKIT_URL accepts https:// as well as wss://. The scheme is normalized before it reaches the avatar worker, so the https:// value LiveKit Cloud injects into deployed agents works unchanged.

Minimal usage

from livekit.plugins import synthesia

avatar = synthesia.AvatarSession(
    synthesia.AvatarConfig(avatar_ids=["<avatar-id>"]),
)
await avatar.start(session, room=ctx.room)   # before session.start()
import { voice } from '@livekit/agents';
import * as synthesia from '@livekit/agents-plugin-synthesia';

const session = new voice.AgentSession(/* ... */);

const avatar = new synthesia.AvatarSession(
  new synthesia.AvatarConfig({
    avatarIds: ['<avatar-id>'],
  }),
);
await avatar.start(session, ctx.room); // before session.start()

The plugin replaces session.output.audio, so the avatar lip-syncs whatever speech your agent produces. You can swap STT, LLM or TTS providers freely and these two lines never change.

synthesia.AvatarSession

AvatarSession(
    avatar_config,
    *,
    api_key=None,
    api_url=None,
    join_timeout=30.0,
    avatar_participant_identity=None,
    avatar_participant_name=None,
)
new synthesia.AvatarSession(
  avatarConfig,
  {
    apiKey,
    apiUrl,
    joinTimeout,
    avatarParticipantIdentity,
    avatarParticipantName,
  }, // options are optional
)

The table lists Python names and units. In Node.js, the second argument is an optional options object with the camelCase equivalents: apiKey, apiUrl, joinTimeout, avatarParticipantIdentity and avatarParticipantName. joinTimeout is in milliseconds and defaults to 30000.

ParameterTypeDefaultDescription
avatar_configAvatarConfigrequiredThe avatars to render. See below.
api_keystr | NoneNoneSynthesia workspace API key. Falls back to SYNTHESIA_API_KEY.
api_urlstr | NoneNoneAPI base URL. Falls back to SYNTHESIA_API_URL, then https://developers.synthesia.io.
join_timeoutfloat30.0Seconds to wait for the avatar to join and publish before raising SynthesiaError with type=ErrorType.TIMEOUT. Raise it if the worker cold-starts slowly.
avatar_participant_identitystr | None"synthesia-avatar-agent"The LiveKit identity the avatar joins under. Must be unique per concurrent avatar in a room, because LiveKit evicts an existing participant when a second joins with the same identity.
avatar_participant_namestr | None"Synthesia avatar"The LiveKit display name the avatar joins under.

Passing an empty or whitespace-only string for either participant field raises SynthesiaError. Omit them to take the defaults.

Properties

PropertyDescription
avatar_identityThe LiveKit identity the avatar joins under.
providerAlways "synthesia".

In Node.js the properties are avatarIdentity and provider, plus sessionId, which holds the Synthesia session ID once the session has started and is null before that. Quote it when contacting support.

synthesia.AvatarConfig

AvatarConfig(avatar_ids)
new synthesia.AvatarConfig({ avatarIds })
FieldTypeDescription
avatar_idsSequence[str]One to five ids of avatars available to your workspace, each prefixed av_…. The first is the active avatar; the rest are precomputed so swap_avatar() can switch to them mid-session.

avatar_ids is validated when you construct the config, not when the session starts:

  • A bare string instead of a list raises ValueError in Python and TypeError in Node.js.
  • Fewer than one or more than five ids raises ValueError in Python and an Error in Node.js.

An id your workspace can't access raises SynthesiaError with type=ErrorType.UNKNOWN_AVATAR later, at start().

Pass every avatar you might swap to up front. Adding one afterwards requires a new session.

Methods

await avatar.start(agent_session, room, *, livekit_url=None, livekit_api_key=None, livekit_api_secret=None)

await avatar.start(agent_session, room, livekit_url=None, livekit_api_key=None, livekit_api_secret=None)
await avatar.start(agentSession, room, {
  livekitUrl,
  livekitApiKey,
  livekitApiSecret,
}); // options are optional

Mounts the avatar into room, launches the worker, and wires session.output.audio. Returns once the avatar has joined and published its video track.

Call this before AgentSession.start(). LiveKit credentials fall back to LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET.

The room must already be connected, because the plugin mints the avatar's token on behalf of your agent's identity and needs one to exist. Connect the room, then attach the avatar.

Calling start() on an already-started session returns immediately and does nothing. Calling it while another start() is still running raises SynthesiaError.

await avatar.swap_avatar(avatar_id, *, timeout=15.0)

In Node.js the signature is await avatar.swapAvatar(avatarId, { timeout }), where timeout is in milliseconds and defaults to 15000.

Switches the rendered avatar mid-session to another id from avatar_ids. Pass "default" to return to the first id. Returns the now-active avatar id once the swap has taken effect.

await avatar.swap_avatar("<another-id-from-avatar-ids>")
await avatar.swap_avatar("default")
await avatar.swapAvatar('<another-id-from-avatarIds>');
await avatar.swapAvatar('default');

Raises SynthesiaError if the session hasn't started or is shutting down, type=ErrorType.UNKNOWN_AVATAR if the target wasn't in avatar_ids, and type=ErrorType.CONNECTION if the swap request to the worker fails. A swap the worker rejects outright raises SynthesiaError with no type set.


await avatar.aclose()

Cooperative shutdown; restores normal audio routing. Called automatically when the room disconnects, or when the avatar drops unexpectedly.

Events

📌

Events are available in the Python plugin only. The Node.js plugin (1.9.3) doesn't expose session_ended or error events. It shuts itself down in both cases, logging the cause, so there's nothing to call. To detect a lost avatar in Node.js, watch your own Room for the avatar participant leaving.

EventFires when
session_endedThe room disconnected. A clean end.
errorThe avatar stopped publishing video while the room was still connected, meaning the worker failed.
avatar.on("session_ended", lambda: ...)
avatar.on("error", lambda exc: ...)

session_ended means you or your user ended the session; error means the avatar went away on its own. Either one triggers shutdown automatically, so you don't need to call aclose() in the handler.

Exceptions

Every failure raises SynthesiaError, which subclasses LiveKit's livekit.agents.APIError, so you can catch Synthesia failures alongside every other plugin's. There is no exception hierarchy below it. To tell failures apart, read the type attribute.

AttributeDescription
typeAn ErrorType member identifying the failure, or None when the failure happened before a request was made.
retryableWhether retrying the same call could plausibly succeed.
retry_afterSeconds to wait, when the backend supplied a value. Populated for rate-limit and concurrency-limit failures only. In Node.js this is retryAfter, in milliseconds.
statusThe HTTP status the API answered, or None if no answer arrived.
request_idThe API's requestId, when the response carried one. Quote it when contacting support. In Node.js this is requestId.
bodyThe raw response body, when there was one.

ErrorType

MemberMeaning
AUTHThe API key is invalid, expired, or lacks the scope this endpoint requires.
FEATURE_NOT_IN_PLANYour workspace's plan doesn't include interactive avatars.
UNKNOWN_AVATARAn avatar isn't accessible to your workspace. Also raised by swap_avatar() for a target that wasn't in avatar_ids.
QUOTA_EXCEEDEDYour workspace's session quota is exhausted.
INVALID_ROOM_TOKENThe token minted for the avatar to join your room was rejected.
LIVEKIT_CREDENTIALS_REJECTEDThe LiveKit URL, API key, or API secret you supplied was rejected.
INVALID_SESSION_REQUESTThe session request was malformed or failed validation.
RATE_LIMITEDThrottled. Carries retry_after when the backend supplied one.
CONCURRENCY_LIMITEvery concurrent-session slot for your plan is in use.
TIMEOUTThe avatar didn't join within join_timeout.
CONNECTIONNo usable response, or the avatar dropped mid-session.

Some failures carry no type at all, including a missing API key and a swap the worker rejects. Handle type is None rather than assuming every SynthesiaError is classified.

try:
    await avatar.start(session, room=ctx.room)
except synthesia.SynthesiaError as e:
    if e.type in (
        synthesia.ErrorType.QUOTA_EXCEEDED,
        synthesia.ErrorType.FEATURE_NOT_IN_PLAN,
    ):
        ...   # fall back to an audio-only session
    raise
try {
  await avatar.start(session, ctx.room);
} catch (e) {
  if (
    e instanceof synthesia.SynthesiaError &&
    (e.type === synthesia.ErrorType.QUOTA_EXCEEDED ||
      e.type === synthesia.ErrorType.FEATURE_NOT_IN_PLAN)
  ) {
    // fall back to an audio-only session
  }
  throw e;
}

Retry guidance

Branch on retryable, not on type; the plugin sets it per failure for exactly this decision. Honor retry_after when it's set. For a TIMEOUT failure, raise join_timeout before retrying, since cold starts are the usual cause.

Starting a session does not retry internally. A transient server error surfaces immediately as SynthesiaError with type=ErrorType.CONNECTION, so retrying is yours to do.

Never wrap auth, plan, quota, unknown-avatar, or validation failures in a retry loop.

For the HTTP-level error codes behind these error types, see Errors.

Troubleshooting

📘

Prefer automated help?

The Claude Code / Cursor skill can scan your codebase and suggest fixes directly.

SymptomLikely causeFix
SynthesiaError: a Synthesia API key is requiredNo SYNTHESIA_API_KEY and no api_key=.Set the env var or pass api_key=.
SynthesiaError: LiveKit url, API key, and API secret are requiredOne of the three is missing or blank at start().Set the env vars or pass them to start(). A blank secret is caught here deliberately: it still mints a token Synthesia accepts, and LiveKit would only reject it much later.
ValueError: avatar_ids must be a list of ids, not a single string (Node.js: TypeError: avatarIds must be a list of ids, not a single string)AvatarConfig(avatar_ids="<id>").Wrap it in a list: avatar_ids=["<id>"], or avatarIds: ['<id>'] in Node.js.
ValueError: avatar_ids must contain between 1 and 5 ids (Node.js: Error: avatarIds must contain between 1 and 5 ids, got N)An empty list, or more than five.Pass one to five ids.
SynthesiaError: livekit_url … is not a ws:// or wss:// URLA malformed LIVEKIT_URL.Use your project's wss:// URL. https:// is normalized automatically.
SynthesiaError: the room's local participant has no identitystart() ran before the room finished connecting.Connect the room first, then attach the avatar.
SynthesiaError: start() is already in progressTwo concurrent start() calls.Await the first one.
type=ErrorType.AUTHKey invalid or expired, or missing the scope this endpoint requires.Check the key and its scopes.
type=ErrorType.FEATURE_NOT_IN_PLANYour plan doesn't include interactive avatars.Retrying won't help. Contact Synthesia.
type=ErrorType.UNKNOWN_AVATAR at start()An avatar id isn't accessible to your workspace.Use an id your workspace has access to.
type=ErrorType.UNKNOWN_AVATAR on swap_avatar()The target id wasn't passed to AvatarConfig.Include every swappable id, up to five, in avatar_ids up front.
SynthesiaError: swap_avatar() requires a started avatar sessionCalled before start(), or during shutdown.Only swap while the session is live.
type=ErrorType.CONCURRENCY_LIMITAll concurrent session slots are in use.End an active session, or wait for one to end.
type=ErrorType.TIMEOUTThe avatar didn't join in time. Usually a cold start or network problem.Raise join_timeout (joinTimeout, in milliseconds, in Node.js) and retry.
Avatar never appears, no errorTerminal console run mode.Run with dev or connect against a real room, and attach the avatar first. See Testing your integration.
Avatar joins but doesn't lip-syncThe agent isn't producing audio, or session.output.audio was reassigned after start().Confirm the agent speaks without the avatar attached; never reassign output.audio after attaching.
Avatar video looks low-res or blurrySubscriber-side adaptive streaming downscaled the track.Create the room with adaptiveStream: false, or render the avatar large and call setVideoQuality(VideoQuality.HIGH). Resolution is set by the hosted worker; there's no plugin-side control.
Works locally, fails when deployedSecrets missing from the deploy environment.Confirm all five environment variables exist in the runtime.
type=ErrorType.TIMEOUT on most sessions, not just cold startsTypical join time has been running ~45 seconds against a 30-second default join_timeout.Raise join_timeout (joinTimeout, in milliseconds, in Node.js) well above the default until this improves.
Session drops and the agent doesn't recoverThe plugin has no built-in reconnect.In Python, handle the error event yourself and decide whether to retry. In Node.js there is no event, so watch your Room for the avatar participant leaving.
A room refuses a new session, or the avatar won't (re)join a room it was just inA known issue can leave a closed session active in LiveKit, blocking a new one in the same room.Check the LiveKit dashboard; close the stale room manually, then retry.

Choosing an avatar

Only synthetic and personal avatars work. Stock actor-based avatars (for example Ryan or Ada) can't be used as interactive avatars, whatever your plan.

For an eligible avatar your plan gives you access to:

  1. Copy its ID from the Avatars page: open the ••• menu and select Copy ID.
  2. Convert it with POST /api/interactive-avatars/avatars. Poll until status is completed, then pass the returned id in avatar_ids.

A repeat request for the same source returns the existing interactive avatar rather than starting a new conversion. Converting a stock avatar, or one outside your plan, returns an error rather than an interactive avatar.

Additional resources