feat(hosted,desktop): Keyframe avatar frames, the avatar's status in app state, and the AvatarFeed reader
Plan item 1. contracts of the Keyframe video-call plan. Independent PR off main. It adds the pinned shared interfaces and nothing that sends or reads them.
What lands
Frames (packages/hosted/src/live-contract.ts, exported from @sidecar/hosted):
- Every avatar frame carries a required
requestId. The host mints it (a UUID) for eachavatar.start, and the other three frames echo it, so an answer or a stop left over from an earlier ask can be told apart from the current one. It is a bounded string, at mostAVATAR_FRAME_BOUNDS.REQUEST_ID_CHARS= 64 chars. - Desktop โ service:
avatar.start {requestId}andavatar.stop {requestId, reason}. The reason comes fromAVATAR_STOP_REASON = {USER, SERVER_CLOSED, ERROR}. A hang-up needs no stop of its own because the service closes the avatar along with the call. Read withavatarRequestFrameFromWire, which refuses any key the frame doesn't declare. For example, the desktop can't pick a persona. - Service โ desktop:
avatar.started {requestId, serverUrl, participantToken, agentIdentity}, read withavatarStartedFrameFromWire.serverUrlmust be an absolutewss:URL of at most 2,048 chars.- The token is at most 8,192 chars and the identity at most 256.
- Service โ desktop:
avatar.unavailable {requestId, reason}, read withavatarUnavailableFrameFromWire. The reason comes fromAVATAR_UNAVAILABLE_REASON = {NOT_CONFIGURED, NO_CAPACITY, ERROR}.- A reason from a newer service reads as
ERRORrather than refusing the frame, the same way an answer drops a key it doesn't know. - A reason over 64 chars is refused.
- A reason from a newer service reads as
- The bounds are in
AVATAR_FRAME_BOUNDS. The JSON Schema goldens for the five new schemas are recorded underLUKE_UPDATE_FIXTURES=1. All five are new files, so no existing golden changed.
Desktop vocabulary (apps/desktop/src/shared/messages/avatar.ts):
-
AVATAR_STATUS = {OFF, STARTING, LIVE, ENDED}. -
AVATAR_END_REASON = {USER, SERVER_CLOSED, NO_CAPACITY, UNAVAILABLE, ERROR, CALL_ENDED}. -
AvatarState {status, endReason?}andAVATAR_OFF. -
state.voice.avataris now required and set toAVATAR_OFFat launch.- A replaced voice window keeps it, through the new
voiceWindowGoneinapp-state.ts, because the call window holds the avatar, not the voice window.
- A replaced voice window keeps it, through the new
-
AVATAR_FEED_KIND = {AUDIO, TURN_END, INTERRUPT}.AvatarFeedis{kind:"audio", pcm},{kind:"turn_end"}or{kind:"interrupt"}, read withavatarFeedFromWire.pcmmust:- be non-empty, padded standard base64;
- be at most
AVATAR_FEED_BOUNDS.PCM_BASE64_CHARS= 2,560 chars, which is exactly 40 ms of PCM16LE 24 kHz mono (960 samples, 1,920 bytes); - decode to an even byte count, so it holds whole samples only.
A shorter chunk is allowed so a turn's last chunk can be partial.
Consumers (no caller in this PR)
- #7 (web: avatar frames and records):
avatarRequestFrameFromWire, the started and unavailable frames, and both reason sets. - #8 (host: frames and setVideo):
avatarStartFrameSchemaandavatarStopFrameSchemafor sending,avatarStartedFrameFromWireandavatarUnavailableFrameFromWirefor reading. - #9a (main: call window plumbing):
AVATAR_STATUS,AVATAR_END_REASON,AvatarStateandstate.voice.avatarfor theAVATAR_REPORTact. - #10 (voice window: feed path):
AvatarFeed,AVATAR_FEED_KINDandAVATAR_FEED_BOUNDS. - #11 (wire-up):
avatarFeedFromWirefor theVOICE_AVATAR_FEEDact.
Tests
live-contract.test.ts:- a start that carries an extra key is refused, and so is a stop with an unknown or missing reason;
avatar.startedis refused when the URL isn'twss:, a URL, token or identity is over its bound, the token is only whitespace, or the identity is missing;- an unknown
avatar.unavailablereason reads asERROR, and an oversized or non-string reason is refused; - a frame without its
requestIdis refused, the two answers included (they are lenient on the unavailable reason only), and so is a request id over 64 chars.
avatar.test.ts:- a full 40 ms chunk, a shorter chunk, a turn end and an interrupt are all accepted;
- a chunk longer than 40 ms is refused;
- each malformed feed is refused: empty, not base64, unpadded, half a sample, missing
pcm,pcmgiven as an array, aturn_endthat carriespcm, an unknown kind, and a value that isn't a record.
app-state.test.ts: the avatar startsOFFand is still standing after the voice window goes away.
./scripts/check.sh exits 0.
Mac
CI could not verify this, and Mac evidence is still pending. The PR touches main's app-state document and the voice-window-gone path in window-service.ts. Nothing is drawn differently.
๐ค Generated with Claude Code
Summary by cubic
Adds the Keyframe avatar frames to the hosted live-contract vocabulary, the avatar's status to the desktop app state, and the AvatarFeed reader that carries the avatar's lip-sync audio. No callers yet; this is plan item 1 (contracts) of the Keyframe video-call plan.
- New frames:
avatar.start/avatar.stop {reason}from the desktop andavatar.started {serverUrl, participantToken, agentIdentity}/avatar.unavailable {reason}from the service, each string bounded; every frame echoes the request id the host mints for itsavatar.start, so an answer or stop from an earlier ask is told apart; an unknownavatar.unavailablereason reads asERROR, and a start carrying an undeclared key is refused. state.voice.avataris now required, starts asAVATAR_OFF, and survives voice-window replacement since the call window owns the avatar, through the newvoiceWindowGonehelper.AvatarFeedaccepts a padded-base64 PCM16LE chunk up to 40 ms, a turn end, or an interrupt; empty, unpadded, overlong, or non-whole-sample chunks are refused.