@trilok-zs/realtime-avatar
v0.3.0
Published
Framework-independent <realtime-avatar> Web Component for LiveAvatar/LiveKit realtime avatars. Bundles React + livekit-client; ships no urls, ids or credentials.
Maintainers
Readme
@trilok-zs/realtime-avatar
Framework-independent Web Component wrapper around the Virtual Rep Trainer Realtime (LiveAvatar) avatar.
LiveAvatarCustom (existing React UI/logic)
↓
React Web Component adapter (RealtimeAvatarElement)
↓
Custom Element <realtime-avatar>
↓
React / Vue / Angular / plain HTML consumersReact, react-dom and livekit-client are bundled into dist/. Consumers
install nothing else and never mount React themselves.
npm install @trilok-zs/realtime-avatarimport "@trilok-zs/realtime-avatar";<realtime-avatar base-url="..." conversation-id="..."></realtime-avatar>What was ported, and from where
| Original (virtual-rep-trainer-web) | Here |
| --- | --- |
| src/avatar/live-avatar-custom.tsx | src/components/LiveAvatarCustom.tsx |
| src/avatar/live-avatar-custom.scss | src/components/styles.ts (injected into the shadow root) |
| src/avatar/utils/loaderStatus.ts | STATUS_BUCKETS in LiveAvatarCustom.tsx |
| Common-Components/horizontal-loader | src/components/HorizontalLoader.tsx (MUI-free) |
| src/avatar/useTimeoutDialog.tsx + TimeoutDialog | src/components/Dialogs.tsx |
| utils/enums/requestConfirmation.ts | ErrorDialog in src/components/Dialogs.tsx |
| src/avatar/services/log-session-data-service.ts | AvatarApi.logSessionData() |
| utils/enums/apiEndpoints.ts (LiveAvatar entries) | src/services/apiEndpoints.ts |
| modules/shared/services/htttp-wrapper.service.ts + common.service.ts | src/services/httpClient.ts |
| store.ts → logHeygenDetails, fetchAndSetVideoUrl | AvatarApi.logEvent(), AvatarApi.getVideoUrl() |
| utils/enums/enum.ts LiveAvatar constants | src/config/defaults.ts |
Host dependencies that became inputs
| Original source | Web Component input |
| --- | --- |
| window.APP_CONFIG.BASE_URL | base-url / config.baseUrl |
| useActiveAuth() → auth.userApiData.user_id | user-id / config.auth.userId |
| atob(localStorage.user)._t | bearer-token (or auth-token) / config.auth.token |
| window.APP_CONFIG.ENVIRONMENT + NO_IDM | auth-mode / config.auth.authMode (the token now decides — see Authentication) |
| trainer.selectedChat.conversation_id | conversation-id |
| trainer.selectedChat.practice_id | practice-id |
| trainer.selectedChat.customer_id | customer-id |
| trainer.selectedChat.image_id | image-id |
| trainer.language | language |
| avatar_details.live_avatar_id | live-avatar-id |
| avatar_details.intro_text | intro-text |
| avatar_details.video_url / video_timer | video-url / video-timer |
| liveAvatar_context_id | context-id (required) |
| sandbox_liveAvatar_id + FORCE_HEYGEN_SANDBOX_MODE | sandbox-avatar-id + sandbox |
| avatar_type + sessionActiveTab (is_videoMode_liveAvatar) | mode="video" \| "audio" |
| maleVoices / femaleVoices | config.voices |
| mic_listening, mic_isLoading (zustand) | element.micState = {...} |
| t("...") (i18next) | config.labels |
| setEnabled_chatElements / setDisabled_chatElements | chat-enabled / chat-disabled events |
| setIsRealtimeAvatarWebRTCReady(true) | avatar-ready event |
| setMsg("assistant", …) | avatar-message event |
| callSessionTabChange(t("chat_session")) | session-timeout event |
| eventBus.emit("callHeygenSpeak", …) | element.sendMessage(text, attachment) |
| onAudioDeltaRef.current(type, msg) | element.pushAudioEvent(type, msg) |
Modes
mode reproduces the original is_videoMode_liveAvatar switch.
video— the component owns the text/voice pipeline WebSocket, the intro video, the intro text and the 90 s/115 s inactivity timer. (Original:AvatarMain→<LiveAvatarCustom setMsg=… />.)audio— no pipeline socket and no timer; an external audio source drives the avatar throughpushAudioEvent(). (Original:LiveAvatarStaticWindow→<LiveAvatarCustom onAudioDeltaRef=… />.)
Configuration
Simple scalars can be HTML attributes; anything structured goes through the
config property. Attributes win for the keys they explicitly set.
<realtime-avatar
base-url="https://api.example.com/simulate"
conversation-id="c-123"
customer-id="cust-9"
image-id="F2"
language="en"
user-id="u-42"
context-id="67808e4d-…"
mode="video"
></realtime-avatar>const avatar = document.querySelector("realtime-avatar");
avatar.config = {
baseUrl: "https://api.example.com/simulate",
mode: "video",
autoStart: true,
auth: {
userId: "u-42",
userName: "Ada Lovelace",
token: "…", // → `Authorization: Bearer …`, no cookies sent
// authScheme: "Bearer", // default; "" sends `token` verbatim
// authMode: "auto", // "token" | "cookie" force the mode
headers: { "x-tenant": "acme" },
},
conversation: { conversationId: "c-123", customerId: "cust-9", imageId: "F2", language: "en" },
avatarDetails: { liveAvatarId: "…", introText: "Hi there", videoUrl: "intro.mp4", videoTimer: 4 },
session: { contextId: "…", isSandbox: false, liveAvatarApiBase: "https://api.liveavatar.com" },
voices: { male: ["cedar"], female: ["marin"] },
timeout: { enabled: true, warningSeconds: 90, endSeconds: 115, dialogSeconds: 25 },
labels: { timeoutTitle: "Are you still there?", continueCall: "Continue call" },
ui: { showLoader: true, showTimeoutDialog: true, showErrorDialog: true },
};Nothing is hard-coded
The package ships no environment, tenant or vendor values. These are all
required inputs; if any cannot be resolved the component emits
avatar-error{ phase: "config" } listing exactly what is missing, and makes no
network calls:
| Required | Attribute |
| --- | --- |
| baseUrl | base-url |
| auth.userId | user-id |
| conversation.conversationId | conversation-id |
| session.contextId | context-id |
| every endpoint the config uses (see below) | endpoint-* |
| an avatar id — avatarDetails.liveAvatarId, or the gendered session.fallbackMale/FemaleAvatarId, or session.sandboxAvatarId when sandboxed | live-avatar-id / fallback-male-avatar-id / fallback-female-avatar-id / sandbox-avatar-id |
| voices.male or voices.female (matching image-id, video mode only) | male-voices / female-voices |
The only values with defaults are protocol/UX constants that carry no
deployment information: session.mode ("LITE"), session.modeName
("realtime_avatar_mode"), the 90 s/115 s/25 s inactivity thresholds and the
English label text. All are overridable.
A pre-publish guard (scripts/verify-package.mjs) fails the build if a URL,
API path, UUID or credential ever reappears in the bundle.
Endpoints (required)
The package ships no URLs. Every endpoint is supplied by the host, as a template string or a builder function, by property or by attribute.
| Config key | Attribute | Required when |
| --- | --- | --- |
| sessionToken | endpoint-session-token | always |
| startSession | endpoint-start-session | always |
| logSessionData | endpoint-log-session-data | always |
| stopSession | endpoint-stop-session | always |
| keepAlive | endpoint-keep-alive | mode="video" |
| pipelineSocket | endpoint-pipeline-socket | mode="video" |
| speechToText | endpoint-speech-to-text | the mic is enabled |
| logEvent | endpoint-log-event | enableEventLogging (default on) |
| presignedVideoUrl | endpoint-presigned-video-url | avatarDetails.videoUrl is set |
requiredEndpoints(config) returns exactly which ones a given configuration
needs; anything missing is reported through avatar-error{ phase: "config" }
before a single request is made.
Placeholders: {baseUrl} {liveAvatarApiBase} {sessionId} {filePath}
{conversationId} {customerId} {language} {voice}. Unknown placeholders
are left untouched.
<realtime-avatar endpoint-session-token="{baseUrl}/v2/liveavatar/token"></realtime-avatar>avatar.config = {
endpoints: {
sessionToken: "{baseUrl}/v2/liveavatar/token",
// a builder function gets the full context — use it when you need encoding
keepAlive: (ctx) => `${ctx.baseUrl}/v2/${encodeURIComponent(ctx.sessionId)}/ping`,
},
};resolveEndpoint(), ENDPOINT_KEYS and ENDPOINT_PLACEHOLDERS are exported if
you want to build or validate templates yourself.
Full attribute list
base-url, live-avatar-api-base, environment, mode, session-tab-label,
debug, auto-start, user-id, user-name, first-name, last-name,
bearer-token, auth-token, auth-scheme, auth-mode, conversation-id,
practice-id, customer-id, image-id,
language, live-avatar-id, intro-text, video-url, video-timer,
context-id, session-mode, session-mode-name, sandbox,
sandbox-avatar-id, fallback-male-avatar-id, fallback-female-avatar-id,
male-voices, female-voices, endpoint-session-token,
endpoint-start-session, endpoint-log-session-data, endpoint-keep-alive,
endpoint-stop-session, endpoint-log-event, endpoint-presigned-video-url,
endpoint-pipeline-socket, show-loader, show-timeout-dialog,
show-error-dialog, fallback-intro-video-url, timeout-enabled,
timeout-warning-seconds, timeout-end-seconds, timeout-dialog-seconds,
enable-event-logging.
male-voices / female-voices are comma-separated (male-voices="cedar, echo").
Changing a session-critical attribute (ids, language, mode, auth, base URL) while running restarts the session.
Public methods
| Method | Maps to |
| --- | --- |
| start() | mounts the tree and runs createSession() |
| stop(reason?) | unmounts → the original cleanup path + POST .../liveavatar/stop/{id} |
| destroy() | stop() + marks the element unusable |
| sendMessage(text, attachment?) | eventBus.emit("callHeygenSpeak", …) → {type:"text_input"} |
| pushAudioEvent(type, message) | onAudioDeltaRef.current(type, message) |
| interrupt() | {type:"agent.interrupt"} |
| keepAlive() | POST .../liveavatar/keep-alive/{id} (10 s throttle) |
| setConversation({...}) | swap conversation + restart |
| updateConfig(patch, { restart? }) | merge config; restarts if session-critical |
Read-only: state, sessionId, isRunning, resolvedConfig.
Writable: config, micState.
CustomEvents
All bubble and are composed: true.
| Event | detail | Original behaviour |
| --- | --- | --- |
| avatar-ready | RealtimeAvatarState | setIsRealtimeAvatarWebRTCReady(true) |
| avatar-started | { sessionId, livekitUrl, wsUrl, apiKeyUsed } | after /v1/sessions/start |
| avatar-stopped | { sessionId, reason } | teardown / timeout / error |
| avatar-error | { message, phase, status?, cause? } | requestConfirmation({topIcon:"warning"}) |
| avatar-message | { role, type, content } | setMsg("assistant", undefined, text) |
| avatar-state-change | { state, reason } | loader progress + agent.speak_started/ended |
| chat-enabled / chat-disabled | — | setEnabled_/setDisabled_chatElements() |
| session-timeout-warning | { elapsedSeconds, dialogSeconds } | 90 s TimerWarningDialog() |
| session-timeout | { elapsedSeconds } | 115 s → callSessionTabChange("chat session") |
| conversation-change | { conversation } | new |
avatar.addEventListener("avatar-ready", (event) => console.log(event.detail));Backend contract (unchanged)
The component performs exactly the calls the original React implementation did.
The URLs below are the ones SIMULATE uses — supply them via config.endpoints.
| # | Call | Notes |
| --- | --- | --- |
| 1 | POST {baseUrl}/hygen/liveavatar/token | {mode, avatar_id, avatar_persona:{context_id}, is_sandbox} → {code:1000, api_key_used, data:{session_id, session_token}} |
| 2 | POST {liveAvatarApiBase}/v1/sessions/start | accept + authorization: Bearer <session_token> only, no body |
| 3 | POST {baseUrl}/hygen/liveavatar/log-session-data | {session_id, key_name, mode_name, conversation_id} |
| 4 | POST {baseUrl}/existing-conversations/full/liveavatar/keep-alive/{id} | throttled to 10 s |
| 5 | POST {baseUrl}/existing-conversations/full/liveavatar/stop/{id} | never rejects |
| 6 | POST {baseUrl}/hygen/log-event | telemetry; disable with enable-event-logging="false" |
| 7 | GET {baseUrl}/hygen/generate-presigned-url/{file} | intro video |
| 8 | WS {baseUrl}/existing-conversations/full/ws/text-voice-pipeline?chat_id&voice&language&customer_id | video mode only |
| 9 | LiveKit room (livekit_url + livekit_client_token) | WebRTC video/audio |
| 10 | Avatar control WS ws_url | agent.speak / agent.speak_end / agent.interrupt |
Not part of this component
The host application keeps calling these; they belong to the OpenAI/ElevenLabs
realtime pipeline that produces the audio, which reaches the avatar through
pushAudioEvent() (the original onAudioDeltaRef):
POST {baseUrl}/existing-conversations/full/realtime-resolver/{conversationId}—chat.tsx→resolveRealtimeSession()POST {baseUrl}/existing-conversations/full/realtime/session—realTimeChatDirect.tsx→requestSession()POST {baseUrl}/existing-conversations/full/realtime/transcript
https://<project>.livekit.cloud/settings/regions is livekit-client's own
region discovery — automatic, nothing to configure.
Authentication
Every request carries the x-userid header from auth.userId. Beyond that
there are exactly two modes, and the presence of a bearer token picks one:
| auth.token | Mode | Headers | Cookies |
| --- | --- | --- | --- |
| set | bearer | Authorization: Bearer <token> + Accept | credentials: "omit" |
| empty / unset | cookie (IDM) | Content-Security-Policy | credentials: "include" |
<!-- bearer: no cookies are sent -->
<realtime-avatar bearer-token="eyJhbGci…" user-id="u-42"></realtime-avatar>
<!-- no token: falls back to cookie auth -->
<realtime-avatar user-id="u-42"></realtime-avatar>avatar.config = { auth: { userId: "u-42", token: idToken } }; // bearer
avatar.setAttribute("bearer-token", refreshedToken); // rotate later
avatar.removeAttribute("bearer-token"); // back to cookiesDetails:
bearer-tokenandauth-tokenare the same input (config.auth.token);bearer-tokenis the clearer name,auth-tokenis kept for compatibility. If both are present,bearer-tokenwins.- The scheme is added for you.
"eyJhbGci…"and"Bearer eyJhbGci…"both produceAuthorization: Bearer eyJhbGci…. Setauth-schemeto change the prefix, or to""to send the token exactly as given. - An empty or whitespace-only token counts as absent, so a not-yet-loaded
token falls back to cookie auth instead of sending
Authorization:. auth-modeoverrides the choice:"cookie"keeps cookies even with a token configured,"token"never sends cookies even before a token arrives.auth.credentialsstill overrides the resultingcredentialsvalue outright.- Changing the token restarts the session, since it feeds the token request.
- The vendor (LiveAvatar) calls are unaffected — they always use the
session_tokenthe backend returns, neverauth.token.
Two deliberate differences from the original:
- A
401emitsavatar-error{status:401}instead of navigating to/login. A Web Component must not redirect its host. - The mode no longer comes from
environment+NO_IDM(demo,stg). Those fields are still accepted but ignored for auth; a token in aprodenvironment now uses bearer auth rather than cookies. Passauth-mode="cookie"to restore the old behaviour for such a config. - WebSocket URLs cannot carry headers. If your pipeline socket needs the token,
put it in the query string with an
endpoints.pipelineSocketfunction.
Never leaving a session open
A LiveAvatar session keeps running (and billing) until it is stopped or expires, so the component closes it on every exit path.
Idle user. While a session is live an inactivity timer runs. At
timeout.warningSeconds it emits session-timeout-warning and shows a dialog
with a countdown; "End call" — or letting the countdown lapse — POSTs the stop
endpoint and tears the whole tree down. Reaching timeout.endSeconds does the
same without asking. Sending a message or starting the mic resets the clock.
avatar.config = {
timeout: { enabled: true, warningSeconds: 30, endSeconds: 45, dialogSeconds: 15 },
};timeout.enabled defaults to true in video mode and false in audio
mode, matching the original. Set it explicitly to run the timer in either.
Page close / reload / navigation. A normal fetch is cancelled when the
document goes away, so the stop request is sent with keepalive: true from the
pagehide handler — it outlives the page. (sendBeacon cannot set the
x-userid header the API requires, so it is not used.)
<realtime-avatar unload-stop-session="true" unload-confirm="true"></realtime-avatar>| Option | Default | Effect |
| --- | --- | --- |
| unload.stopSession | true | POST the stop endpoint on pagehide |
| unload.confirm | false | native "Leave site?" prompt while a session is live |
unload.confirm triggers the browser's own dialog — custom wording is ignored,
and Chrome requires a prior user interaction with the page before it will show.
The stop endpoint is POSTed at most once per session, so unload followed by element removal does not double-fire.
Lifecycle & cleanup
element.remove() (or stop() / destroy()) runs the original teardown:
- clears the pipeline reconnect timer, the ElevenLabs audio-done timer and the inactivity interval
- removes the intro-video
timeupdatelistener - detaches every subscribed LiveKit track, then
removeAllListeners()+disconnect() - pauses both
<video>elements and the<audio>element and nulls theirsrcObject - closes the pipeline and avatar-control WebSockets with their handlers nulled first
POST .../liveavatar/stop/{sessionId}- unmounts the React root and drops the controls reference
Late async callbacks are guarded by a disposed flag, so a session-start
response arriving after removal cannot reconnect anything.
Multiple instances
Supported. Each element owns its own shadow root, React root, HttpClient,
AvatarApi, LiveKit Room, sockets, timers and session id. There is no
module-level mutable state.
The limit is the backend/vendor account: each instance consumes its own LiveAvatar session and concurrency is capped by your LiveAvatar plan.
TypeScript
dist/types/index.d.ts exports the configuration, request, response, state,
event and control types, plus a global declaration so document.querySelector
and TSX both resolve the element. The JSX declaration is intentionally
React-type-free so Vue/Angular consumers typecheck without @types/react.
Scripts
npm install
npm run build # vite lib build + tsc declarations
npm run typecheck
npm test # 29 integration tests (happy-dom, stubbed backend/WS/LiveKit)
npm run dev