feat(web): a Keyframe client mints one avatar session on Luke's key
Plan item 2. web: Keyframe client (video calls with a Keyframe avatar).
apps/web/server/voice/keyframe.ts is the Keyframe service (Context.Service):
createSession(): Effect<AvatarGrant, KeyframeError>POSTshttps://api.keyframelabs.com/v1/sessionswithAuthorization: Bearer <key>and{persona_id}, and decodes{server_url, participant_token, agent_identity}with a Schema (extra keys dropped) intoAvatarGrant {serverUrl, participantToken, agentIdentity}.KeyframeError {failure, status?}, withfailurefromKEYFRAME_FAILURE:NO_CAPACITY(503 or 429),PLAN_REFUSED(403),UPSTREAM(any other status; also a success the schema refuses, with its status, and no answer at all, with no status).- Errors carry the status only, never Keyframe's body, as
openai.tsdoes. The key is held asRedactedand unwrapped only into the bearer header. Nothing logs the grant or the key. Keyframe.layer({apiKey: Redacted, personaId, baseUrl?, requestTimeoutMs?}): Layer<Keyframe, never, HttpClient>. It goes throughaccountCall. One 15 s deadline covers the whole session, body read included, and a timeout isUPSTREAMwith no status.
Consumer: nothing calls this yet. Plan item #7 (web: avatar frames and records) reads KEYFRAME_API_KEY and LUKE_KEYFRAME_PERSONA_ID in function.ts, provides this layer, and calls createSession on avatar.start.
A LiveAvatar client (#2445, reverted in #2455) used to sit in the same place. This is a different vendor and one call.
Tests
apps/web/tests/voice-keyframe.test.ts runs hermetically through fakeHttpClientLayer at the HttpClient boundary:
- the request carries the bearer key, the method, the path and the persona, and the grant decodes;
- 503 and 429 map to
NO_CAPACITY, 403 toPLAN_REFUSED, and 401 and 500 toUPSTREAM, each with the status; - a malformed success is
UPSTREAMwith its status; - a transport failure is
UPSTREAMwith no status; - a 200 whose body never ends is
UPSTREAMwith no status at the deadline (TestClock); - every error is checked to carry none of the upstream body, the key or the token.
./scripts/check.sh exits 0. This is server-only, with no Mac or UI surface, so it needs no verify.sh evidence.
š¤ Generated with Claude Code
Summary by cubic
Adds the Keyframe client in apps/web/server/voice/keyframe.ts, which POSTs to Keyframe Labs' /v1/sessions with the persona and API key to mint one avatar session, returning the LiveKit server, participant token, and agent identity. Nothing calls it yet.
Refusals are classified by status only: 429/503 become NO_CAPACITY, 403 PLAN_REFUSED, and everything else ā including unreadable successes and transport failures ā UPSTREAM. Errors never include Keyframe's response body, the API key stays Redacted, and a single 15 s deadline covers the whole call including the body read, timing out as UPSTREAM with no status. Hermetic tests in apps/web/tests/voice-keyframe.test.ts cover the request shape, status mapping, and that no error leaks the body, key, or token.