feat(desktop): a call's Keyframe avatar ships as a bundle of its own, opened as a scoped session over the SDK's narrowest slice
Plan item 6. desktop: Keyframe session bundle. Independent PR, base main.
What it adds
- Dependencies.
@keyframelabs/sdk@0.3.0andlivekit-client@2.22.4are pinned exactly inapps/desktopdevDependencies, beside the other bundled renderer libraries. 2.22.4 is the version the SDK's^2.15.14resolves to, and pnpm stands up one copy, sosetLogLevelreaches the same logger the SDK'sRoomuses. The SDK's@google/genaipeer is optional and is not installed. - The bundle.
src/renderer/avatar-session/index.tscallssetLogLevel(LogLevel.warn)before any room exists. It then sets one global,window.lukeAvatarSession = { createSession }. It has its own esbuild entry inscripts/build.mjs, which outputsavatar-session.jsbesidecall.html. - Budget. The new line is
renderer/avatar-session.js: 148920gzipped bytes, which is LiveKit's client plus the SDK. No other baseline moves, andrenderer.jscarries none of it. - Loading.
call/avatar-session-bundle.tsloads it asavatarSessionModuleAtom = bundleAtom(...), following the whiteboard, plan-editor and file-tree precedent. contract.ts. It holds the grant and the narrowest slice ofPersonaSessionand its options that the window drives, and imports no value. Effect stays on the renderer's side: the effect over the session is the renderer's, so the IIFE bundle carries no second copy ofeffect.
The contract (for #9b)
openAvatarSession(grant: AvatarGrant, module: AvatarSessionModule)
: Effect<AvatarSession, AvatarSessionError, Scope>
interface AvatarSession {
video: Effect<MediaStreamTrack, AvatarSessionError> // resolves on the SDK's onVideoTrack; fails CLOSED if the session closes first
audio: Effect<MediaStreamTrack, AvatarSessionError>
sendAudio: (pcm: Uint8Array) => Effect<void> // PCM16LE 24 kHz mono; this and the two below do nothing once closed
endTurn: Effect<void> // endAudioTurn()
interrupt: Effect<void> // interrupt() only (lossy: lk.control, reliable:false); #9b's feed map does endTurn then interrupt
speaking: Stream<boolean> // false first, then each change from lk.playback_started/finished, deduped; ends on close; one reader
closed: Effect<AvatarCloseReason>
}
AVATAR_CLOSE_REASON = { USER, SERVER_CLOSED, ERROR } // named as #1's avatar end reasons
// client_initiated ā USER; room_deleted (incl. the plan's duration cap) | server_shutdown | participant_removed ā SERVER_CLOSED; unknown ā ERROR
AVATAR_SESSION_FAILURE = { CONNECT, CLOSED } // the error carries its kind only; #9b reports a CONNECT failure (a rejected connect()) as ERROR
- Signature change from the pinned shape.
openAvatarSessiontakes the loaded module as a second argument. The SDK lives in the bundle, so the caller hands over whatavatarSessionModuleAtomresolved to. This is the same seamlive-peer.tsgives the browser. - Scope. The session's
close()is registered with the scope beforeconnect(), so a connect that fails partway still leaves the room. Closing the scope is the one way this side ends a session. - Errors. No error carries the SDK's or LiveKit's message, because a connection error can name the room the grant opens.
- Callbacks. They land in a
Deferredor aQueuethroughdoneUnsafe/offerUnsafe. There is nonew Promise, no timer, and noEffect.run*.
Consumer: #9b (call window runs the session). Nothing calls openAvatarSession or avatarSessionModuleAtom yet. knip.json lists call/avatar-session-bundle.ts as an entry until #9b imports it, as #2443 did with the tile. The bundle's index.ts is a permanent entry.
Tests
avatar-session.test.ts runs against a fake PersonaSessionSlice at the SDK boundary:
- the tracks resolve;
speakingfollows the playback RPCs, once per change, and ends on close;closedmaps each SDK reason;- closing the scope leaves the room and gives
CLIENT; - a close before a track fails the wait on that track;
- a refused grant fails with
CONNECTand no token, and still leaves the room; - feed calls reach the session, and a rejected write is not the caller's.
./scripts/check.sh exits 0.
Spike 0 findings (from code, not a live session)
The spike ran with no Keyframe key. Everything below comes from reading @keyframelabs/sdk@0.3.0 and livekit-client@2.22.4. None of it has been tested against a live Keyframe session.
- Pin.
livekit-clientis pinned at exactly 2.22.4, which is what the SDK's^2.15.14resolves to. - Logging.
setLogLevel(LogLevel.warn)is enough for LiveKit.- The SDK's own
console.*lines bypass it, but they carry no secret.
- Close report.
closedresolves fromonClose, not from ourclose()call.onClose('client_initiated')fires asynchronously afterclose()returns, and the fake reports it the same way. - Reason mapping.
client_initiatedmaps toUSER.room_deletedmaps toSERVER_CLOSED. This includes the plan's duration cap.server_shutdownandparticipant_removedmap toSERVER_CLOSED.unknownmaps toERROR.- A rejected
connect()fails withAvatarSessionError{CONNECT}, which #9b reports asERROR.
- Close order.
close()awaits every queued audio write, then callsroom.disconnect()without awaiting it. The scope's finalizer awaitsclose(). - Interrupt is lossy.
interrupt()goes onlk.controlwithreliable: false, so it can be lost. This is documented onAvatarSession.interrupt. - No warnings after close. After a close, every SDK
sendAudiowarns on the console.sendAudio,endTurnandinterruptare therefore no-ops onceclosedhas resolved, so a running feed doesn't warn 25 times a second.
Also untested live:
- whether billing stops on
close(); - the order the tracks arrive in. The tests assume no order;
- whether the playback RPCs fire as their names say.
Mac: CI could not verify this, and Mac evidence is pending. The bundle is built and budget-checked on Linux. Nothing here runs in a window until #9b, which also adds the connect-src for LiveKit to call.html.
š¤ Generated with Claude Code