realtime-avatar 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ A live character your users can talk to — voice, or voice and video. She liste
4
4
  speaks, so you can interrupt her mid-sentence and she stops, the way a person stops.
5
5
 
6
6
  ```bash
7
- npm install --save-exact realtime-avatar@0.14.0
7
+ npm install --save-exact realtime-avatar@0.16.0
8
8
  ```
9
9
 
10
10
  ```ts
@@ -25,6 +25,61 @@ return call.raw; // relay to the browser byte-for-byte
25
25
 
26
26
  That is the whole server half. The client joins with the payload and renders her.
27
27
 
28
+ ## Optional call recordings
29
+
30
+ Your server decides whether to record after your application obtains consent:
31
+
32
+ ```ts
33
+ const call = await rta.startCall({ avatarId, recording: "audio_video" });
34
+ if (!isQueued(call) && call.recording) {
35
+ await saveRecordingId(call.sessionId, call.recording.recordingId);
36
+ }
37
+
38
+ // Later, in your authenticated admin backend:
39
+ const recording = await rta.getRecording(recordingId);
40
+ if (recording.status === "ready") {
41
+ const { url, expiresAt } = await rta.getRecordingAccess(recordingId);
42
+ // Return this short-lived access to the authorized viewer.
43
+ }
44
+ ```
45
+
46
+ Omitted or `"off"` disables recording. `"audio"` records published user and avatar audio;
47
+ `"video"` records published video without audio; `"audio_video"` keeps both on one media timeline.
48
+ Recording does not enable the microphone, camera, or screen sharing. Only tracks that participants
49
+ authorize and publish can be recorded. Camera controls remain a future SDK feature.
50
+
51
+ Recordings may finish processing after a call ends. Use `listRecordings({ sessionId })` to find
52
+ them, or refresh `getRecording(recordingId)` while processing. These methods and
53
+ `getRecordingAccess` require a server key with `recordings:read`.
54
+
55
+ Save `recordingId`, not a playback URL. Files are retained until `retainedUntil` (30 days by
56
+ default); each URL expires at `expiresAt` (up to one hour, capped by retention). Obtain fresh
57
+ access before replaying or seeking after expiry. An expired URL does not delete the file.
58
+ Treat the URL as private: anyone who has it can play that file until it expires.
59
+
60
+ Transcript delivery remains the signed `transcript` webhook configured on `startCall`. Join the
61
+ transcript and recordings by `sessionId`; keep your own script revision with that call. Transcript
62
+ timestamps describe conversation turns and do not by themselves establish frame-accurate media
63
+ alignment for lip-sync analysis.
64
+
65
+ Client-safe Zod schemas and derived types are available from `realtime-avatar/recording`.
66
+ They are the same executable contract used by the service, verified against the published digest.
67
+
68
+ ```mermaid
69
+ ---
70
+ title: Recording ownership and private playback
71
+ ---
72
+ flowchart LR
73
+ App[Application server: consent and recording policy] --> RTA[RTA: call and recording lifecycle]
74
+ RTA --> Media[Media provider: record published tracks]
75
+ Media --> Storage[Private media storage]
76
+ RTA --> Metadata[Recording ID and session ID]
77
+ Admin[Authorized admin backend] --> RTA
78
+ RTA --> Access[Temporary playback URL]
79
+ Access --> Player[Video or audio player]
80
+ Storage --> Player
81
+ ```
82
+
28
83
  New here? The [quickstart](https://realtimeavatar.ai/docs/quickstart) goes from an API key to a
29
84
  working call, and a [sandbox key](https://realtimeavatar.ai/signup) is free with no card. Mount
30
85
  the server half on your framework:
@@ -82,9 +137,14 @@ all), and the `video` policy types are deliberately not one-to-one with the wire
82
137
 
83
138
  ```ts
84
139
  // calls
85
- rta.startCall({ avatarId, mode?, instructions?, context?, maxSeconds?, video?, transcript?, metadata? })
140
+ rta.startCall({ avatarId, mode?, instructions?, context?, maxSeconds?, video?, recording?, transcript?, metadata? })
86
141
  rta.endCall(sessionId, { reason? }) // free an abandoned call's slot; idempotent, never throws
87
142
 
143
+ // optional recordings; server only, requires recordings:read
144
+ rta.listRecordings({ sessionId, limit?, cursor? })
145
+ rta.getRecording(recordingId)
146
+ rta.getRecordingAccess(recordingId)
147
+
88
148
  // avatars
89
149
  rta.createAvatarFromImage({ displayName, imageUrl, motionPrompt?, voice? }) // the only lane
90
150
  rta.createAvatarFromVideo({ displayName, videoUrl, voice? }) // DEPRECATED — closed, 422
@@ -141,22 +201,65 @@ Browser — these never can:
141
201
  | `realtime-avatar/react-native` | The same surface for Expo / React Native |
142
202
  | `realtime-avatar/browser` | `enableMicrophone`, `attachRemoteAudio`, `prepareAvatarRoom` — no React |
143
203
  | `realtime-avatar/tools` | `attachAvatarTools` — the browser tool plane |
204
+ | `realtime-avatar/recording` | Client-safe Zod recording schemas and derived types |
144
205
 
145
206
  Every adapter takes the same two hooks: `authorize` gates the request, `session` decides the
146
207
  call. Policy — `instructions`, `maxSeconds`, `voice`, `video` — is decided in `session`, on
147
208
  your server. A route that spreads the request body into `startCall` hands your caller your
148
209
  system prompt and your bill.
149
210
 
150
- ### Native connection facts
211
+ ### Optional connection details
212
+
213
+ `AvatarCall` provides call status, actions and end reasons for your default UI. To show
214
+ connection diagnostics, opt in with `onConnectionDetailsChange`. It supplies data for
215
+ your own UI; enabling it adds no built-in network notice or controls.
216
+
217
+ ```tsx
218
+ import { useState } from "react";
219
+ import { AvatarCall, type AvatarCallProps, type AvatarConnectionDetails } from "realtime-avatar/react";
220
+
221
+ function CallWithDetails(props: Pick<AvatarCallProps, "client" | "avatarId">) {
222
+ const [details, setDetails] = useState<AvatarConnectionDetails | null>(null);
151
223
 
152
- LiveKit owns media transport, connection state, and reconnection. Use its
153
- `useConnectionState()` and `useConnectionQualityIndicator({ participant })` hooks, or
154
- `RoomEvent` callbacks, to build your product's network UI. Their values already use
155
- `ConnectionState`, `ConnectionQuality`, and `Track.StreamState` from `livekit-client`.
156
- For current receiver measurements, `RemoteVideoTrack.getReceiverStats()` and
157
- `RemoteAudioTrack.getReceiverStats()` return LiveKit's exported `VideoReceiverStats` and
158
- `AudioReceiverStats` types.
159
- Keep these native types in process; there is no SDK-specific JSON mirror to maintain.
224
+ return (
225
+ <>
226
+ <AvatarCall {...props} onConnectionDetailsChange={setDetails} />
227
+ {details && <p>Connection: {details.connectionState}. Local quality: {details.localQuality}.</p>}
228
+ </>
229
+ );
230
+ }
231
+ ```
232
+
233
+ Once bound to a call, the callback receives an initial snapshot, then changed facts only. It receives `null`
234
+ when its room or session binding is retired, before a replacement binding's snapshot.
235
+ Clearing the session grant keeps details reset until a new grant arrives.
236
+ Keep this state scoped to one call and clear it on `null`, as the example does. This is
237
+ a current snapshot, not a lossless event history. Replacing the handler does not restart
238
+ the call, and errors in your handler do not interrupt it. Omitting the callback adds no
239
+ reporting listeners; the callback adds no timers, stats polling or uploads.
240
+
241
+ Each field preserves LiveKit's native type and meaning:
242
+
243
+ | Field | Source |
244
+ | --- | --- |
245
+ | `connectionState` | The bound room's `state`. |
246
+ | `localQuality` | The local participant's `connectionQuality`. |
247
+ | `audio.publisherQuality` | The selected audio publisher's `connectionQuality`. |
248
+ | `video.publisherQuality` | The selected video publisher's `connectionQuality`. |
249
+ | `audio.streamState`, `video.streamState` | The corresponding remote track's `streamState`, or `null` before the track exists. |
250
+
251
+ Audio and video publishers are selected independently and can differ. Neither field
252
+ substitutes the control agent or an arbitrary remote participant. `audio` or `video` is
253
+ `null` when no corresponding track reference is selected; LiveKit's `Unknown` quality
254
+ means a publisher exists but its quality is unknown. An active or subscribed track does
255
+ not prove that video has rendered or audio is audible. Continue using call status for
256
+ the call lifecycle.
257
+
258
+ Native and custom integrations pass the same optional callback to their existing
259
+ `SessionLifecycleRoomBridge` inside `RealtimeAvatarLiveKitRoom`. Both
260
+ `realtime-avatar/react` and `realtime-avatar/react-native` export `AvatarConnectionDetails`.
261
+ For deeper receiver measurements, use LiveKit's public `RemoteVideoTrack.getReceiverStats()`
262
+ and `RemoteAudioTrack.getReceiverStats()` methods and their native return types.
160
263
 
161
264
  Retain the app's session-to-room association (`room_name`, timestamps and participant
162
265
  identity), adding the server-observed room SID when exact room-lifetime lookup needs it.
@@ -1,4 +1,4 @@
1
- import { RealtimeAvatar, isQueued, RealtimeAvatarHttpError } from './chunk-UZQXZH2Z.js';
1
+ import { RealtimeAvatar, isQueued, RealtimeAvatarHttpError } from './chunk-V26PE2M3.js';
2
2
 
3
3
  // ../proxy/src/config.ts
4
4
  var json = (body, status = 200) => new Response(JSON.stringify(body), {
@@ -214,6 +214,44 @@ var schema26 = z.strictObject({
214
214
  });
215
215
  var clipLibraryResponseSchema = schema24;
216
216
  var clipLibraryUpdateSchema = schema26;
217
+ var RECORDED_MEDIA_MODES = ["audio", "video", "audio_video"];
218
+ var recordingModeSchema = z.enum(["off", ...RECORDED_MEDIA_MODES]).describe("Server-owned recording policy; omitted means off. Audio includes user and avatar audio.");
219
+ var RECORDING_STATUSES = ["pending", "recording", "processing", "ready", "failed", "expired"];
220
+ z.enum(RECORDING_STATUSES);
221
+ var recordingMetadataSchema = z.object({
222
+ sessionId: z.string().min(1),
223
+ recordingId: z.string().min(1),
224
+ mode: recordingModeSchema.exclude(["off"]),
225
+ createdAt: z.string().datetime({ offset: true }),
226
+ retainedUntil: z.string().datetime({ offset: true })
227
+ });
228
+ var recordingArtifactSchema = z.discriminatedUnion("status", [
229
+ recordingMetadataSchema.extend({ status: z.enum(["pending", "recording", "processing", "expired"]) }).strict(),
230
+ recordingMetadataSchema.extend({
231
+ status: z.literal("ready"),
232
+ mediaType: z.enum(["audio/mp4", "video/mp4"]),
233
+ sizeBytes: z.number().int().positive(),
234
+ durationMs: z.number().int().nonnegative().nullable()
235
+ }).strict(),
236
+ recordingMetadataSchema.extend({
237
+ status: z.literal("failed"),
238
+ errorCode: z.enum(["recording_failed", "recording_unavailable"])
239
+ }).strict()
240
+ ]);
241
+ var listRecordingsQuerySchema = z.object({
242
+ sessionId: z.string().min(1).optional(),
243
+ limit: z.coerce.number().int().min(1).max(100).default(50),
244
+ cursor: z.string().min(1).optional()
245
+ }).strict();
246
+ var listRecordingsResponseSchema = z.object({
247
+ data: z.array(recordingArtifactSchema),
248
+ nextCursor: z.string().nullable()
249
+ }).strict();
250
+ var recordingAccessResponseSchema = z.object({
251
+ recordingId: z.string().min(1),
252
+ url: z.string().url(),
253
+ expiresAt: z.string().datetime({ offset: true })
254
+ }).strict();
217
255
 
218
256
  // ../http-client/src/retry.ts
219
257
  var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 500, 502, 503, 504]);
@@ -233,10 +271,332 @@ function isTransient(cause) {
233
271
  const name = cause?.name;
234
272
  return name === "TimeoutError" || name === "TypeError" || name === "FetchError";
235
273
  }
274
+ var DEFAULT_AVATAR_ID = "maria";
275
+ var DEFAULT_BACKGROUND_ID = "plain_white";
276
+ var sessionModeSchema = z.enum(["avatar", "voice"]);
277
+ var DEFAULT_SESSION_MODE = "avatar";
278
+ var DEFAULT_MAX_SESSION_SECONDS = 1800;
279
+ var avatarSourceKindSchema = z.enum(["portrait", "source_video"]);
280
+ var liveKitSttModeSchema = z.enum(["server", "off"]);
281
+ var renderBackendSchema = z.string();
282
+ var sessionLiveEditSchema = z.object({
283
+ rules: z.string().min(1).max(2e3),
284
+ cooldown_seconds: z.number().int().min(5).max(600).optional(),
285
+ // Which machinery runs the re-edit. Absent ⇒ the server default ("editor"). A deploy
286
+ // that cannot provide the requested renderer serves the editor lane and logs it,
287
+ // rather than failing a call that would otherwise have connected fine.
288
+ renderer: z.enum(["editor", "generative"]).optional()
289
+ }).strict();
290
+ var sessionSupportEditsSchema = z.object({
291
+ instruction: z.string().min(1).max(1e3),
292
+ reference_url: z.string().url().optional(),
293
+ live_edit: sessionLiveEditSchema.optional()
294
+ }).strict();
295
+ var LLM_PROVIDERS = ["local", "gemini", "openai"];
296
+ var llmProviderSchema = z.enum(LLM_PROVIDERS);
297
+ var llmConfigSchema = z.object({
298
+ backend: llmProviderSchema.optional(),
299
+ model: z.string().max(200).nullable().optional()
300
+ }).strict();
301
+ var llmSelectionSchema = z.object({
302
+ provider: llmProviderSchema,
303
+ model: z.string().max(200).nullable().optional()
304
+ }).strict();
305
+ var liveKitInitialContextMessageSchema = z.object({
306
+ role: z.enum(["system", "user", "assistant"]),
307
+ content: z.string().min(1).max(4e3)
308
+ }).strict();
309
+ var CARTESIA_TTS_MODELS = [
310
+ "cartesia/sonic-2",
311
+ "cartesia/sonic-2-latest",
312
+ "cartesia/sonic-3",
313
+ "cartesia/sonic-3-latest",
314
+ "cartesia/sonic-turbo",
315
+ "cartesia/sonic-turbo-latest"
316
+ ];
317
+ var cartesiaTtsModelSchema = z.enum(CARTESIA_TTS_MODELS);
318
+ var cartesiaVoiceSpecSchema = z.object({
319
+ provider: z.literal("cartesia"),
320
+ model: cartesiaTtsModelSchema.default("cartesia/sonic-3"),
321
+ voice_id: z.string().min(1).max(120),
322
+ speed: z.number().min(0.5).max(2).nullable().optional(),
323
+ emotion: z.string().min(1).max(80).nullable().optional(),
324
+ language: z.string().min(2).max(16).nullable().optional()
325
+ }).strict();
326
+ var FISH_TTS_MODELS = ["speech-1.6", "s1", "s2-pro", "speech-1.5", "s1-mini"];
327
+ var fishTtsModelSchema = z.enum(FISH_TTS_MODELS);
328
+ var breezeVoiceSpecSchema = z.object({
329
+ provider: z.literal("breezeblue"),
330
+ model: z.string().min(1).max(80).default("bluebell-v1-en"),
331
+ voice_id: z.string().min(1).max(120),
332
+ guidance_scale: z.number().min(1).max(10).nullable().optional(),
333
+ instructions: z.string().min(1).max(1e3).nullable().optional(),
334
+ language: z.string().min(2).max(16).nullable().optional()
335
+ }).strict();
336
+ var fishVoiceSpecSchema = z.object({
337
+ provider: z.literal("fish"),
338
+ model: fishTtsModelSchema.default("speech-1.6"),
339
+ voice_id: z.string().min(1).max(120),
340
+ speed: z.number().min(0.5).max(2).nullable().optional(),
341
+ emotion: z.string().min(1).max(80).nullable().optional(),
342
+ language: z.string().min(2).max(16).nullable().optional()
343
+ }).strict();
344
+ var voiceSpecSchema = z.discriminatedUnion("provider", [
345
+ cartesiaVoiceSpecSchema,
346
+ breezeVoiceSpecSchema,
347
+ fishVoiceSpecSchema
348
+ ]);
349
+ var nullableUrlSchema = z.string().url().nullable();
350
+ var clipTriggerSchema = z.enum([
351
+ "idle",
352
+ "listen",
353
+ "think",
354
+ "directive"
355
+ ]);
356
+ var sessionClipSchema = z.object({
357
+ clip_id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "clip_id must be a slug").refine((id) => id !== "primary", "'primary' is reserved for the avatar's source video"),
358
+ source_video_url: z.string().url().optional(),
359
+ video_cache_id: z.string().min(8).max(160).optional(),
360
+ max_seconds: z.number().min(1).max(10).optional(),
361
+ trigger: clipTriggerSchema.optional(),
362
+ loop: z.boolean().optional(),
363
+ weight: z.number().min(0).max(100).optional(),
364
+ crossfade_ms: z.number().int().min(0).max(1e3).optional(),
365
+ trim_start_ms: z.number().int().min(0).max(2e3).optional(),
366
+ trim_end_ms: z.number().int().min(0).max(2e3).optional(),
367
+ // The cue the character reads to decide this clip. `when` is the public name and the
368
+ // one the docs use; `hint` is the name the wire first shipped under and still accepts.
369
+ // Both are listed because this object is `.strict()` — omitting `when` would make the
370
+ // public name a validation error. Send one, never both.
371
+ when: z.string().min(1).max(120).optional(),
372
+ hint: z.string().min(1).max(120).optional()
373
+ }).strict().refine((clip) => clip.source_video_url || clip.video_cache_id, {
374
+ message: "a clip needs source_video_url or video_cache_id"
375
+ }).refine((clip) => !(clip.when && clip.hint), {
376
+ message: "set `when` or `hint`, not both \u2014 they are the same field",
377
+ path: ["when"]
378
+ });
379
+ var sessionChoreographySchema = z.object({
380
+ idle_dwell_min_seconds: z.number().min(1).max(60).optional(),
381
+ idle_dwell_max_seconds: z.number().min(1).max(120).optional(),
382
+ special_weight: z.number().min(0).max(100).optional(),
383
+ start_grace_seconds: z.number().min(0).max(60).optional(),
384
+ crossfade_ms: z.number().int().min(0).max(1e3).optional(),
385
+ crossfade_easing: z.enum(["linear", "smooth", "ease_out"]).optional(),
386
+ wrap_crossfade_ms: z.number().int().min(0).max(1e3).optional()
387
+ }).strict().refine(
388
+ (c) => c.idle_dwell_min_seconds === void 0 || c.idle_dwell_max_seconds === void 0 || c.idle_dwell_min_seconds <= c.idle_dwell_max_seconds,
389
+ { message: "idle_dwell_min_seconds must be <= idle_dwell_max_seconds" }
390
+ );
391
+ var sessionBehaviorSchema = z.object({
392
+ gestures_enabled: z.boolean().optional()
393
+ }).strict();
394
+ var transcriptWebhookSchema = z.object({
395
+ url: z.string().url().max(500),
396
+ secret: z.string().min(16).max(200)
397
+ }).strict();
398
+ var clientMetadataSchema = z.record(z.string().min(1).max(64), z.string().max(200)).refine((value) => Object.keys(value).length <= 16, {
399
+ message: "client_metadata carries at most 16 entries"
400
+ });
401
+ var MAX_SESSION_INSTRUCTIONS_CHARS = 8e3;
402
+ z.object({
403
+ avatar_id: z.string().min(1).max(160).default(DEFAULT_AVATAR_ID),
404
+ background_id: z.string().min(1).max(160).default(DEFAULT_BACKGROUND_ID),
405
+ mode: sessionModeSchema.default(DEFAULT_SESSION_MODE),
406
+ create_room: z.boolean().default(true),
407
+ dispatch_agent: z.boolean().default(true),
408
+ instructions: z.string().min(1).max(MAX_SESSION_INSTRUCTIONS_CHARS).optional(),
409
+ initial_context: z.array(liveKitInitialContextMessageSchema).max(32).default([]),
410
+ initial_say: z.string().min(1).max(1e3).optional(),
411
+ llm: llmConfigSchema.nullable().optional(),
412
+ max_session_seconds: z.number().int().min(1).max(DEFAULT_MAX_SESSION_SECONDS).optional(),
413
+ participant_identity: z.string().min(1).max(160).optional(),
414
+ participant_name: z.string().max(160).optional(),
415
+ queue_ticket_id: z.string().min(1).max(160).optional(),
416
+ portrait_url: nullableUrlSchema.optional(),
417
+ room_name: z.string().min(1).max(160).optional(),
418
+ source_kind: avatarSourceKindSchema.default("portrait"),
419
+ source_video_url: nullableUrlSchema.optional(),
420
+ stt_mode: liveKitSttModeSchema.default("server"),
421
+ video_cache_id: z.string().min(1).max(240).nullable().optional(),
422
+ voice: voiceSpecSchema.nullable().optional(),
423
+ voice_id: z.string().min(1).max(240).nullable().optional(),
424
+ // Deliberately UNBOUNDED. This object is `.strict()`, so it rejects rather than trims:
425
+ // a count cap here does not mean "use fewer clips", it means "there is no call". How
426
+ // many a session actually warms is decided where the clips are loaded, and loading
427
+ // fewer is always safe — so the wire must not hold a number too.
428
+ clip_library: z.array(sessionClipSchema).optional(),
429
+ choreography: sessionChoreographySchema.optional(),
430
+ behavior: sessionBehaviorSchema.optional(),
431
+ expression_profile: z.string().min(1).max(40).optional(),
432
+ render_backend: renderBackendSchema.optional(),
433
+ support_edits: sessionSupportEditsSchema.optional(),
434
+ transcript_webhook: transcriptWebhookSchema.optional(),
435
+ client_metadata: clientMetadataSchema.optional()
436
+ }).strict().superRefine((value, ctx) => {
437
+ if (value.support_edits && value.render_backend === "generative") {
438
+ ctx.addIssue({
439
+ code: "custom",
440
+ message: "support_edits needs a source video to edit; it cannot be combined with render_backend='generative'",
441
+ path: ["support_edits"]
442
+ });
443
+ }
444
+ if (value.support_edits && value.mode === "voice") {
445
+ ctx.addIssue({
446
+ code: "custom",
447
+ message: "support_edits needs a video session; it cannot be combined with mode='voice'",
448
+ path: ["support_edits"]
449
+ });
450
+ }
451
+ if (value.source_kind === "portrait") {
452
+ if (value.source_video_url || value.video_cache_id) {
453
+ ctx.addIssue({
454
+ code: "custom",
455
+ message: "source_video_url/video_cache_id require source_kind='source_video'",
456
+ path: ["source_kind"]
457
+ });
458
+ }
459
+ return;
460
+ }
461
+ if (value.portrait_url) {
462
+ ctx.addIssue({
463
+ code: "custom",
464
+ message: "portrait_url cannot be combined with source_kind='source_video'",
465
+ path: ["portrait_url"]
466
+ });
467
+ }
468
+ if (!value.source_video_url && !value.video_cache_id) {
469
+ ctx.addIssue({
470
+ code: "custom",
471
+ message: "source_kind='source_video' requires source_video_url or video_cache_id",
472
+ path: ["source_video_url"]
473
+ });
474
+ }
475
+ });
476
+ z.object({
477
+ avatarId: z.string().min(1).max(160),
478
+ backgroundId: z.string().min(1).max(160).default(DEFAULT_BACKGROUND_ID),
479
+ mode: sessionModeSchema.default(DEFAULT_SESSION_MODE),
480
+ createRoom: z.boolean().default(true),
481
+ dispatchAgent: z.boolean().default(true),
482
+ instructions: z.string().min(1).max(MAX_SESSION_INSTRUCTIONS_CHARS).optional(),
483
+ initialContext: z.array(liveKitInitialContextMessageSchema).max(32).default([]),
484
+ initialSay: z.string().min(1).max(1e3).optional(),
485
+ llm: llmSelectionSchema.nullable().optional(),
486
+ maxSessionSeconds: z.number().int().min(1).max(DEFAULT_MAX_SESSION_SECONDS).optional(),
487
+ participantIdentity: z.string().min(1).max(160).optional(),
488
+ participantName: z.string().max(160).optional(),
489
+ queueTicketId: z.string().min(1).max(160).optional(),
490
+ roomName: z.string().min(1).max(160).optional(),
491
+ sttMode: liveKitSttModeSchema.default("server"),
492
+ voice: voiceSpecSchema.nullable().optional(),
493
+ voiceId: z.string().min(1).max(240).nullable().optional(),
494
+ // Unbounded, for the same reason as `clip_library` on the wire schema above.
495
+ clipLibrary: z.array(sessionClipSchema).optional(),
496
+ behavior: sessionBehaviorSchema.optional(),
497
+ renderBackend: renderBackendSchema.optional(),
498
+ supportEdits: sessionSupportEditsSchema.optional(),
499
+ transcriptWebhook: transcriptWebhookSchema.optional(),
500
+ clientMetadata: clientMetadataSchema.optional()
501
+ }).strict();
502
+ var liveKitSessionGrantSchema = z.object({
503
+ recording: recordingArtifactSchema.optional(),
504
+ status: z.literal("ready").default("ready"),
505
+ session_id: z.string().min(1),
506
+ room_name: z.string().min(1),
507
+ livekit_url: z.string().min(1),
508
+ participant_token: z.string().min(1),
509
+ participant_identity: z.string().min(1),
510
+ reservation_expires_at: z.string().datetime({ offset: true }),
511
+ stt_mode: liveKitSttModeSchema.default("server"),
512
+ room_created: z.boolean().default(false),
513
+ dispatch_created: z.boolean().default(false),
514
+ join_timeout_seconds: z.number().int().nonnegative().default(0),
515
+ idle_timeout_seconds: z.number().int().nonnegative().default(0),
516
+ max_session_seconds: z.number().int().nonnegative().default(0)
517
+ }).passthrough();
518
+ var sessionEndReasonSchema = z.enum([
519
+ "user_ended",
520
+ "session_cap",
521
+ "idle",
522
+ "disconnected",
523
+ "out_of_credits",
524
+ "agent_ended",
525
+ "failed"
526
+ ]);
527
+ var approachingEndReasonSchema = z.enum(["session_cap", "idle"]);
528
+ var sessionClockFrameSchema = z.object({
529
+ kind: z.literal("session_clock"),
530
+ started_at_unix_ms: z.number().int().nonnegative(),
531
+ max_session_seconds: z.number().int().nonnegative(),
532
+ idle_timeout_seconds: z.number().int().nonnegative()
533
+ }).strict();
534
+ var endingFrameSchema = z.object({ kind: z.literal("ending"), reason: approachingEndReasonSchema }).strict();
535
+ var closingTurnDoneFrameSchema = z.object({ kind: z.literal("closing_turn_done"), turn_id: z.string().min(1) }).strict();
536
+ var endedFrameSchema = z.object({ kind: z.literal("ended"), reason: sessionEndReasonSchema }).strict();
537
+ var behaviorStateFrameSchema = z.object({
538
+ kind: z.literal("behavior_state"),
539
+ state: z.string().min(1).max(32),
540
+ clip_id: z.string().min(1).max(64).optional(),
541
+ trigger: clipTriggerSchema.optional(),
542
+ loop: z.boolean().optional(),
543
+ prev_clip_id: z.string().min(1).max(64).optional()
544
+ }).strip();
545
+ var clipAckFrameSchema = z.object({
546
+ kind: z.literal("clip_ack"),
547
+ request_id: z.string().max(64),
548
+ accepted: z.boolean(),
549
+ reason: z.string().max(64)
550
+ }).strip();
551
+ z.discriminatedUnion("kind", [
552
+ sessionClockFrameSchema,
553
+ endingFrameSchema,
554
+ closingTurnDoneFrameSchema,
555
+ endedFrameSchema,
556
+ behaviorStateFrameSchema,
557
+ clipAckFrameSchema
558
+ ]);
559
+ var liveKitCapacitySnapshotSchema = z.object({
560
+ // Placement identity + per-worker session ceiling. These are always present on the
561
+ // wire (the platform serializes them and they come back on the grant, so a customer
562
+ // already sees them) but are typed OPTIONAL here on purpose: this SDK is the defensive
563
+ // READER of that wire, so a consumer must tolerate a response variant that omits them
564
+ // rather than hard-fail parse. `max_sessions_per_gpu` is how many sessions one waking
565
+ // worker serves — a queue-depth estimate input a consumer reads off the busy response.
566
+ capacity_pool: z.string().min(1).optional(),
567
+ agent_name: z.string().min(1).optional(),
568
+ max_sessions: z.number().int().nonnegative(),
569
+ max_sessions_per_gpu: z.number().int().positive().optional(),
570
+ worker_count: z.number().int().nonnegative(),
571
+ active_sessions: z.number().int().nonnegative(),
572
+ reserved_sessions: z.number().int().nonnegative(),
573
+ observed_worker_active_sessions: z.number().int().nonnegative(),
574
+ available_sessions: z.number().int().nonnegative(),
575
+ queue_size: z.number().int().nonnegative(),
576
+ admission_open: z.boolean(),
577
+ recommended_retry_ms: z.number().int().nonnegative(),
578
+ load: z.number().min(0).max(1)
579
+ }).passthrough();
580
+ z.object({
581
+ message: z.string().min(1),
582
+ capacity: liveKitCapacitySnapshotSchema,
583
+ queue_size: z.number().int().nonnegative(),
584
+ queue_ticket_id: z.string().min(1).optional(),
585
+ queue_position: z.number().int().positive().optional(),
586
+ recommended_retry_ms: z.number().int().nonnegative()
587
+ }).strict();
588
+ z.enum([
589
+ "page_hide",
590
+ "disconnected",
591
+ "superseded",
592
+ "unmount",
593
+ "manual",
594
+ "idle_timeout"
595
+ ]);
236
596
 
237
597
  // ../http-client/src/client.ts
238
598
  var DEFAULT_BASE_URL = "https://realtimeavatar.ai/api/v1";
239
- var SDK_VERSION = "0.14.0";
599
+ var SDK_VERSION = "0.16.0";
240
600
  var RealtimeAvatar = class {
241
601
  #apiKey;
242
602
  #baseUrl;
@@ -281,6 +641,7 @@ var RealtimeAvatar = class {
281
641
  if (options.maxSeconds !== void 0) body.max_session_seconds = Math.floor(options.maxSeconds);
282
642
  if (options.voice !== void 0) body.voice = options.voice;
283
643
  if (options.metadata !== void 0) body.client_metadata = options.metadata;
644
+ if (options.recording !== void 0) body.recording = recordingModeSchema.parse(options.recording);
284
645
  if (options.clientTools) body.capabilities = ["client_tools"];
285
646
  if (options.transcript !== void 0) {
286
647
  body.transcript_webhook = { url: options.transcript.url, secret: options.transcript.secret };
@@ -299,20 +660,23 @@ var RealtimeAvatar = class {
299
660
  };
300
661
  }
301
662
  }
302
- const grant = await this.#json(response);
663
+ const raw = await this.#json(response);
664
+ const grant = liveKitSessionGrantSchema.parse(raw);
665
+ if (!isRecord(raw)) throw new RealtimeAvatarError("Invalid session grant");
303
666
  return {
304
667
  status: "ready",
305
- sessionId: String(grant.session_id),
306
- roomName: String(grant.room_name),
307
- livekitUrl: String(grant.livekit_url),
308
- participantToken: String(grant.participant_token),
309
- participantIdentity: String(grant.participant_identity),
310
- maxSessionSeconds: Number(grant.max_session_seconds ?? 0),
311
- idleTimeoutSeconds: Number(grant.idle_timeout_seconds ?? 0),
312
- reservationExpiresAt: String(grant.reservation_expires_at),
668
+ ...grant.recording === void 0 ? {} : { recording: grant.recording },
669
+ sessionId: grant.session_id,
670
+ roomName: grant.room_name,
671
+ livekitUrl: grant.livekit_url,
672
+ participantToken: grant.participant_token,
673
+ participantIdentity: grant.participant_identity,
674
+ maxSessionSeconds: grant.max_session_seconds,
675
+ idleTimeoutSeconds: grant.idle_timeout_seconds,
676
+ reservationExpiresAt: grant.reservation_expires_at,
313
677
  // The parsed fields above are for YOUR logic. Relay `raw` to the client untouched:
314
678
  // the browser SDK validates the grant strictly and rejects an added or renamed key.
315
- raw: grant
679
+ raw
316
680
  };
317
681
  }
318
682
  /**
@@ -632,6 +996,24 @@ var RealtimeAvatar = class {
632
996
  cursor = page.nextCursor ?? void 0;
633
997
  } while (cursor);
634
998
  }
999
+ /** Recording metadata for this account, optionally limited to one call. Requires recordings:read. */
1000
+ async listRecordings(options = {}) {
1001
+ const parsed = listRecordingsQuerySchema.parse(options);
1002
+ const query = new URLSearchParams();
1003
+ if (parsed.sessionId !== void 0) query.set("sessionId", parsed.sessionId);
1004
+ if (options.limit !== void 0) query.set("limit", String(parsed.limit));
1005
+ if (parsed.cursor !== void 0) query.set("cursor", parsed.cursor);
1006
+ const suffix = query.size ? `?${query}` : "";
1007
+ return listRecordingsResponseSchema.parse(await this.#json(await this.#request("GET", `/recordings${suffix}`)));
1008
+ }
1009
+ /** Current recording status. Final media can become ready after the call has ended. */
1010
+ async getRecording(recordingId) {
1011
+ return recordingArtifactSchema.parse(await this.#json(await this.#request("GET", `/recordings/${encodeURIComponent(recordingId)}`)));
1012
+ }
1013
+ /** Renewable playback access. Keep recordingId in storage; URLs expire at expiresAt. */
1014
+ async getRecordingAccess(recordingId) {
1015
+ return recordingAccessResponseSchema.parse(await this.#json(await this.#request("GET", `/recordings/${encodeURIComponent(recordingId)}/access`)));
1016
+ }
635
1017
  async creditBalance() {
636
1018
  return await this.#json(await this.#request("GET", "/credits/balance"));
637
1019
  }
@@ -811,4 +1193,4 @@ function isQueued(result) {
811
1193
  return "queued" in result;
812
1194
  }
813
1195
 
814
- export { RealtimeAvatar, RealtimeAvatarError, RealtimeAvatarHttpError, clipLibraryDeclarationSchema, isQueued, verifyTranscript };
1196
+ export { RealtimeAvatar, RealtimeAvatarError, RealtimeAvatarHttpError, clipLibraryDeclarationSchema, isQueued, listRecordingsResponseSchema, recordingAccessResponseSchema, recordingArtifactSchema, recordingModeSchema, verifyTranscript };
package/dist/express.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { P as ProxyConfig } from './types-CJcTowDB.js';
2
- import './types-B9GTrpx0.js';
1
+ import { P as ProxyConfig } from './types-Bphj69NC.js';
2
+ import './types-VOv0vsFs.js';
3
+ import 'zod';
3
4
 
4
5
  type Expressish = {
5
6
  method: string;
package/dist/express.js CHANGED
@@ -1,5 +1,5 @@
1
- import { createProxyHandler } from './chunk-B3AHTJ3J.js';
2
- import './chunk-UZQXZH2Z.js';
1
+ import { createProxyHandler } from './chunk-2TNFET3Q.js';
2
+ import './chunk-V26PE2M3.js';
3
3
 
4
4
  // ../proxy/src/express.ts
5
5
  function realtimeAvatarExpress(config) {
package/dist/hono.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { P as ProxyConfig } from './types-CJcTowDB.js';
2
- import './types-B9GTrpx0.js';
1
+ import { P as ProxyConfig } from './types-Bphj69NC.js';
2
+ import './types-VOv0vsFs.js';
3
+ import 'zod';
3
4
 
4
5
  /**
5
6
  * Hono (and anything else built on Fetch handlers — Workers, Bun, Deno).
package/dist/hono.js CHANGED
@@ -1,5 +1,5 @@
1
- import { createProxyHandler } from './chunk-B3AHTJ3J.js';
2
- import './chunk-UZQXZH2Z.js';
1
+ import { createProxyHandler } from './chunk-2TNFET3Q.js';
2
+ import './chunk-V26PE2M3.js';
3
3
 
4
4
  // ../proxy/src/hono.ts
5
5
  function realtimeAvatarHono(config) {