SDK Reference
This page documents the @kaltura/intelligent-agents JavaScript SDK's own object-level API — its classes, functions, and constructor options across the Management and Experience entry points. For the raw backend HTTP endpoints the SDK wraps, see the Backend API Reference.
Contents
Management
import { Management } from '@kaltura/intelligent-agents/management';
const mgmt = new Management({ partnerId, adminSecret });
// 1. provision a complete agent from a one-line brief
const admin = await mgmt.sessions.createAdminToken(); // admin KS — server-side only
const { agentId, configId, widgetId } = await mgmt.provision({
brief: 'A helpful support agent for a video platform',
ks: admin.ks,
});
// 2. headless streaming conversation (auto-mints a conversation token)
for await (const seg of mgmt.converse(configId, 'Hello!')) {
if (seg.type === 'text') process.stdout.write(seg.content);
}
// 3. assembled result (text + toolCalls + threadId + _meta)
const result = await mgmt.converseOnce(configId, 'Hello!');
console.log(result.text, result.threadId);
// 4. react to session events automatically, server-side — no polling
const topic = await mgmt.insightSettings.create({
key: 'TOPIC', title: 'Topic', prompt: 'What was the main topic of this conversation, in 1-3 words?', valueType: 'string',
}, admin.ks);
await mgmt.lifecycle.create({
name: 'Summarize on session end',
systemName: 'auto_summary_v1',
eventType: 'session_ended',
objectType: 'thread',
action: { actionType: 'triggerInsightSettingsKai', insightSettingsIds: [topic.id] },
}, admin.ks);
provision() returns {name, configId, avatarId, agentId, widgetId, profile, personaLint, blocks?, _meta}. personaLint (see lintPersonaIdentity below) is a warning-only check for persona-name drift. It never fails provision(). Inspect personaLint.findings yourself if you want to act on it.
converseOnce returns { text, threadId, messageId, segments, toolCalls, experiences, experiencesList, kindCounts, spiralStopped, truncated, _meta }. spiralStopped:true means a tool spiral was detected and cut short — check toolCalls[0] and re-prompt. truncated:true means the stream hit maxSegments (a runaway-non-tool-segment guard, default 2000) before finishing — gathered content is returned but the turn is incomplete.
Pass recoverFromSpiral: true (to converseOnce or conversations.send) to auto-recover from the empty-spiral case. This is a tool-call loop so long that the brain (the AI model that drives the conversation) never reaches a spoken sentence in that turn. The result is spiralStopped:true with text:'', and headless HTTP has no live-socket interrupt()/reconnect to fall back on, so there's nothing to recover in the same turn.
The result then carries spiralRecovered (true/false) and firstAttempt: {toolCalls, spiralStopped} from the discarded empty attempt. This option is off by default; omit it for the original, untouched behavior.
See Spiral recovery auto-resend below for how the shared SPIRAL_RECOVERY_PREFIX resend mechanism works. The headless path triggers it from an empty first attempt, not from a hard-spiral cold reconnect.
Experience
import { KalturaAvatarSession } from '@kaltura/intelligent-agents/experience';
import { io } from 'socket.io-client';
const session = new KalturaAvatarSession({
token, // conversation KS — appInit.ks
conversationManagerUrl, // from appInit
srsBaseUrl, // from appInit
turnServerUrl, // from appInit
videoEl: document.querySelector('video'),
socketFactory: (url, opts) => io(url, opts), // inject socket.io
});
// Attach listeners before connect(): some events fire before it resolves.
session.on('transcript', ({ text }) => console.log(text));
session.onToolCall('navigate_to_slide', ({ slide_num }) => deck.goTo(slide_num));
await session.connect();
session.speak('Tell me about onboarding.');
How speak(text) works: it injects text into the conversation on the same path as the viewer's own voice transcript. The brain treats it as a new turn and replies on its own terms. It's not an echo, and there's nothing to "rephrase": the avatar's next line is the brain's reply to text, not a repeat of it. For scripted, word-for-word playback instead, see scripted avatar sessions.
All transports are injected — socketFactory, rtcConstructor, fetch, getUserMedia. Tests pass fakes; the SDK stays zero-dependency.
The SDK assigns the stream to videoEl.srcObject and applies no CSS of its own — size the box yourself with object-fit: cover (aspect-agnostic, no letterbox/pillarbox bars) — see Displaying the Avatar Video.
{{var}} personalization (request_vars)
Pass slow-changing personalization values (viewer name, account tier) that the brain's prompt reads via {{var}} templating — join-time via cfg.requestVars, or mid-session via updateRequestVars():
const session = new KalturaAvatarSession({ token, /* … */, requestVars: { user_name: 'Ada' } });
// later, once you learn more about the viewer:
session.updateRequestVars({ account_tier: 'enterprise' }); // user_name keeps 'Ada'
updateRequestVars(vars) merges vars into the session's map, so send only the keys that changed. For a full per-turn context blob the brain reads fresh every turn (not just {{var}} substitution), use session.setDynamicPrompt() instead. The two mechanisms are distinct.
For the full picture of when to use request_vars vs. setDynamicPrompt() vs. actively nudging the brain with speak() vs. answering a brain-initiated request with submitStructuredDataForm() — and a worked example showing how they compose — see Dynamic Data Injection.
request_vars rejects a reserved key before any network call. The rejected names are:
- The
sys__*names the brain sets on every turn:sys__thread_id,sys__message_id,sys__user_id,sys__user_message,sys__ks,sys__is_new_thread,sys__context_id,sys__context_type,sys__avatar_enabled,sys__avatar_share_screen_enabled - The bare
sys__user_objname, and anysys__user_obj.-prefixed key secrets
Values must be scalar (string, number, boolean, or null). An object or array throws too.
Tap-to-talk (push-to-talk voice)
For the app-level decision of whether to use this at all, and the UI/accessibility/safety design around it, see Voice Input Modes — this section is the API reference.
startTapToTalk()/endTapToTalk() are a distinct voice-input mode from typed-text speak()/interrupt(). The ASR mic uplink is always connected once connect() resolves. Tapping just tells the server to mark a capture window (tapToTalkStart) and, on release, mint the turn from whatever it captured (tapToTalkEnd, after a short server-side settle). That turn then arrives through the same agentTurnToTalk/transcript pipeline as any open-mic turn, so there's no separate transcript path to wire up.
micButton.addEventListener('click', () => {
if (session.tapToTalkActive) session.endTapToTalk();
else session.startTapToTalk();
});
session.on('tapToTalkStarted', () => micButton.setAttribute('aria-pressed', 'true'));
session.on('tapToTalkEnded', () => micButton.setAttribute('aria-pressed', 'false'));
startTapToTalk() throws capability_disabled unless session.capabilities.tapToTalk (from clientConfiguration.isTapToTalk) is true. Treat isTapToTalk as a fixed, per-agent deployment choice, never a live per-session toggle — build the UI conditionally on the flag instead:
if (session.capabilities.tapToTalk) {
micButton.addEventListener('click', () => {
if (session.tapToTalkActive) session.endTapToTalk();
else session.startTapToTalk();
});
session.on('tapToTalkStarted', () => micButton.setAttribute('aria-pressed', 'true'));
session.on('tapToTalkEnded', () => micButton.setAttribute('aria-pressed', 'false'));
} else {
micButton.remove(); // open-mic agent: no tap control to show
}
speak()/interrupt() throw invalid_state while a tap is open. Otherwise they'd bracket the server's tapped-mode window with the typed-text isSpeechStart marker, minting a duplicate turn. startTapToTalk()/endTapToTalk() throw invalid_state if called out of order, and are gated by the same requireDisclosureAck disclosure gate as speak().
Pair tap-to-talk with silence-based auto-stop and a hard max-duration cap. That way an abandoned tap (tab closed, navigation away) can't leave a capture window open forever. Treat a disconnect/pagehide while tapToTalkActive as an implicit endTapToTalk().
Build the control as click-to-toggle, not press-and-hold. It's more usable for longer utterances, and it satisfies WCAG 2.5.2 Pointer Cancellation on its own, since the down-event never fires the action.
Resilience: brain stalls and tool-call spirals
KalturaAvatarSession watches for a brain that goes quiet or loops instead of answering — see System Internals Reference for the full failure-mode matrix.
- Brain-stall watchdog (
brainStallMs, default on) — emitsbrainStalled({count}), repeating for as long as nothing perceivable (spoken/avatar content or a GenUI widget) follows a turn. - Dead-air masking (
responsePending/responseSettled) —responsePending({}) fires the moment a turn starts awaiting the brain's first perceivable output (spoken/avatar/GenUI content).responseSettled({}) fires once that output arrives, the turn ends, an interruption occurs, or the session tears down. Use this pair to show or hide a "thinking…" affordance instead of leaving the avatar's face frozen during the gap. Seeexamples/browser-experience.htmlfor a working example. - Tool-call spiral circuit breaker — constructor options
toolSpiralLimit(default 10, per turn) andhardToolSpiralLimit(defaulttoolSpiralLimit * 3, session-scoped). Events:toolSpiralDetected(soft) andtoolSpiralRecovering({count, limit, lastTurnText}, hard, triggers a cold reconnect).recoverFromSpiral(defaulttrue) auto-resends the abandoned turn's text once on reconnect and emitsspiralRecovered {text}. Set it tofalseto handle recovery yourself vialastTurnText. See System Internals Reference § Tool-call spiral for why the two tiers exist and how recovery works.
const session = new KalturaAvatarSession({ token, /* … */, recoverFromSpiral: false });
session.on('toolSpiralRecovering', ({ lastTurnText }) => {
if (lastTurnText) myOwnResend(lastTurnText);
});
Two ICE-level failure modes get distinct, faster handling:
- Zero-candidates fail-fast — if ICE gathering completes having produced no candidates at all (a dead network path, e.g. TURN unreachable), the SDK escalates to media recovery immediately. It doesn't wait out the full 10s stuck-in-
new/checkingwatchdog. A 3s floor still guards against a genuinely slow TURN-only network. - Recoverable vs. session-gone — an STV media-recovery failure carrying a WHEP 404 means the server session is truly gone, not just a transient drop. It surfaces a distinct
connectivityChangeddetail('stv session gone (404)') before cold-reconnecting. Both cases still cold-reconnect the same way, but now you can tell them apart in logs and metrics.
Devices and media quality
session.on('hardwareMuteChanged', ({ muted }) => micIndicator.set(muted));
session.on('localSpeakingChanged', ({ speaking }) => localSpeakerIndicator.set(speaking));
session.on('localMicLevel', ({ level }) => micButton.style.setProperty('--level', level)); // 0-1, every 50ms tick
const { mics, speakers } = await session.listDevices();
await session.switchMic(mics[1].deviceId); // replaceTrack, no renegotiation
await session.setAudioOutput(speakers[1].deviceId); // HTMLMediaElement.setSinkId, retried 5x/500ms
await session.setAsrBandwidth(24); // kbps, applied live via RTCRtpSender.setParameters
hardwareMuteChanged({muted}) — fires when the OS/hardware mutes or unmutes the active mic track (track.onmute/onunmute). Mute is debounced 5s (many platforms bliponmuteduring device switches); unmute fires immediately.localSpeakingChanged({speaking}) — an instant local speaking indicator from client-side volume analysis (AnalyserNode, 50ms sampling, threshold vialocalVadThreshold, default 300), independent of the server's own turn-taking signals. It activates only while at least one listener is registered, so a session that never listens pays zero Web Audio cost, and it deactivates the moment the last listener unsubscribes.localMicLevel({level}, 0-1) — the same 50msAnalyserNodesampler's continuous volume, normalized against the analyser's max possible byte-frequency sum. It's emitted on every tick, not just on threshold transitions, so it can drive a real-time UI meter (e.g. a mic button that visually fills with live input volume) without needing to bucketlocalSpeakingChanged. It shares the same lazy activate/deactivate lifecycle: registering a listener for eitherlocalMicLevelorlocalSpeakingChangedstarts the sampler, and the sampler stops only once every listener for both has unsubscribed.listDevices()—{mics, speakers}fromnavigator.mediaDevices.enumerateDevices()(video input omitted; an avatar session has no local camera). Returns empty lists headlessly/without permission rather than throwing.switchMic(deviceId)— swaps the ASR uplink's sender track viareplaceTrack, no renegotiation; rewires the hardware-mute watch and VAD onto the new stream and stops the old one.setAudioOutput(deviceId)— routesvideoElplayback viasetSinkId, retrying up to 5 times at 500ms; returnsfalse(never throws) if the platform lackssetSinkIdor every retry is exhausted.preferredVideoCodec(constructor option, e.g.'VP9') — filters the STV downlink's video transceiver to a single codec viasetCodecPreferences. Silently falls back to browser-default negotiation if the codec isn't in this browser'sRTCRtpReceiver.getCapabilities('video').maxAsrBitrateKbps(constructor option) /setAsrBandwidth(kbps)(mid-session) — caps the ASR mic uplink's bitrate viaRTCRtpSender.setParameters(), no renegotiation.
Noise suppression (Tier-1 default + Tier-2 BYO-DSP)
Two independent layers:
// Tier 1 (always on by default) — no code needed. To customize or opt out:
const session = new KalturaAvatarSession({ token, /* … */,
micConstraints: { noiseSuppression: false }, // merge over the default, or...
// micConstraints: false, // ...opt out entirely (bare audio:true)
});
// Tier 2 (opt-in) — BYO-DSP: any lib or bespoke processor shaped (stream) => Promise<MediaStream|{stream,stop}>
import { createNoiseSuppressor } from '@kaltura/intelligent-agents/experience/noise-suppressor';
const session = new KalturaAvatarSession({ token, /* … */,
noiseProcessor: createNoiseSuppressor({ thresholdDb: -50 }), // the SDK's own lightweight AudioWorklet gate
micConstraints: false, // recommended when the DSP expects raw, unprocessed audio — stacking Tier-1 browser
// suppression under a second denoiser double-processes the signal
});
micConstraints(constructor option) —MediaTrackConstraintsmerged into everygetUserMedia({audio})call this session makes (connect(),switchMic()). Default{echoCancellation:true, noiseSuppression:true, autoGainControl:true}— the standard browser-native Tier-1 baseline. Passfalseto send bareaudio:true; pass a partial object to override individual fields.noiseProcessor(constructor option) — pluggable Tier-2 DSP hook:(stream) => Promise<MediaStream|{stream,stop}>. It's called with the rawgetUserMediastream atconnect()and everyswitchMic(). Its returned stream (or{stream,stop}, if the processor owns a resource that needs explicit teardown, e.g. anAudioWorkletNodegraph) is what actually reaches the ASR uplink. The SDK core bundles no DSP library. Bring a third-party processor (dynamically import it so apps that don't use it never load it) or a bespoke one; anything matching the shape works. A processor that throws fails mic acquisition closed with a typednoise_processor_failederror (the same fail-closed behavior as agetUserMediarejection).createNoiseSuppressor(opts)(./experience/noise-suppressor, separately importable, has zero effect until constructed and passed asnoiseProcessor) — the SDK's own real, lightweight, dependency-free Tier-2 implementation. It's an adaptive RMS noise gate running as a pure-browser-nativeAudioWorkletProcessor(attack/release-smoothed envelope, adaptive noise-floor tracking) — not spectral or ML denoising, which is a heavier Tier-2 DSP approach. Options:thresholdDb(default-50),attackMs(default5),releaseMs(default150),floorAdaptMs(default2000).audioContext/getAudioContext/audioWorkletNodeConstructorare injectable for testing, mirroring the rest of the SDK's constructor-injection style.
Text-only chat and switchable transports (KalturaChatSession / KalturaAgentSession)
KalturaChatSession talks to the same brain and the same thread as KalturaAvatarSession, over plain HTTPS instead of a socket + WebRTC. There's no mic, no camera, no video element, and no socket.io, so a chat-only page never triggers a permission prompt:
import { KalturaChatSession } from '@kaltura/intelligent-agents/experience';
const chat = new KalturaChatSession({ token /* conversation KS */ });
chat.onToolCall('navigate_to_slide', ({ slide_num }) => deck.goTo(slide_num));
await chat.connect(); // no network — marks the session live for API parity with the avatar transport
const { text, threadId } = await chat.sendText('What have we covered so far?');
KalturaChatSession has feature parity with the avatar transport wherever the wire allows it:
request_vars/setDynamicPrompt— same canonical-map merge semantics; the full map rides every turn, since HTTP has no join to persist itonToolCallwith the same per-turn dedup, schema-check, and fused-segment recoveryrespondToTool()forwaitForResponse:truetools- Thread continuity — seed
cfg.threadIdwith another session'sthreadIdgetter to continue that conversation on the other transport
It emits the same transport-agnostic event subset as the avatar transport (transcript, turnStart, turnEnd, toolCall, toolCallResult, toolCallInvalid, stateChange, responsePending, responseSettled, brainStalled, warning, error, ended). App code written against those events works unchanged when KalturaAgentSession swaps transports underneath it. sendText() turns are serialized: a second call awaits the previous turn's stream end.
KalturaAgentSession is a facade that runs one conversation over either transport. It can switchMode() between them mid-conversation without losing the thread: it tears down the current transport, constructs the other one seeded with the same threadId and the same canonical request_vars map, and reconnects:
import { KalturaAgentSession } from '@kaltura/intelligent-agents/experience';
const agent = new KalturaAgentSession({
token, mode: 'avatar', // starting transport — 'avatar' (default) or 'chat'
avatar: { videoEl, conversationManagerUrl, srsBaseUrl, turnServerUrl, socketFactory },
chat: { genieUrl },
});
// Attach before connect(): transportChanged fires before connect() resolves,
// and again before each switchMode() resolves.
agent.on('transportChanged', ({ mode, transport }) => { /* rewire mode-specific listeners */ });
agent.onToolCall('navigate_to_slide', ({ slide_num }) => deck.goTo(slide_num));
await agent.connect();
// later, drop to text-only without losing context:
await agent.switchMode('chat');
The facade owns one state machine (idle → connecting → connected ⇄ switching → closed | failed) and forwards the transport-agnostic event subset 1:1. Mode-specific APIs (mic control, interrupt(), tap-to-talk, disclosure, videoEl, …) are not mirrored on the facade. Use the transport getter and rewire such listeners on each transportChanged event. Switching is tear-down-and-reconstruct by design: no live mutation of a running transport. A sendText() that arrives mid-switch is buffered (up to 8 calls) and dispatched on the new transport, or rejected with the switch error if the switch fails.
KAVA analytics (opt-in, client-only Application Events)
import { KavaAnalytics } from '@kaltura/intelligent-agents/experience/analytics';
const analytics = new KavaAnalytics({
partnerId: AGENTIC_PARTNER_ID, sessionId: session.threadId,
hostingKalturaApplication: 28, // see HOSTING_APPLICATIONS — Avatar Videos in this example
});
analytics.pageLoad({ pageType: 'View', pageName: 'product-deck' });
btnFeedbackDismiss.onclick = () => analytics.buttonClicked({ buttonType: 'Open', buttonName: 'feedback-dismiss' });
KavaAnalytics (./experience/analytics, its own subpath so apps that don't report analytics never load it) reports KAVA (Kaltura Video Analytics) events to https://analytics.kaltura.com/api_v3/index.php (service=analytics&action=trackEvent). It implements only the 10000-range Application Event family: pageLoad (10003) and buttonClicked (10002). Use these for interactions the server has zero visibility into: a page/view landing, a UI-only click, a contact-form submit/skip, a widget dismiss.
This call is a write, best-effort, and not idempotent — each call records a new row, and there is no dedup contract. It's fire-and-forget by design, so callers don't need to await it for correctness.
Deliberately does not implement the 80000-range "Immersive Agents" events (callStarted/callEnded/messageResponse/messageFeedbackSent). There is no code path in this module that can send them. The backend already reports all four server-side for every session KalturaAvatarSession connects to (same socket, matching event names), so a client-side copy would double-count on the live analytics dashboards. If a real gap in that server-side reporting is ever found, file it as a GitHub issue rather than adding a client resend.
Transport: prefers navigator.sendBeacon (survives page-unload); falls back to an injectable fetch with keepalive:true when unavailable or when the beacon queue is full. Never reads a response body. enabled: false no-ops every call without touching the network — use for offline/mock test runs.
pageLoad(fields)—{pageType, pageName, pageValue, pageInfo}.pageTypeis validated against the closed enumPAGE_TYPES(View/Create/Edit/Participate/List/Analytics/Admin/Error/Login/Registration/Custom); an invalid value throws before any network call.buttonClicked(fields)—{buttonType, buttonName, buttonValue, buttonInfo}.buttonTypeis free text (the spec leaves it open-ended, e.g.Create/Filter/Navigate/Open).- Common params set once at construction and attached to every event:
partnerId,ks,entryId,sessionId,referrer,userId,hostingKalturaApplication/hostingKalturaApplicationVer,customId1/customId2. buildPageLoadParams/buildButtonClickedParamsare the pure param-builders behind the class — unit-testable in isolation, or usable directly if you want your own transport.
Reporting a GenUI widget interaction specifically (which chip/link/answer the viewer picked)? See GenUI Reference § Widget-interaction analytics for the recipe, confirmed against two widget types, plus the exact list of signals the platform already tracks server-side so you don't duplicate one client-side.
Connectivity beacon (opt-in)
const session = new KalturaAvatarSession({ token, /* … */, statsIntervalMs: 5000 });
session.on('connectionQuality', ({ channel, rttMs, packetLossPct, jitterMs, bitrateKbps }) => {
metrics.gauge(`avatar.${channel}.rtt_ms`, rttMs);
});
statsIntervalMs(constructor option) — pollsRTCPeerConnection.getStats()on both the ASR uplink and STV downlink at this interval, and emitsconnectionQuality. The shape is adopted from the WebRTC avatar engine'sPeerConnectionWebrtcStats, but only the raw numbers: no scoring engine or telemetry-backend wiring, so you can feed them into whatever metrics pipeline you already run. Leaving it unset (the default) disables the beacon entirely, so a session that never opts in pays zerogetStats()cost.connectionQuality({channel: 'asr'|'stv', rttMs, packetLossPct, jitterMs, bitrateKbps}) —rttMscomes from the active candidate-pair'scurrentRoundTripTime.packetLossPct/jitterMscome from the RTP stream stats (outbound-rtpfor ASR,inbound-rtpfor STV).bitrateKbpsis a byte-count delta against the previous poll, so it'snullon the first tick for each channel. Any field the browser didn't report isnullrather than a guessed value.
Accessibility (WCAG 2.2 AA / captions) + AI-disclosure gate
Live captions satisfy WCAG 1.2.4 (Live Captions) — render them from CaptionService, which handles segmentation, timing, and barge-in invalidation for you:
import { CaptionService } from '@kaltura/intelligent-agents/experience';
const captions = new CaptionService(session, {
replacements: { 'Kalturah': 'Kaltura' }, // optional term corrections
});
captions.onCaption(({ text, clear }) => {
captionEl.textContent = clear ? '' : text;
});
For the EU AI Act Art. 50 interaction-disclosure obligation, construct the session with requireDisclosureAck:true — this blocks speak() with a typed disclosure_required error until your app calls acknowledgeDisclosure(), so a deployer has a provable gate rather than a policy document:
const session = new KalturaAvatarSession({ token, /* … */, requireDisclosureAck: true });
session.on('disclosure', (notice) => showDisclosureBanner(notice));
await session.connect();
// session.speak(...) throws `disclosure_required` here until:
session.acknowledgeDisclosure();
Security posture
Designed for enterprise, HIPAA, HITRUST, and regulated frameworks. Full control matrix in Security. For Kaltura's authoritative legal/compliance positions, see Kaltura's AI Principles and the subprocessors list.
| Control | What the SDK does |
|---|---|
| KS guidance for agents | disableentitlement reachable only via sessions.createAdminToken(); createConversationToken/createAgentToken refuse extraPrivileges that disable entitlement, so a client-facing token can't be tricked into escalating |
| Short-lived tokens | Admin: 1h default; conversation/agent: 30min default. revoke() for active revocation; setToken() for mid-session rotation; restrictions for least privilege |
| Audit stream | onAuditEvent emits structured, pre-redacted events (token.mint, guard.reject, tool.invoke, …) to your SIEM |
| Transport | https/wss enforced; cleartext rejected; ephemeral TURN credentials preferred |
| Browser hygiene | Token is memory-only, non-enumerable, dropped on disconnect(); prototype-pollution scrub on setDynamicPrompt; AI disclosure gate before first speech (EU AI Act Art. 50) |
| Supply chain | Zero runtime deps, no registry install step — sourced straight from git; all CI-gated |
Key design rules
- Keep
disableentitlement(management) server-side.KalturaAvatarSessionexpects ageniegpcid/agentid/widget token (entitlement ON) — see Security for the full guidance and the rare case where an app deliberately needs to hand a browser broader access. - Destructive ops require
{ confirmPermanent: true }. Never a flag on a read operation. - Capabilities are a full-replace dict. A partial update drops keys you omit. Use
intellects.setCapability(configId, name, state, ks)— it reads, merges, and writes. kaltura_genie_experiencescompetes with client tools. Set it'off'at creation for tool-driven intellects (the capability injects a system rule that out-competes custom tools). Set at creation — partner config is cached ~24 h server-side.tools.clientToolReadiness(body)lints for this.force_experienceandmodel_type:'fast'are hints, not contracts. The live runtime hardcodesavatar_only; structured widgets arrive reliably only on the HTTP converse path. Neither field gives you a way to confirm after the fact which model actually replied or which experience actually rendered.- Group turn events by
speechId, never timestamp. A new utterance invalidates the prior one's in-flight captions (barge-in guard).
Client-side commands
The cleanest way for the brain to drive your UI: no custom JSON, no fragile text parsing, no server-side echo call. tools.client() builds a native type:"client" tool. The model calls it, the backend emits a silent type:"tool" segment carrying tool_metadata.id, and that's the entire server-side contract: no request block, no echo endpoint, no response shaper.
// author once (server, admin KS)
import { tools } from '@kaltura/intelligent-agents/management';
const navigate = tools.client({
name: 'navigate_to_slide',
description: 'Go to a slide. Call when the user asks about a deck topic.',
args: { slide_num: { prompt: 'Slide number (1-N).', type: 'int', required: true } },
waitForResponse: false, // fire-and-forget — omitting this blocks (backend default is true)
});
// tools are a SEPARATE, partner-level entity — create it, then reference the id.
// (mgmt.tools.create(...) is an alias for .add(), same call, same result)
const { id } = await mgmt.tools.add(navigate, ks);
const { configId } = await mgmt.intellects.create({
capabilities: { kaltura_genie_experiences: 'off' }, // required — see design rules
tool_ids: [id],
}, ks);
// consume at runtime (browser)
session.onToolCall('navigate_to_slide', ({ slide_num }) => deck.goTo(slide_num));
// or headless
const { toolCalls } = await mgmt.converseOnce(configId, 'tell me about pricing');
waitForResponse controls whether the model's turn blocks on a real client-supplied result. Pass it explicitly: see Client Commands § the brain calls it for why omitting it is not the same as false. Fire-and-forget tools (waitForResponse:false) have no response channel back to the model at all. Fold any "call once, then narrate" guidance directly into the tool's description instead of relying on a fixed success message.
Native client tools with a real wire ACK
For a tool that must block the model's turn on a real client-supplied result (rather than fire-and-forget), set waitForResponse: true:
const pick = tools.client({
name: 'ask_user_to_pick_a_slide',
description: 'Ask the on-screen viewer to pick a slide, and wait for their answer.',
waitForResponse: true, // block the model's turn on a real client result
timeout: 15, // seconds to wait for the ACK (default 30)
});
const { id } = await mgmt.tools.add(pick, adminKs);
// live-socket host side
session.onToolCall('ask_user_to_pick_a_slide', async (call) => {
const slide = await askViewer();
await session.respondToTool(call.toolMetadata.id, { slide });
});
waitForResponse:false never populates call.toolMetadata with an id to ACK against — only register respondToTool/onToolCall ACK logic for tools you built with waitForResponse:true. A respondToTool call for an unknown or already-resolved id degrades to {ok:false, reason:'unknown_or_stale'} rather than throwing or hanging. This ACK is a live-socket operation only — there is no headless (Management) equivalent.
Handler results (local only, unless waitForResponse:true)
A handler's return value (or thrown/rejected error) is captured and re-emitted as 'toolCallResult' — {call, ok:true, value} for a non-undefined return/resolve, {call, ok:false, error} for a throw/reject. A handler returning undefined (the common case) emits nothing.
session.onToolCall('create_slide', (args) => ({ slideNumber: deck.append(args) }));
session.on('toolCallResult', ({ call, ok, value, error }) => console.log(call.name, ok, value ?? error));
For a waitForResponse:false tool this is local/app-observable only — it never reaches Genie's brain. To actually carry your handler's result back to the model, build the tool with waitForResponse:true and call session.respondToTool(call.toolMetadata.id, ...) (see above) — that is the only wire channel back to the model.
Arg validation before dispatch
onToolCall takes an optional third argument, a per-key {type, required, enum} schema, checked against call.args before the handler runs:
session.onToolCall('navigate_to_slide', ({ slide_num }) => deck.goTo(slide_num), {
slide_num: { type: 'int', required: true },
});
session.on('toolCallInvalid', ({ call, errors }) => console.warn('rejected', call.name, errors));
Validated fields, top-level keys only: type (one of str/int/float/bool/list/dict, the same six-value ARG_TYPES vocabulary as tools.client's args, so you can pass the exact object you already declared there), required, and enum (a closed set of legal values). On a mismatch, the handler is not invoked. The SDK emits 'toolCallInvalid' {call, errors} instead of 'toolCall', so a malformed call (e.g. a bare string where an int was declared) never runs on bad data. No schema registered means no check and zero behavior change. collectConverse has the same guard for headless use: pass opts.toolArgSchemas: {toolName: schema} and a mismatched call is diverted to the result's toolCallsInvalid array instead of toolCalls.
Fused multi-tool turns (handled automatically on the live session)
When a turn calls 2+ tools, the server can stream them as one type:"tool" segment that names only the last tool, with earlier tools' JSON args concatenated into the same string (see Wire Protocol §4e). On KalturaAvatarSession, you don't need to do anything — the SDK recovers every fused call:
parseToolCall(segment)recovers the named tool's own args correctly either way, and exposes any earlier, unnamed blobs ascall.fusedArgs(array, arrival order — absent when the segment wasn't fused).- The session pairs each queued
fusedArgsblob with thetool_responsesegment that echoes its real tool name (viaparseToolResponseName(segment)) and dispatches it through the normalonToolCallpath — same dedup, same schema validation, sametoolCallResult/toolCallInvalidevents. - The queue is ASR-sub-turn-scoped and clears on every
agent_start_speech. A name dispatched directly in one sub-turn never blocks that same name's fused recovery in the next sub-turn of the same turn, and a stray echo never leaks a recovery into the wrong turn.
Headless collectConverse() gets the corrected named-tool args for free, but does not run this pairing. An earlier fused blob in a headless turn is reachable only via fusedArgs on that one ToolCall, not as its own toolCalls entry. If you need full recovery headlessly, replay toolCalls and pair each fusedArgs blob with the matching tool_response-derived name yourself using parseToolResponseName.
AI-SDR / CRM lead capture
./management ships validated api tool builders for the common CRM contact-upsert integrations, so an AI-SDR or concierge agent doesn't need to hand-write the HTTP tool config:
import { hubspotContactUpsert, salesforceContactUpsert } from '@kaltura/intelligent-agents/management';
// pure config builder — no network call, no secret VALUE here
const tool = hubspotContactUpsert({ secretName: 'HUBSPOT_TOKEN' });
// or: salesforceContactUpsert({ secretName: 'SF_TOKEN', instanceUrl: 'https://yourorg.my.salesforce.com' })
// inject the secret VALUE server-side (never in the tool config)
await mgmt.intellects.secrets.set(configId, { HUBSPOT_TOKEN: process.env.HUBSPOT_TOKEN }, ks);
// tools are a separate, partner-level entity — create it, then link it
const { id } = await mgmt.tools.add(tool, ks);
await mgmt.intellectConfig.setToolIds(configId, [id], ks);
Both recipes validate their config (via tools.api()) and throw a typed error for a missing secretName/instanceUrl before any write. See src/management/crm-recipes.js for the full arg list (propertiesToCapture/fieldsToCapture, externalIdField).
For Marketo, Airtable, Google Sheets/Forms, or any other REST target, see External API Integrations. That guide also covers the real backend-managed OAuth2 authorization-code flow (consent plus auto-refresh) for providers that require it.
GenUI
import { ExperienceRenderer } from '@kaltura/intelligent-agents/experience/genui';
// 2-line happy path — renders all first-class GenUI widgets
new ExperienceRenderer({
session,
mount: document.getElementById('widgets'),
onAction: (action, payload) => { /* followup / submit / play / open / answer */ },
}).start();
mountWidget(descriptor, target, opts) is the zero-dep, never-innerHTML, accessible renderer. It ships zero styling: you theme the stable kgenui/kgenui__* class contract. onMount(root, descriptor) is the progressive-enhancement seam for host-injected libraries (Mermaid, Chart.js, KaTeX) — see test/unit/genui.test.js for the hook's contract.
Markdown rendering
A summary widget's text renders as flat escaped text by default. Pass mountWidget(descriptor, target, { markdown: true }) to opt into the allow-listed markdown-to-DOM renderer instead. See GenUI Reference § Markdown rendering for the full opt-in contract and what's sanitized.
Widgets interrupted mid-stream
A widget interrupted mid-stream (a different runtime/speechId arrives before its JSON body finishes writing, e.g. a barge-in) is never mounted as a silently-truncated widget. SegmentAssembler recognizes the cut-off JSON shape, and ExperienceRenderer mounts the same typed fallback it uses for a throwing custom renderer: {kind:'error', data:{runtime, message}}. This is distinguishable from any complete widget's descriptor.
graded-question (host-registered)
graded-question is a prompt with either multiple-choice options or a free-text answer, an optional answer key, and an optional explanation. It is not one of the nine backend runtimes above: there's no Genie brain tool that emits it. Instead, it's a host-registered widget:
import { renderGradedQuestion } from '@kaltura/intelligent-agents/experience/genui';
new ExperienceRenderer({ renderers: { 'graded-question': renderGradedQuestion } });
This is the same "10th runtime" extensibility seam any custom widget uses — see GenUI Reference § Registration, fallback, and provenance. Grading happens client-side in mountWidget. It's a comprehension-check primitive, not a tamper-proof assessment, since the answer key travels in the descriptor itself. The full shape and the onAction('answer', ...) event are in GenUI Reference § 10. graded-question.
Clearing widgets on a new turn
In live mode (.start()), ExperienceRenderer also subscribes to the session's turnStart event (re-emitted from the raw agent_start_speech socket event: {speechId, turnId, isNewTurn}). By default (clearOnTurnStart: true), it discards the assembler's in-flight buffer and clears rendered/last when isNewTurn is true, so a widget from a previous turn never lingers into the next one. This is the same correctness fix Genie's own web client applies by nulling its content on AgentStartSpeechReceived.
A duplicate turn (isNewTurn:false, e.g. a server-side tap-to-talk retrigger for a turnId already in flight) is ignored here, matching every other turnStart/isNewTurn consumer in the SDK. Otherwise the duplicate would wipe an already-rendered widget out from under the viewer mid-turn. Pass clearOnTurnStart: false to keep the previous default behavior of accumulating and persisting widgets across turns.
Presenter
The Presenter helper (./experience/presenter, its own subpath so apps that don't need it never pay for its module graph) manages a deck walkthrough end to end:
- Per-slide Dynamic Prompt (DPP) injection via
session.setDynamicPrompt()— a structured context blob telling the brain what's on screen right now. - Navigation via ONE deterministic, silent, idempotent mechanism:
onToolCall('navigate_to_slide')— no speech-parsing fallback. - Duplicate-nav suppression.
- A sequential resume point (
reason:'resume'). - Session memory ("welcome back").
All of it is pure logic over an injected session/storage, fully unit-testable.
Getters (read-only):
| Getter | Returns |
|---|---|
covered |
Visited slide numbers |
questions |
Questions recorded so far |
lastNav |
{target, reason, at} |
lastDppSlide |
The slide: sub-object last sent in a DPP |
secondsOnCurrentSlide |
Seconds spent on the current slide |
memory |
The current session-memory object |
Methods:
| Method | Purpose |
|---|---|
start() |
Begin the walkthrough |
goTo(n, reason) |
Navigate to slide n |
refreshDpp() |
Resend the current slide's Dynamic Prompt |
saveMemory() |
Persist "welcome back" session memory |
clearMemory() |
Clear session memory |
recordQuestion(text) |
Record a question observed outside ASR (e.g. typed chat) |
appendSlide(slide) |
Grow the deck at runtime (e.g. a brain-driven create_slide command); pushes onto slides, grows total, and returns the new 1-based slide number without navigating |
destroy() (alias stop()) |
Remove every listener this Presenter registered on session, and make every other method above a no-op from then on. Idempotent. Call it before discarding a Presenter whose session stays connected (e.g. swapping decks mid-session) — otherwise the old instance keeps injecting DPPs/navigating/saving memory alongside any replacement, and (in dev) a skipped destroy()/stop() logs a console.warn the moment the replacement is constructed |
App hooks (each exists because a real app needed to extend one specific seam without forking the class):
| Hook | Signature | Purpose |
|---|---|---|
extendDpp |
(slide, ctx) |
Merges app-specific fields into every DPP sent (e.g. an engagement block built from secondsOnCurrentSlide) |
extraMemory / restoreMemory |
(questions) / (memory) |
Write/read pair for persisting app-specific fields alongside Presenter's own "welcome back" session memory, instead of layering a second storage call |
onTurnText |
(text, full) |
Fires with the per-turn accumulated avatar text — the same text Presenter itself uses internally — so an app can drive its own analytics or triggers off it |
onSlideChange |
(n, slide, reason) |
Your renderer hook, called right after the DPP goes out (e.g. to page a PDF viewer to the new slide) |
metaFor |
(category) |
Returns per-category DPP meta flags (disclaimer_required/non_gaap_cited) when your compliance categories differ from the financial/legal default |
dppSlide |
(slide, ctx) |
Full-replace hook for the DPP's slide: sub-object when your slide shape doesn't match the default {title, talking_points, category, content, narrator_guidance} vocabulary (e.g. body/topics/track/level) |
The constructor option oneNavPerTurn: true guards against a brain "restart" firing two different nav targets within the same spoken turn — the second is silently suppressed until the next turn.
The constructor option deckOutline: true adds a full-deck {slide_num, title}[] outline to every DPP as dpp.outline. This is the SDK-native alternative to hand-rolling a topic→slide mapping into BASE_DIRECTIVE, which also goes stale after a runtime appendSlide() since BASE_DIRECTIVE is static. Duplicate titles are disambiguated automatically (the colliding slide's first talking point, or its slide number if it has none). Default is false: no outline key at all unless requested.
See examples/deck-presenter.html for a self-contained runnable demo: construct Presenter right after the session, before connect(), with requireDisclosureAck: true and the extendDpp/extraMemory/restoreMemory hooks in action.
Chroma-key Avatar Compositor
attachChromaKeyAvatar() (./experience/chroma-key, its own subpath so apps that don't composite the avatar never load it) wires a bring-your-own transparent-background compositor, any chroma-key-video-shaped class, directly onto a KalturaAvatarSession's own avatar <video> element. It keeps that compositor's lifecycle in lockstep with the session's. The SDK never bundles, imports, or depends on chroma-key-video (or any keying/matting library) itself. This is glue, the same constructor-injection pattern ./experience/noise-suppressor uses for audioWorkletNodeConstructor:
import { KalturaAvatarSession } from '@kaltura/intelligent-agents/experience';
import { attachChromaKeyAvatar } from '@kaltura/intelligent-agents/experience/chroma-key';
// YOUR dependency, not the SDK's — there is no npm package for chroma-key-video; load it by
// bundling https://github.com/kaltura/chroma-key-video locally, or straight from jsDelivr's
// GitHub-CDN mode, pinned to a released tag:
import { ChromaKeyVideo } from 'https://cdn.jsdelivr.net/gh/kaltura/chroma-key-video@v1.2.0/src/chromakey.js';
const session = new KalturaAvatarSession({ token, …appInit, videoEl, socketFactory });
const player = attachChromaKeyAvatar({
session,
videoEl: session.videoEl, // must be the SAME element the session itself renders into
ChromaKeyVideo,
options: { autoTune: true },
container: document.getElementById('composited'), // omit to skip .mount() entirely
});
await session.connect();
// No extra teardown needed: player.destroy() fires automatically on session 'ended', a
// fatal session 'error', or session.disconnect()/stop() (the normal "hang up" path) —
// calling session.disconnect() alone is enough.
Behavior:
- Construction is synchronous —
attachChromaKeyAvatar()returns the liveChromaKeyVideoinstance immediately, noPromise. videoElmust besession.videoEl— the session's own read-only getter for the element its WHEP downlink actually assignssrcObjectto. Passing a second, different reference throws aKalturaError— this catches a stale/duplicated element before it silently keys the wrong stream.- Returned unwrapped, zero shadow API — the returned
playeris the exact instanceChromaKeyVideoconstructed, with no proxy or wrapping. It's a standardEventTarget: listen onplayerdirectly viaaddEventListenerfor its own events (e.g.chroma-key-video's'started'/'backend'/'error').attachChromaKeyAvatar()never re-emits them ontosession. - Auto-cleanup —
player.destroy()is called exactly once: on the session's'ended'event, any FATAL'error'(capacity_unavailable/tier_exceeded/bad_request/peer_removed/unsupported_client), or the session reaching its'disconnected'state. That last state is whatsession.disconnect()/session.stop()triggers (the human-in-the-loop kill switch, e.g. a "leave call" button); that path never emits'ended'on its own. A transient/recoverable error (e.g. a socket hiccup the session itself reconnects from) does NOT destroy the player. It also checks the player's ownisDestroyedflag first, so an integrator who already calledplayer.destroy()themselves never gets a second call. All three teardown paths are safe to fire together or in any order. - Idempotent, no double-wiring — a second
attachChromaKeyAvatar()call against a session that already has a live compositor logsconsole.warnand returns the EXISTING instance instead of constructing (and WebGL-context-leaking) a second one. Never throws for this. - No reconnect ceremony — a WHEP reconnect reassigns
srcObjecton the SAMEvideoElthe compositor was already constructed against; no re-attachChromaKeyAvatar()call is needed.
Non-goals: this plugin does not reimplement chroma-keying, matting, backend fallback, or WebGL context-loss recovery. That's entirely chroma-key-video's (or your chosen library's) job. If your app keys a URL-sourced clip with chroma-key-video directly, bypassing this plugin entirely, running that URL through safeUrl() first is still your obligation. This plugin never accepts or fetches a URL itself; it only ever touches the session's own live video element.
See examples/chroma-key-avatar.html for a self-contained runnable demo.
Advanced / building-block exports
These are importable from their entry points and useful when composing custom pipelines or renderers outside the high-level helpers.
./management
| Export | Description |
|---|---|
collectConverse(stream) |
Collects a converse() async-iterable into a single assembled result (text, toolCalls, threadId, _meta, etc.). Use it when you need the full turn result without converseOnce. Dedupes tool calls semantically, by name + canonicalJson(args), matching the live session's dispatch dedup so a non-deterministic JSON key order on an LLM retry doesn't defeat it. Also caps spiraling tool calls (spiralStopped) and total segments (truncated). Does not itself recover from an empty spiral — see conversations.send({recoverFromSpiral:true})/converseOnce above. |
SPIRAL_RECOVERY_PREFIX |
The exact nudge text ('Please answer in words only this turn, without calling any tool. ') that conversations.send({recoverFromSpiral:true}) prepends on its one headless recovery retry, and that KalturaAvatarSession's recoverFromSpiral (default true) prepends on its one live-session auto-resend after a hard-spiral cold reconnect. Exported from core/stream.js (re-exported from management/conversations.js for back-compat) so callers can detect/strip it if they inspect raw thread history. |
canonicalJson(value) |
Deterministic JSON serialization with object keys sorted at every nesting level (arrays keep order). The key shape both collectConverse and the live session's onToolCall dispatch use to dedup semantically-identical tool calls whose JSON key order the LLM emitted non-deterministically. |
parseConverseStream(readable) |
Low-level NDJSON/SSE line parser. Turns a raw fetch ReadableStream into typed Segment objects — the foundation converse() builds on. |
redact(value) |
Scrubs KS tokens, secrets, and PII from any string or object before logging. Used internally by the audit stream. |
uuidv4() |
Cryptographically random UUID v4 (uses crypto.randomUUID when available, pure-JS fallback). |
randId(prefix?) |
Short collision-resistant ID with an optional prefix — used for idempotency keys and _meta receipts. |
parseCsv(text) |
Zero-dep CSV parser (RFC 4180). Used by the tools.api CSV response path. |
summarizeReport(rows, opts) |
Aggregates raw reporting rows into a { _meta, totals, byAgent, byThread } summary. |
lintPrompts(prompts) / validatePromptVars(text, vars) / lintGlossary(glossary) / assembleSystemPrompt(parts) |
The prompt-authoring toolchain (management/prompt-lint.js): lint a prompt set for the SYS_VARS an intellect actually supplies, validate a template's {{var}} references against a known var set, lint a glossary for duplicate/conflicting terms, and assemble a final system prompt from ordered parts. Use these to catch a broken prompt (an unresolvable {{var}}, a name collision) before it ships, not after a live conversation surfaces it. |
lintPersonaIdentity({name?, openingPhrase?, baseDirective?, prompts?}) |
Warns when a persona rename didn't fully propagate. persona_name_drift fires whenever a declared name (or an openingPhrase-derived name that differs from it) is missing from base_directive/prompts[]. It doesn't need openingPhrase at all, so it also catches intellects that only declare name and skip openingPhrase entirely. persona_name_mismatch still needs an openingPhrase that parses to a name different from the declared name. Returns {ok, summary, findings, detectedName, _meta} — warning-only, never throws. mgmt.provision() runs this automatically and returns the result as personaLint (see above). Call it directly to re-check an intellect you're editing outside of provision(). |
resolveCapabilities(layers) / CAPABILITY_STATE / CAPABILITY_INFO |
management/capabilities.js's typed capability resolver. It merges the env/partnerConfig/request layers for each entry in CAPABILITIES down to one resolved CAPABILITY_STATE (on/off/disabled) plus a resolvedFrom provenance tag. This lets a caller build an accurate "what can this agent do" view without re-deriving precedence from raw config fields. CAPABILITY_INFO carries the human-readable name/description per capability. |
findIntellectsReferencingTool(mgmt, toolId, ks) |
Lists every intellect's configId that currently references toolId in its tool_ids. This is the reuse-safety check mgmt.tools.delete runs by default before deleting a partner-level Tool. Call it yourself to preview what a delete would break, or to build the same shared-by-name guard around your own upsert-by-name logic (mgmt.skills's delete runs the analogous findIntellectsReferencingSkill check internally). |
./experience
| Export | Description |
|---|---|
TranscriptTracker |
Assembles per-speechId caption segments into a running transcript, respecting barge-in invalidation. Useful when building a custom captions UI outside KalturaAvatarSession. |
CaptionService |
Higher-level caption engine built on TranscriptTracker — call onCaption(({text, clear}) => ...) to register a render callback; fires with the next visible segment or {text:'', clear:true} to hide captions. |
parseToolCall(segment) |
Extracts a { name, args, raw } tool-call from a raw type:'tool' segment. Handles both JSON and stringified-JSON argument encodings; on a fused multi-tool segment (see above), args is the named tool's own (last) blob and any earlier blobs ride along as fusedArgs. |
parseToolResponseName(segment) |
Extracts the tool name echoed by a type:'tool_response' segment ("<name> responded with size <n>"), or null. The attribution signal for pairing a fused fusedArgs blob with its real tool. |
segmentKind(segment) |
Returns one of 'spoken', 'control', 'experience', 'error' for any segment object — normalizes the wire variance between HTTP converse and socket paths. |
apportion(text, maxLen) |
Splits a long text string into caption-display-safe chunks of at most maxLen characters, breaking on word boundaries. |
Emitter |
Minimal on/off/emit event emitter (zero deps). The base class for KalturaAvatarSession — extend it when building custom session wrappers. |
safeText(s) |
Returns an HTML-escaped string safe for text node injection. |
safeUrl(url) |
Validates and returns a URL string; returns '' for javascript: and other unsafe schemes. |
renderSafeLink(href, label) |
Returns a safe <a> tag string using safeUrl + safeText — use in custom widget renderers that need to emit links without innerHTML risk. |
sanitizeJson(obj) |
Deep-clones a plain object, stripping non-serializable values and keys that start with __. Safe to pass to JSON.stringify for logging. |
clampInbound(value, min, max) |
Numeric clamp — used to bound untrusted inbound numeric fields (e.g. widget dimensions) before rendering. |
./experience/presenter
| Export | Description |
|---|---|
Presenter |
Deck-walkthrough plugin — see the Presenter section above. Its own subpath so apps that don't present a deck never load it. |
parseSlideNumber |
Parses a slide_num tool-call argument (number, numeric string, or ordinal word like "next"/"third") against a known slide total. |
./experience/genui
| Export | Description |
|---|---|
ExperienceRenderer |
2-line happy-path renderer for all first-class GenUI widgets — see the GenUI section above. |
mountWidget(descriptor, target, opts) |
Zero-dep, never-innerHTML, accessible single-widget renderer with an onMount progressive-enhancement seam. |
parseWidget(segment) / normalizeRuntime / RUNTIMES / GENUI_WIDGET_NAME |
Wire-shape parsing helpers for building a custom GenUI renderer. |
DEFAULT_RENDERERS / WIDGET_KINDS |
The default per-kind renderer map and the frozen list of kinds it dispatches on. |
SegmentAssembler |
Collects typed stream segments from the live socket into the same assembled shape as collectConverse — use when replaying socket captures or building a custom turn handler. onMalformed({runtime, runtimeName, speechId, reason, message}) fires instead of onWidget when a fragment sequence is interrupted (reason:'boundary') before its JSON body finishes — a natural end-of-turn ('turnEnd') or stop() flush is never flagged malformed. |
renderGradedQuestion |
Renderer for the graded-question comprehension-check widget — NOT in DEFAULT_RENDERERS/WIDGET_KINDS (there's no backend runtime for it). Register it yourself: new ExperienceRenderer({ renderers: { 'graded-question': renderGradedQuestion } }). See GenUI above and GenUI Reference § 10. |
./experience/noise-suppressor
| Export | Description |
|---|---|
createNoiseSuppressor(opts) |
Builds a cfg.noiseProcessor-conforming function backed by a pure-browser-native AudioWorkletProcessor noise gate — see Noise suppression above. Its own subpath so apps that don't opt into Tier-2 DSP never load it. |
./experience/chroma-key
| Export | Description |
|---|---|
attachChromaKeyAvatar(cfg) |
Wires a bring-your-own chroma-key-video-shaped compositor onto a session's own avatar video — see the Chroma-key Avatar Compositor section above. Its own subpath so apps that don't composite the avatar never load it. |
./experience/analytics
| Export | Description |
|---|---|
KavaAnalytics |
Fire-and-forget KAVA reporter — see KAVA analytics above. Its own subpath so apps that don't report analytics never load it. |
buildPageLoadParams(common, fields) / buildButtonClickedParams(common, fields) |
Pure param-builders for the two valid client-side event types, used internally by KavaAnalytics and importable directly for a custom transport. |
EVENT_TYPES |
{pageLoad:10003, buttonClicked:10002} — the only two valid client-side codes. |
PAGE_TYPES |
The closed enum pageLoad's pageType field is validated against. |
HOSTING_APPLICATIONS |
hostingKalturaApplication values by name: genieChat, agents, modelsSdk, conversationManager, avatarVideos, agenticAvatarsStudio, kaiVendor. |
DEFAULT_ANALYTICS_URL |
The KAVA ingestion endpoint (https://analytics.kaltura.com/api_v3/index.php). |
Testing
npm test # all layers, offline
npm run test:unit # builders, parsers, errors, redaction
npm run test:integration # provision flow, token mint, converse (fake fetch)
npm run test:e2e # full connect machine (fake socket + fake RTCPeerConnection)
npm run test:evals # SDK event model vs. golden captured session
Fakes live in test/fakes/ — socket.js, rtc.js, fetch.js. Inject them in your own tests:
import { FakeSocket } from '@kaltura/intelligent-agents/test/fakes/socket.js';
const session = new KalturaAvatarSession({ …, socketFactory: () => new FakeSocket() });
For live-backend Playwright e2e (real agent + WebRTC connect), boot a real session against KalturaAvatarSession with no socketFactory/fake transport override, then drive it the same way — connect(), wait for the ready state, assert on the resulting DOM/state.
Intellect configuration
The intellectConfig facade wraps the read-merge-write cycle:
// one call — reads current config, overlays your patch, writes
await mgmt.intellectConfig.patch(configId, { base_directive: 'Be concise.' }, ks);
// typed setters
await mgmt.intellects.setCapability(configId, 'use_knowledge_base', 'on', ks);
const { id: toolId } = await mgmt.tools.add(myTool, ks); // tools are a separate, partner-level entity
await mgmt.intellectConfig.setToolIds(configId, [toolId], ks); // then link it
await mgmt.intellects.secrets.set(configId, { API_KEY: value }, ks); // write-only
await mgmt.intellectConfig.setKnowledgeIds(configId, [knowledgeId], ks); // ungated
await mgmt.intellectConfig.setMcpServers(configId, { docs: { url: 'https://mcp.example.com/sse' } }, ks); // ungated
setMcpServers writes the intellect's mcp_servers map ({"<name>": {url}}; pass {} to clear). The backend normalizes on read: each entry comes back expanded with type:'mcp', transport:'streamable_http', and null header/allow-list fields. Because of this, never diff your input against a subsequent get byte-for-byte.
intellectConfig.describe(configId, ks) returns every editable field partitioned into editable + readOnly — wire directly to a settings UI.
Brain-model and rate-limit fields have no public write door: agent_llm/agent_fast_llm/agent_avatar_llm/rate limits/run_quota_check/web_search_config are set by internal tooling only. No public route reads or writes them; describe() surfaces their current values read-only, informationally. Grounding a new agent via knowledge_ids is fully ungated. Event-driven session/thread rules are supported — see Lifecycle Rules.
Skills, voice import, and the embed snippet
Skills (mgmt.skills) are standalone, partner-level reusable instruction entities on Genie (v1/skill/*) — {id (uuid), name, description, instructions}. Full lifecycle, including update:
const skill = await mgmt.skills.add({ name: 'greeter', description: 'Greets warmly.', instructions: 'Always say hi.' }, ks);
// mgmt.skills.create(...) is an alias for .add() — same call, same result
const page = await mgmt.skills.list(ks); // async-iterable + awaitable first page
const one = await mgmt.skills.get(skill.id, ks);
await mgmt.skills.update(skill.id, { instructions: 'Always say hi, in one short sentence.' }, ks); // idempotent; renaming re-checks the unique-name constraint (409 on conflict)
await mgmt.skills.delete(skill.id, ks, { confirmPermanent: true });
name is checked against your partner id OR partner 0 (a shared global pool), so a name can collide with a global-pool Skill in ways invisible from a partner-scoped list() — the same nuance applies to Tools.
Before deleting, mgmt.skills.delete lists every intellect and refuses with a typed skill_in_use error naming each one still referencing the id in skill_ids, unless called with {confirmPermanent:true, force:true}. Tools' mgmt.tools.delete carries the identical tool_in_use guard.
Attach a Skill to an intellect via intellectConfig.setSkillIds — the intellect only holds a reference list ({id, mode} pairs), the skill body itself lives in mgmt.skills. mode is 'preloaded' (instructions go in the system prompt every turn) or 'adhoc' (the brain pulls it in only when relevant) — see the exported SKILL_MODES:
await mgmt.intellectConfig.setSkillIds(configId, [{ id: skill.id, mode: 'adhoc' }], ks);
// pass [] to detach every skill
Provider voice import (mgmt.catalog) creates a catalog Voice item directly from an ElevenLabs or Cartesia voice id — no audio upload:
const v = await mgmt.catalog.importVoiceFromElevenLabs('EXAVITQu4vr4xnSDxMaL', ks);
// or: await mgmt.catalog.importVoiceFromCartesia('<cartesia-voice-id>', ks);
An unknown provider id creates nothing and raises a typed voice_not_found_elevenlabs / voice_not_found_cartesia error (the backend replies an HTTP-200 exception envelope; the SDK maps it).
Custom avatar face (mgmt.catalog, mgmt.avatars) — self-serve, three ways:
- A ready-made Visual. Upload a full portrait via
catalog.createVisual, then pass the returned id asvisual:{id}(oritemIdasvisualIdinprovision). - Compose one from parts.
catalog.createFace/catalog.createBackgroundeach return a half. Then callavatars.create({face:{id}, background:{type:'color'|'visual', value?}, voice, ...}, ks)—faceandbackgroundare both required together at create time. - Start from a curated template. Pick a
templateIdfromcatalog.listTemplates()and override just the parts you want.
avatars.update() also accepts background alone, to swap only the background against the avatar's current face. The model animates the composed result at runtime. Video-clip ingest is not available through this API.
Embed snippet (mgmt.agents.getEmbedScript(agentId, embedType, ks)) returns the ready-to-paste HTML <script type='module'> that renders the agent's chat widget on any page. embedType is one of contained (inline box), page (full page), or floater (floating launcher) — validated against the exported EMBED_TYPES before any network call.
Scripted-Video (STV-only) Sessions
A second, independent backend (avatar-session/*) powers a brain-free avatar: no LLM, no ASR, no socket.io. You drive it entirely from your own server by handing it pre-rendered speech audio. Use it when you already have the text (and optionally the TTS audio) and just need a talking-head video, e.g. reading back a scripted announcement or a pre-approved script.
import { Management } from '@kaltura/intelligent-agents/management';
const mgmt = new Management({ partnerId, adminSecret });
const admin = await mgmt.sessions.createAdminToken();
const session = await mgmt.avatarSessions.create({ visualConfig: { id: avatarId } }, admin.ks);
const { whepUrl, turn } = await mgmt.avatarSessions.initClient(session);
// hand { whepUrl, turn } to the browser — non-secret, safe to send over your own API
await mgmt.avatarSessions.say(session, audioBytes, { duration: durationSeconds });
// duration is required — the server has no duration probe of its own; measure your own audio
await mgmt.avatarSessions.end(session);
import { KalturaScriptedVideoSession } from '@kaltura/intelligent-agents/experience';
const view = new KalturaScriptedVideoSession({ whepUrl, turn, videoEl: document.querySelector('video') });
await view.connect(); // negotiates WHEP, resolves once the stream is playable
// ...later
view.disconnect();
create authenticates with your own admin KS (mgmt.sessions.createAdminToken()). Every call after it (initClient/say/interrupt/keepAlive/end) authenticates with the session's own Bearer token instead. create()'s return value is a receipt ({sessionId, token, isExpired(), secondsRemaining()}); pass it straight to the other methods rather than re-deriving a KS.
say-audio (wrapped as say()) is the only speech-injection mechanism this backend exposes. There is no verbatim text-to-speech endpoint on it: say-text 503s on the live deployment, and set-emotion/queue-status/status don't exist. See the full auth/lifecycle table on GitHub, and examples/scripted-video-session.mjs + .html in the SDK repo for a complete runnable server+browser pair, including a stand-in for your real TTS call.
RAG (knowledge base)
const rec = await mgmt.knowledge.addRecord({ name: 'Product Docs' }, ks);
// mgmt.knowledge.createRecord(...) is an alias for .addRecord() — same call, same result
const { configId } = await mgmt.intellects.create({
knowledge_ids: [rec.id],
capabilities: { use_knowledge_base: 'on' },
}, ks);
// isIndexed() reports the record's own lifecycle status (ready:true immediately,
// before any entry has indexed) — it is NOT an indexing-completion check, see below.
const status = await mgmt.knowledge.isIndexed(rec.id, ks);
Content modalities indexed: captions, OCR, document attachments.
Don't use these as an indexing-status signal — see API Reference § Ground the Agent for why:
knowledge.isIndexed()'sreadyflagknowledge.search()'s "couldn't find relevant information" replyknowledge.corpusStatus()'spopulatedflag
Use knowledge.entryStatus() instead: it's the official per-entry completion check.
Knowledge records have full lifecycle CRUD:
const got = await mgmt.knowledge.getRecord(rec.id, ks); // read one
await mgmt.knowledge.updateRecord(rec.id, { name: 'Docs v2' }, ks); // rename/edit
await mgmt.knowledge.deleteRecord(rec.id, ks, { confirmPermanent: true });
deleteRecord does NOT unlink the record from intellects that reference it — an intellect's knowledge_ids keeps the dangling id. Clear it yourself (intellectConfig.setKnowledgeIds(configId, [], ks)) when retiring a record. A deleted or unknown record id → typed not_found; another partner's → forbidden.
Threads
Mounted at mgmt.threads. Every method needs an admin KS.
// list an agent's threads, newest first
const threads = await mgmt.threads.list(admin.ks, {
filter: { agentIdEquals: agentId, orderBy: '-createdAt' },
});
// patch or clear a thread's analysis
await mgmt.threads.setAnalysis(threadId, { priority: 'high' }, admin.ks);
await mgmt.threads.clearAnalysis(threadId, admin.ks);
// inject a message into a thread from your own backend
await mgmt.threads.push({ id: threadId, content: 'Order #4821 just shipped.' }, admin.ks);
| Method | What it does |
|---|---|
threads.list(ks, opts) |
List threads. opts.filter: agentIdEquals, statusEquals/statusIn, createdAtGreaterThanOrEqual/LessThanOrEqual, idEquals/idsIn, userIdEquals, orderBy (+/- createdAt/updatedAt, goes inside filter) |
threads.get(id, ks) / threads.transcript(id, ks) |
Fetch a thread, or its flattened human:/ai: transcript |
threads.rename(id, title, ks) |
Rename a thread |
threads.setAnalysis(id, patch, ks) / threads.clearAnalysis(id, ks) |
Shallow-merge into (or wipe) thread_metadata.analysis. A key that actually changes fires the analysis_updated event Lifecycle Rules react to |
threads.push({id, content, request_vars?, system_message?}, ks) |
Inject an external message into a thread from your own backend. threads.push exists and does not fail for a missing live socket — delivered:false in the reply just means no live socket was attached right now; the message still persists on the thread either way |
threads.delete(threadIds, ks, confirm) |
Batch-delete threads by id. The GDPR/CCPA deletion path for conversation PII |
agentIdEquals (on threads.list and feedback.list) only matches threads opened with sessions.createAgentToken({agentId}). A plain sessions.createConversationToken({configId}) thread's agent_id is "default". So an agentIdEquals filter set to a real agent id excludes that thread — it never matches it by default.
Messages
Mounted at mgmt.messages. Every method needs an admin KS.
| Method | What it does |
|---|---|
messages.list(ks, opts) |
List messages, optionally scoped with opts.threadId (sugar for filter.threadIdEquals) |
messages.get(id, ks) / messages.share(id, newTitle, ks) |
Fetch one message, or clone it under a new title for sharing |
messages.report(ks, opts) / messages.reportSummary(ks, opts) |
Raw partner conversation CSV, or a parsed {totals, byAgent, byThread} summary |
messages.report/messages.reportSummary return end-user ids, names, and verbatim question/feedback text. Treat as PII, scope with a filter, and redact before sharing outside your team.
Feedback
Mounted at mgmt.feedback. feedback.add accepts any KS (meant to be callable with the end user's own conversation token); feedback.list needs an admin KS.
// rate a message (any KS)
await mgmt.feedback.add({ message_id: messageId, is_positive: true }, ks);
| Method | What it does |
|---|---|
feedback.add({message_id, is_positive, comment?}, ks) |
Rate a message. Any KS, idempotent per (message_id, is_positive) |
feedback.list(ks, opts) |
Read ratings back. opts.filter: messageIdEquals/messageIdsIn, threadIdEquals, agentIdEquals, isPositiveEquals |
feedback.list's ratings include the message text and end-user id — treat as PII, same as messages.report.
Follow-ups
Mounted at mgmt.followups. followups.getSuggested accepts any KS (meant to be callable with the end user's own conversation token); followups.list needs an admin KS.
// starter questions for the current agent
const suggestions = await mgmt.followups.getSuggested(ks);
| Method | What it does |
|---|---|
followups.getSuggested(ks) |
Starter questions for the current partner/agent. Any KS |
followups.list(ks, opts) |
Raw partner-wide follow-up question records |
Related docs
| Doc | What it adds |
|---|---|
| Getting Started | First working agent in about five minutes |
| Backend API Reference | The raw HTTP endpoints behind every SDK method here |
| Platform Overview | How ./management and ./experience fit into the backend services and runtime protocol as a whole |
| System Internals Reference | The module-by-module data-flow map and failure-mode tables behind this page's SDK surface |
| Wire Protocol | The exact socket/WebRTC wire shapes KalturaAvatarSession speaks |
| Security | The control matrix and KS-handling guidance behind this SDK's auth model |