Reference

Architecture Reference

The exact field-by-field mechanics behind ARCHITECTURE.md — the connect sequence, wire shapes, scaling internals, SDK module routing, and failure-mode tables. Read ARCHITECTURE.md first for the big picture; consult this doc for an exact field, timeout, or module boundary. For a from-scratch reimplementation walkthrough, see ARCHITECTURE-RECIPE.md. For the exhaustive socket-event-by-event capture, see WIRE-PROTOCOL.md.

Contents


Endpoints & Credentials

Thing Value
Control socket wss://conversation.avatar.us.kaltura.ai path /socket.io
STV WHEP base https://srs.avatar.us.kaltura.ai
STV play URL {srsBaseUrl}/rtc/v1/play/?app=app&stream={session_id} (or webrtc_url from stvNewSession)
STV WHEP signaling POST {srsBaseUrl}/rtc/v1/whep/?app=app&stream={session_id} (body: plain SDP, Content-Type: application/sdp)
TURN turn.avatar.us.kaltura.ai (default username/credential in wire.js's turnServers(), overridable via creds). Address it with explicit ports + transports — a bare turn:host yields no relay candidate (→ packetsSent=0, the avatar can't hear you). Use all four: turn:HOST:80?transport=udp, turn:HOST:443?transport=udp, turn:HOST:80?transport=tcp, turns:HOST:443?transport=tcp. iceTransportPolicy resolves as forceRelay && !isFirefox ? 'relay' : 'all' per leg (in the WebRTC avatar engine's session client). STV → 'relay' in every client; ASR is 'relay' in the production runtime (forceAsrRelay:true) but 'all' in the embed SDK / debug-app — functionally identical, because the ASR server advertises only a private host candidate so the pair relays through TURN regardless. Firefox forces 'all' on both. So the TURN URLs are what must be correct, not the policy. Full per-client matrix + source lines: WIRE-PROTOCOL.md §5.
Auth Socket.IO auth: { token: <enrichedKS> } + query.partnerId

All of conversationManagerUrl, srsBaseUrl, turnServerUrl, and the enriched ks come from POST https://api.avatar.us.kaltura.ai/v1/application/appInit (see API-REFERENCE.md). The agent is identified by partnerId (from the KS) + the KS itself — NOT clientId/flowId (those belong to a separate, unrelated demo integration and play no role in this system).


Socket.IO Connection

import { io } from 'socket.io-client';

const socket = io(conversationManagerUrl, {   // from appInit
  path: '/socket.io',
  transports: ['websocket'],
  auth: { token: enrichedKs },                // from appInit
  query: {
    partnerId: '<your_partner_id>',           // derived from the KS
    clientId: undefined,                       // optional; unused for Kaltura agents
    flowId: undefined,                         // optional; unused for Kaltura agents
    billed_client: '',
    stickyId: '<random-16>',
    level: 'published',
    debugMode: true
  }
});

Full Connect Sequence (state-machine order)

Exact order from the avatar runtime client's connection state machine. Each step waits for the named inbound event before advancing; timeouts in parens.

# Client does Emits (→) / Waits (←) Inbound event Timeout
0 Init WebRTC session (TURN config) + getUserMedia(audio:true,video:false) (browser mic prompt)
1 Open socket onServerConnected {finalUrl, loadingVideoURL, agentName, hostName} 10s
2 Join room join (see payload below)
3 Wait config + join ack clientConfiguration, ← joinComplete both required 5s
4 Create STV session stvNewSession {room_id, cast_mode}
5 Wait session stvNewSession {session_id, status, webrtc_url?} (or ← throwToNoAgent) sets sessionId + webrtcUrl
6 Wait agent showAgent {} agent joined 10s
7 Wait ready askPermissions {constraints:{audio,video}} server ready
8 (optional) wait player-ready, 1s delay
9 Connect ASR (mic uplink) asr-webrtc-* handshake (below) 30s
10 Subscribe STV video (WHEP) and wait until it is playable → WHEP POST → wait <video> canplay + ~300ms settle first decoded frame 5s
11 Approve — this is what starts the spoken greeting approvedPermissions {client, room}
CONNECTED listen for agent_raw_text, generatingSpeech, stvStartedTalking

Overall connecting timeout: 30s.

Ordering matters — approvedPermissions triggers the opening line. Subscribe to the STV video and wait until it is actually decoding frames (<video> canplay, readyState ≥ HAVE_FUTURE_DATA, plus a short jitter-buffer settle) before emitting approvedPermissions. ICE connected fires ~2s before the first frame decodes — approving on ICE alone means the first 1–2s of the greeting is spoken into a pipe the user can't see/hear yet and is clipped. The reference client gates approval on both mic-ready AND video-ready (the WebRTC avatar engine's permission-approval check); the SDK reproduces this in src/experience/session.js (_approve, gated on the same canplay/HAVE_FUTURE_DATA settle logic).


The join payload (step 2) — this carries the agent/brain config

socket.emit('join', {
  client: clientId,            // optional
  room: roomId,                // a client-generated room id (also sent as 'channel')
  channel: roomId,
  kaltura: {
    entryId: <entryId>,            // only if context is a media entry
    context_id: <contextId>,       // category/entry the KB is scoped to
    threadId: <existingThreadId>,  // to resume a conversation thread
    force_experience: 'avatar_only',
    capabilities: {                // Genie capabilities — same enum as intellect config
      avatar: 'on',
      generate_followup_questions: 'on',
      use_knowledge_base: 'off',   // forced off when an entryId is set
      // use_content_search, include_sources, etc.
    }
  },
  userAgent, userAgentHints, isMobile,
  channel_password: null, peer_name: 'unknown',
  peer_video: false, peer_audio: true
});

A WebRTC peer connection whose SDP/ICE are relayed through the socket (NOT WHEP). From the avatar runtime client's ASR connection handler:

// 1. tell server to prepare
socket.emit('asr-webrtc-init', { sessionId: peerId });
// 2. wait
socket.once('asr-webrtc-ready', ...);          // (or 'asr-webrtc-error')   timeout 30s
// 3. create RTCPeerConnection with the mic track, generate offer, then:
socket.emit('asr-webrtc-offer', { offer, is_reconnect: false });
socket.once('asr-webrtc-answer', ({ answer }) => pc.setRemoteDescription(answer));  // 30s
// 4. trickle ICE both ways
socket.emit('asr-webrtc-ice-candidate', { candidate });
// (server may push its own candidates on the same event name)

PeerConnection config: TURN turn.avatar.us.kaltura.ai (default username/credential from wire.js's turnServers(), four explicit port/transport URLs — see Endpoints table above), iceTransportPolicy per the leg's forceRelay flag (production runtime forces 'relay' for ASR; the no-SDK debug-app uses 'all' — both relay in practice since the server only offers a private candidate), audio constraints {echoCancellation, autoGainControl, noiseReduction}, no video. Once connected, the server transcribes your speech and routes it to the brain automatically — there is no separate "send transcript" call.


Standard WHEP — completely independent of the socket. From the avatar runtime client's signaling adapter:

const playUrl = stvNewSession.webrtc_url
  ?? `${srsBaseUrl}/rtc/v1/play/?app=app&stream=${session_id}`;

// create a recv-only RTCPeerConnection, addTransceiver('video'|'audio', {direction:'recvonly'})
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

const answerSdp = await fetch(`${srsBaseUrl}/rtc/v1/whep/?app=app&stream=${session_id}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/sdp' },
  body: offer.sdp                       // plain SDP text, NOT JSON
}).then(r => r.text());                 // answer is plain SDP text

await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
pc.ontrack = (e) => { videoEl.srcObject = e.streams[0]; };  // the avatar video

That's it — a vanilla WHEP subscribe. The avatar's face+voice stream into your <video>.


Conversation Phase — What Streams While Connected

Three parallel listeners (the avatar runtime client's connected-state handler):

1. Brain output — agent_raw_text (the intelligence)

The server streams the brain's response as deltas. Envelope (agentRawTextSchema):

socket.on('agent_raw_text', ({ speechId, turnId, delta }) => {
  const d = JSON.parse(delta);   // delta is a JSON string (agentRawTextDeltaSchema):
  // { messageId, threadId?, role?, type?, content?, segmentNumber?,
  //   segmentStart?, segmentEnd?, et?, metadata?, event?, status? }
});

type values: think, text, unisphere-tool, tool, tool_response, avatar, error, share, thread — the same set as /assistant/converse (the live runtime wraps the same brain stream). The first agent_raw_text on the live socket additionally carries an init_response delta (openingPhrase/threadId/messageId) — that one is a WebSocket-only frame from the Genie brain backend's websocket handler, not an HTTP-converse segment. The type is the LLM's code-fence tag (open-ended) for content blocks, plus the fixed control types think/tool/tool_response/error; see WIRE-PROTOCOL.md §4e.

This is the same brain and same stream format as the text-only Genie /assistant/converse API — the avatar runtime just delivers it over the socket instead of HTTP.

2. Talking state — for UI/turn-taking

socket.on('stvStartedTalking',  ()           => {/* avatar began speaking */});
socket.on('stvFinishedTalking', ({agentContent}) => {/* done; final text */});
socket.on('agent_start_speech', ({speechId, isNewTurn, turnId}) => {/* speech boundary */});

3. Lifecycle

socket.on('conversationEnded', () => {/* server ended it → teardown */});
socket.on('conversationTimeWarning', ({remainingTime}) => {/* seconds left */});

Sending User Input

Two ways the user drives the conversation:

  1. Voice (primary) — just speak. The ASR channel publishes mic audio; the server transcribes and feeds the brain. No client call needed.

  2. Text injection — drive the live avatar by text instead of voice. This is a socket event (the same channel ASR transcripts use), NOT an /assistant/converse HTTP call — HTTP converse is a separate stateless chat that never reaches the avatar's speech engine, so the avatar stays silent. Verified working via the SDK's own session.speak() (src/experience/session.js):

    // the isSpeechStart marker interrupts a mid-sentence avatar (no-op if idle) — issue #39
    socket.emit('debug_text_entered', { text: '', isFinal: false, isSpeechStart: true });
    socket.emit('debug_text_entered', { text, isFinal: true });   // captured client emit name
    

    The server handler is onTextEntered (the conversation-manager's text-injection handler), which reads only { text, isFinal, isSpeechStart? } and routes the text to the same pipeline as ASR transcripts (vadSpeechDetected), keyed by the socket's own room (room: socket.id). It does not read room_id/session_id — those appear in captures but are ignored server-side. (The avatar runtime client's own text-entry emitter sends only {text,isFinal} and its TODO says "this event does nothing," but the live capture confirms the injected text is spoken.) For purely typed chat (no avatar), the production chat UI instead calls Genie /assistant/converse directly with the geniegpcid KS. See WIRE-PROTOCOL.md §4a.


Complete Message Catalog

The exhaustive, field-by-field event catalog — every client emit and server event with its captured payload, source cite, and subscriber — lives in WIRE-PROTOCOL.md §4 (§4a client→server, §4b–§4d server→client, §4e the parsed agent_raw_text.delta types). The connect-sequence steps above name the key events in order; that doc is the reference for each one's exact shape.


Scale & Sticky Sessions

The conversation manager is a horizontally-scaled pool of pods behind a load balancer, with a fixed number of concurrent avatar "agent slots" per pod. Three mechanisms make this work: sticky routing, a capacity queue, and shared cross-pod state.

Sticky routing — stickyId

The single most important scaling detail. A live avatar session is stateful and pinned to one pod (it owns the ASR peer connection, the speech pipeline, and the brain conversation). Socket.IO starts on HTTP long-polling and only later upgrades to WebSocket — those initial polling requests must all reach the same pod, or the handshake breaks.

The STV video channel does not need stickiness — it's stateless WHEP (srs.avatar.us.kaltura.ai), scaled independently and frontable by CDN/anycast.

Capacity & the queue (throwToNoAgent / throwToExceededTier)

Each pod has a bounded number of agent slots (the face-renderer + brain pipeline is expensive). Two distinct "full" signals:

Signal Meaning Client behavior
throwToNoAgent All agent slots currently busy (transient) Enter availability queue (poll until a slot frees)
throwToExceededTier Account plan/tier limit hit (hard) Fail immediately — TIER_EXCEEDED, not recoverable

The queue (transient capacity):

Session validity is checked separately via isValidSessionvalidSession / throwToExceededTier / throwToBadRequest.

Connection recovery vs. session recovery

Cross-pod shared state (data plane)

Pods are stateless-enough to scale because shared state lives in managed backing services (provisioned via the avatar infrastructure module):

Service Role in scaling
Valkey/Redis (avatar-cm-cache, resource-manager-avatar, front-proxy, cnc, cnc-polls) Conversation-manager cache, resource/slot accounting, front-proxy routing state, command-and-control — cluster-mode, multi-node-group, replicated
SQS (+ DLQ) Async work between renderer / brain / pipeline stages; 30s visibility timeout, 24h retention
DynamoDB Durable session/agent registry & coordination
STV renderer + media server Video origin — renders the face server-side and egresses it to clients via WHEP. Scaled independently of the control plane
CloudFront + WAF Edge for the public surface; the WAF enforces origin/CDN-header validation on public API endpoints

So "agent availability" isn't per-pod guesswork — slot accounting is centralized in Redis/Valkey, which is what checkAvailability consults. Concretely (the conversation-manager's agent-availability service), a slot is available when STV has free capacity (unless the call is speech-only) AND Whisper/ASR is available AND activeCalls < maxCalls; maxCalls comes from the CALL_CAPACITY env via the conversation-manager's call-capacity config (default 20 in prod / 12 in non-prod). availabilityResult.details surfaces exactly these: {stvAvailable, whisperAvailable, activeCalls, maxCalls, capacityAvailable}. The brain conversation/thread state is also externalized (the same thread is resumable via threadId regardless of which pod handles a later turn over the text API).

For what a custom (no-Kaltura-lib) client must implement to work correctly with this scaling model, see ARCHITECTURE-RECIPE.md's "Implications for a Custom Client".


SDK Module Map & Data Flow

This section is the source-of-truth map of the SDK's internals: how a call flows from a typed method to the right backend, and the routing/data-flow rules README doesn't cover.

Two entry points, one shared core

Branch security on the minted Token, never on inspectKs(realKs).kind. The public inspectKs export (@kaltura/intelligent-agents/management, src/management/ks-inspect.js) decodes only a KSv2 token's plaintext header: it reliably returns {partnerId}, but a real encrypted KS's privileges are AES-encrypted, so it returns kind:'opaque', disableEntitlement:null, encrypted:true. kind/disableEntitlement are populated only for unencrypted test tokens. To decide what a token may do, read the .kind of the minted Token object (it records what it was minted with: admin/conversation/agent/widget), not inspectKs of an opaque production KS.

Management modules (what each does, where it writes)

Module (src/management/) Exposes Backend the writes hit
intellects.js Intellects — DTO CRUD (add/get/update/delete), addExternal/listExternal/listInternal, prompt authoring (setPrompts/previewPrompt/snapshot/restore/diffSnapshots), capabilities (getCapabilities/setCapability/setCapabilities/resolveCapabilities), setClientVariablesEnabled, brain config (setBrainConfig/getBrainConfig/brainConfigAvailable), buildBrainConfigPatch. Mounts secrets (tools are a separate top-level resource — see tools.js). Genie v1/intellect/* for DTO fields; Genie partner-config/update/get for brain config (gated)
intellect-config.js IntellectConfig (mgmt.intellectConfig) — the ONE shared patch(configId, patch|fn, ks) primitive + typed field setters incl. setToolIds (the intellect-side tool_ids reference list) + describe() (an editable/readOnly map). buildUserPropertiesForms. Genie v1/intellect/update (read-modify-write, full-replace dicts; tool_ids is a plain array write)
capabilities.js CAPABILITIES/CAPABILITY_STATE/CAPABILITY_DEFAULTS/CAPABILITY_INFO, assertCapability/validateCapabilities, resolveCapabilities (pure layered resolver), mergeCapabilityWrite. Re-exported from BOTH entry points. pure — no network
tools.js tools.api/csv/code builders + tools.client (authors a native, silent client-side command tool with NO server-side call — requires kaltura_genie_experiences:'off'; see CLIENT-COMMANDS.md) + tools.clientToolReadiness + tools.validate, class Tools (mgmt.tools: add/get/list/update/remove over the standalone Tool entity), applyResponseMapping. Genie v1/tool/* (partner-level entity CRUD — NOT intellect/update; link via intellectConfig.setToolIds's tool_ids)
secrets.js IntellectSecrets (mgmt.intellects.secrets: listNames/has/set/remove/replaceAll/validate), validateSecretRefs. Write-only values; name-only read contract (no redact() reliance). Genie v1/intellect/update config.secrets (mask-and-keep merge)
prompt-lint.js pure: lintPrompts/validatePromptVars/lintGlossary/assembleSystemPrompt/SYS_VARS. Client-side prompt-preview replica (author layer only). pure — no network
conversations.js Conversations (stream/send, assertRequestVars), Threads/Messages/Feedback/Followups, Knowledge (addRecord + knowledge_ids linkage — Path A, ungated; uploadDocument, createCategory/findOrCreateCategory, linkCategory/linkRecords/linkAvailable, corpusStatus, getLinkage, setEnabled, search, isIndexed). Genie assistant/converse (converse); Genie v1/knowledge/add + intellect knowledge_ids (Path A, ungated); OVP category/*+upload (containers); Genie partner-config/update (Path B re-point, gated)
provision.js provision() — the agent factory; optional knowledge/tools/capabilities blocks layer after the core create (tools creates each Tool entity via mgmt.tools.add, then links the successful ids in one intellectConfig.setToolIds write). both hosts

The top-level headless converse surface lives on the Management class itself: converse(configId, message, opts?, ks?) (AsyncGenerator over conversations.stream) and converseOnce(...) (delegates to conversations.send). Both auto-mint a conversation token from configId when ks is omitted, so the admin secret never leaves the server. opts carries {threadId, sse, model_type, force_experience, request_vars, capabilities, recoverFromSpiral}; assertRequestVars rejects reserved keys + non-scalar values before the wire. opts.capabilities is a per-message {name:state} override validated client-side, but the server-side DISABLED veto still wins — a stored/env-disabled capability cannot be turned on per message (e.g. converse(cfg, msg, {capabilities:{use_web_search:'on'}}) is honored only if use_web_search is not disabled by a stored layer). opts.recoverFromSpiral:true on conversations.send/converseOnce sends one same-thread nudge retry (SPIRAL_RECOVERY_PREFIX) when the first attempt comes back spiralStopped:true with empty text — see stream.js's collectConverse entry above for what it's recovering from.

Scope-guard timing on the streaming path. conversations.stream(...) is an async generator, so its assertConversation(ks) scope check fires on the first .next()/iteration — NOT at call time. const g = k.conversations.stream(opts, adminKs) without iterating gets no guard yet (despite the client's "before any network call" framing, which holds for the non-generator methods). For eager scope validation, use conversations.send(...)/converseOnce(...) — they assert the token kind synchronously before returning. The non-generator reads (conversations.status, all agents/avatars/catalog calls) guard at call time as documented.

resolveCapabilities return shape (the 15 names are nested, not top-level)

resolveCapabilities(layers) (src/management/capabilities.js) returns a two-key object — { capabilities, _meta } — so Object.keys(result).length === 2. The 15 AssistantCapability states live under .capabilities, keyed by name, NOT at the top level:

const { capabilities, _meta } = k.intellects.resolveCapabilities({
  partnerConfig: stored,                       // from intellects.getCapabilities
  request: { use_web_search: 'off' },          // per-turn override to model
});
capabilities.avatar.state;          // 'on' | 'off' | 'disabled'
capabilities.avatar.resolvedFrom;   // which layer won
capabilities.use_web_search.inferred; // true — see best-effort note below

Each per-name entry is { state, resolvedFrom, vetoed, inferred?, layers }:

A freshly created intellect returns an empty capabilities {} from getCapabilities (nothing stored yet), so every name then resolves from the env/default layer until you setCapability/setCapabilities.

Experience GenUI layer

src/experience/genui/ turns brain unisphere-tool segments into framework-agnostic render descriptors:

session.js wires the guardrail gate: each agent_raw_text delta is classified by classifyAgentAction (wire.js — reads seg.metadata.runtimeName/widgetName + the adapter-normalized seg.type with -tool stripped) and, when a capability policy or onAgentAction hook is present, run through _gateAgentAction BEFORE emit('brainSegment', d) (default-allow so existing nav/GenUI flows are untouched; a veto emits agentActionDenied + an agent.action.deny audit event). Presenter exposes covered/questions/lastNav ({target, reason, at}); session.micEnabled is a read getter.

Routing rule: partner-config DTO vs intellect DTO (where a field goes)

This is the load-bearing design rule for the management layer — a write goes to exactly one of two doors, and they have different auth gates:

When designing a new field setter, decide its door by which DTO genuinely accepts it (the IntellectConfig EDITABLE_FIELDS vs READ_ONLY_FIELDS constants encode this), and route reads to the SAME door — getBrainConfig reads partner-config/get, NOT intellects.get, because the intellect read DTO does not expose those fields (reading them via intellects.get would falsely report persisted values as unset).

Honest limits surfaced by the SDK

README.md's "Honest limits" covers the partner-config 403 gate, no-verbatim-speech, and the force_experience/model_type hint caveats — read that first. The rest are architecture-level limits not covered there:

The SDK's own node:test suite (test/) exercises every one of these surfaces against the real backend and against injected fakes — see README.md for the full command list.


Resilience & Failure Handling

How the system behaves under network failures, disconnects, and device problems. There are three reconnection tiers, only loosely coordinated:

Tier Layer Auto-recovers? Scope
1. Socket.IO transport control socket ✅ built-in (backoff + jitter + state recovery) the websocket only
2. WebRTC peer (ASR + STV) the WebRTC avatar engine's session client ✅ 5 attempts × 2s, independent per channel the media peer connections
3. Avatar session this SDK (KalturaAvatarSession) ✅ socket-transport recovery: a recoverable drop → reconnectingreconnected (same-pod, ≤~20s, no re-join); non-recoverable → clean ended. the whole conversation

The headline risk: tiers 2 and 3 are not wired together for custom non-SDK clients — the SDK wires them via _recoverMedia_coldReconnect; when the WebRTC layer exhausts retries and emits 'failed', a custom client that does not use the SDK's KalturaAvatarSession must handle this itself.

Device permissions (mic/camera)

connecting → gettingUserMedia calls getUserMedia(audio:true, video:false) on the WebRTC avatar engine's session client — audio only by default; the avatar doesn't need your camera. On denial, the runtime client's device-media handler routes to error with reason: DevicesPermissionDenied, skipDisconnect:true, suppressNotification:true (and shouldPurge=false) — a clean, retryable abort with no scary toast. The SDK (KalturaAvatarSession) surfaces distinct NotAllowed/NotFound/NotReadable codes; the avatar runtime client's classification is coarse (no NotAllowedError vs NotFoundError vs NotReadableError distinction). No pre-flight navigator.permissions.query, no mid-call device-loss handling.

WebRTC media peer (the WebRTC avatar engine's session client)

Control socket & session machine (avatar runtime client)

Failure-mode matrix

Failure Detected by Handling today
User denies mic permission getUserMedia throws Clean abort, no toast, retry possible
No mic / mic busy getUserMedia throws Same generic path (not distinguished)
ASR/STV peer drops WebRTC avatar engine's ICE state 5× re-join @ 2s; SDK handles via _onIceStateChange; the avatar runtime client's wrapper leaves failed event unhandled
STV server session gone (404) WHEP status Give up; app must recreate session
Control socket transient drop Socket.IO disconnect Socket.IO auto-recovers… but the runtime client's error handler may also tear down
Control socket permanent drop Socket.IO disconnect (!active) Teardown + "reconnect" notification
All agent slots busy throwToNoAgent Availability queue + poll (see Scale & Sticky Sessions above)
Plan/tier exceeded throwToExceededTier Fatal, clear message
Connect hangs SDK: setTimeout (TIMEOUTS constants); avatar runtime client: xstate after timeouts 5–30s timeouts → error (well covered)
Player/video element error onPlayerError chain Disconnect (PlayerConnectionFailed)
Brain stalls mid-conversation KalturaAvatarSession watchdog brainStalled event, repeating every brainStallMs until output lands; the avatar runtime client has no liveness timeout
Tool-call spiral (same command retried with no narration) KalturaAvatarSession two-tier circuit breaker Soft signal (toolSpiralDetected) + hard cold-reconnect recovery — see Tool-call spiral: what happened and how it's mitigated below
Tab backgrounded / network change KalturaAvatarSession online/offline/visibilitychange handling in SDK; the avatar runtime client does not handle these events

Tool-call spiral: what happened and how it's mitigated

A tool-eager brain can loop the same client command many times in one turn instead of narrating — live-verified worst case: show_widget retried 438× over 9 minutes with zero spoken output. KalturaAvatarSession defends against this with a two-tier circuit breaker.

Soft tier — signal only. Once a turn accumulates toolSpiralLimit (default 10) raw type:"tool" segments — counted before dedup, since a spiral IS the same call repeating — the SDK emits toolSpiralDetected once. This is signal only; it no longer calls interrupt(). An earlier version called interrupt() (tapToTalkStart/tapToTalkEnd) here to try to yield the runaway turn back to the client. Two live incidents killed that approach:

  1. A repro proved interrupt() has no observable effect on a spiral already running server-side — the identical show_widget call kept repeating for 5+ minutes past the soft trip, including through a server-pushed idle "wake-up" turn whose agent_start_speech reset the per-turn counter and let the soft breaker "detect" and interrupt() again, while the spiral underneath never actually stopped, until the socket itself died (transport closeJoinRoomTimeout).
  2. Worse, interrupt() was actively harmful: per WIRE-PROTOCOL.md's documented barge-in semantics, a mid-turn tapToTalkStart forces an early stvFinishedTalking with truncated agentContent — so the soft trip was silently cutting the turn's own narration (avatarStopTalking fired with empty text), with no mechanism to reopen the talking channel once the brain went on to stream a complete, correct spoken answer for that same turn.

The default limit was also raised from 6 to 10, because a legitimate turn can double its raw tool-segment count when speak()'s barge-in branch (still-playing TTS audio from a prior turn) spawns a parallel tap-to-talk stream for the same question — a 3-tool turn duplicating into 6 raw segments this way previously tripped the breaker on an ordinary turn, not a real spiral.

Hard tier — the actual fix. A session-scoped hard counter (hardToolSpiralLimit, default toolSpiralLimit * 3) counts raw tool segments since the last perceivable output and is immune to turn-boundary resets — an idle wake-up nudge mid-spiral cannot hide it. Once it's crossed, the SDK emits toolSpiralRecovering (carrying lastTurnText, the abandoned turn) and forces _coldReconnect() — the same full media rebuild already used for a dead media channel, replaying threadId so brain memory continues. This turns the eventual uncontrolled JoinRoomTimeout into a deliberate, bounded, self-healing reconnect.

Because the control socket is still live at this point (unlike a genuine transport drop), _coldReconnect() opens a brand-new socket rather than re-join-ing the still-connected one — the server's join handler is idempotent-guarded per-connection and silently no-ops a re-join on a live socket (reproduced live as JoinRoomTimeout). _coldReconnect() detects this case (this.state !== 'reconnecting' at entry means the socket never actually dropped) and opens a genuinely new socket via the same factory connect() uses, before re-join-ing on it. The one path that safely reuses the existing socket is the genuine-transport-disconnect case, reached only after a real drop already set state to 'reconnecting' — there the server has already discarded that session, so re-join-ing it is not a no-op.

The hard guard re-arms on a successful cold reconnect, not just on perceivable output — a spiral by definition never produces spoken/GenUI content, so that's the only reset path that can actually fire while one is running. Without this re-arm, a second spiral later in the same session would find the guard permanently latched from the first recovery and hang indefinitely, reproducing the original symptom just delayed to the second occurrence.

A cold reconnect restores connectivity and brain memory (threadId) but otherwise abandons the turn that triggered it. With recoverFromSpiral (default true), the SDK auto-resends that turn's tracked text once (from speak() or ASR's userTranscription), prefixed with SPIRAL_RECOVERY_PREFIX (the same nudge proven live on the headless Conversations#send({recoverFromSpiral:true}) path), and emits spiralRecovered {text}. recoverFromSpiral:false suppresses the resend and leaves it to the app via lastTurnText. All three thresholds (brainStallMs, toolSpiralLimit, hardToolSpiralLimit) are configurable at construction; 0 disables any of them. The avatar runtime client (the non-SDK reference implementation) has no such breaker. Author-side mitigation (a tool-call budget in the system prompt) and the headless-path equivalent are covered in CLIENT-COMMANDS.md's "Tool spirals starve the voice" — this section documents only the SDK's own recovery mechanism.

What's already solid (don't regress)

Click to talk with Nova — she knows this whole SDK.