Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
696996a
Add LiveAvatar sandbox gateway
jiejuncai-ly Jun 18, 2026
238b839
Remove generated sandbox flow PNG
jiejuncai-ly Jun 24, 2026
3eaf0a9
Fix sandbox session lint warning
jiejuncai-ly Jun 24, 2026
e4726e0
Fix sandbox gateway lifecycle leaks
jiejuncai-ly Jun 24, 2026
62f1e8d
Fix proxied fetch response headers
jiejuncai-ly Jun 24, 2026
561f52f
Preserve gateway agent name by default
jiejuncai-ly Jun 24, 2026
abf76c2
Update sandbox template defaults
jiejuncai-ly Jun 24, 2026
94ac1a4
Fix sandbox gateway review issues
jiejuncai-ly Jun 24, 2026
7a823d1
Remove frontend sandbox gateway runtime
jiejuncai-ly Jun 24, 2026
4ca0a8e
Address frontend gateway review nits
jiejuncai-ly Jun 24, 2026
be4b490
Tighten frontend review cleanup
jiejuncai-ly Jun 24, 2026
e77af79
Narrow legacy gateway ignores
jiejuncai-ly Jun 24, 2026
13fac5f
Clarify LexVoice gateway doc ownership
jiejuncai-ly Jun 24, 2026
c9384db
Clarify gateway env example ownership
jiejuncai-ly Jun 24, 2026
e6a9a18
Remove redundant LexVoice path assertion
jiejuncai-ly Jun 24, 2026
7ff2d34
Remove redundant LexVoice path assertion
jiejuncai-ly Jun 24, 2026
bd2ac02
release gateway sandbox on session stop
jiejuncai-ly Jun 30, 2026
88df01b
fix gateway sandbox release cleanup
jiejuncai-ly Jun 30, 2026
555c322
harden gateway release path handling
jiejuncai-ly Jun 30, 2026
6d40cea
Use gateway voice session id
jiejuncai-ly Jun 30, 2026
86b3c0b
Align room input identity test
jiejuncai-ly Jul 1, 2026
5ad0c8c
Harden sandbox session release flow
jiejuncai-ly Jul 2, 2026
0066631
Merge origin/lex-main into sync/sandbox-gateway-on-lex-main-20260618
jiejuncai-ly Jul 2, 2026
95b1d50
Address sandbox session review nits
jiejuncai-ly Jul 2, 2026
6fb21d2
Require room video readiness before reusing agent dispatch
Jul 3, 2026
d837d33
Keep sandbox alive during page refresh
jiejuncai-ly Jul 3, 2026
cc916d1
Stop releasing sandboxes from client session stops
jiejuncai-ly Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# This file is intentionally documentation-only.
#
# Integrated LexVoice runs should not configure frontend variables here.
# Use `../lex-voice/.env` as the single source of truth; `lex-voice/run.sh`
# injects LiveKit, room-input, input-source, role-device, agent, media, and
# debug settings into the frontend process when it starts `make start_ui`.
# Use the LexVoice repository `.env` as the single source of truth; its `run.sh`
# injects LiveKit, room-input, input-source, role-device, agent, media, and debug
# settings into the frontend process when it starts `make start_ui`.
#
# Only create `agent-starter-react/.env.local` for standalone frontend
# development where this repository is launched directly with `pnpm dev`.
# In that case, define only the variables needed for that standalone run.

# For sandbox gateway deployment, see the LexVoice repository's
# `deploy/liveavatar_gateway/.env.example.gateway` and
# `deploy/liveavatar_gateway/.env.example.sandbox` reference files.
#
# `OBSERVABILITY_ENABLED=1` uses the same unified switch as the backend. When
# enabled by the LexVoice runtime, browser-side probes publish LiveKit data
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
# testing
/coverage

# Legacy sandbox gateway artifacts from pre-migration local runs
/.sandbox-gateway/
/logs/direct-proxy.log
/logs/sandbox-gateway*.log
/logs/sandbox-gateway.pid

# next.js
/.next/
/out/
Expand Down
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,27 @@ Run the following command to automatically clone this template.
lk app create --template agent-starter-react
```

For integrated LexVoice runs, configure `../lex-voice/.env` and start the
frontend through the LexVoice runtime scripts. `../lex-voice/run.sh` injects
LiveKit, room-input, input-source, role-device, agent, media, and debug settings
into this Next.js process.
For integrated LexVoice runs, configure the LexVoice repository `.env` and start
the frontend through the LexVoice runtime scripts. The LexVoice repository's
`run.sh` injects LiveKit, room-input, input-source, role-device, agent, media,
and debug settings into this Next.js process.

The session lifecycle API keeps start/stop state in memory, so integrated
deployments should route `/api/session/*` to a single Next.js instance or sticky routing.
If you replace the custom connection details endpoint, it must echo the requested
`sessionId` and derive the same room name so dispatch and stop calls coordinate
with the connected room.

### LiveAvatar Gateway Deployments

Sandbox-backed public deployments are owned by the LexVoice repository. Set
`LIVEAVATAR_USE_SANDBOX=1` in the LexVoice repository `.env` and configure
broker, template, warm pool, and `SANDBOX_ENV_*` values in the LexVoice repository's
`deploy/liveavatar_gateway/.env`.

This frontend repository only runs the Next.js UI. It does not create, release,
or warm sandbox sessions.

For standalone frontend development, install dependencies and run the dev
server directly:

Expand All @@ -78,8 +88,8 @@ pnpm dev

And open http://localhost:3000 in your browser.

You'll also need a LiveKit server and an agent worker. In this workspace, those
are normally provided by the sibling `../lex-voice` project.
You'll also need a LiveKit server and an agent worker. In integrated workspaces,
those are normally provided by the LexVoice project.

## Configuration

Expand Down Expand Up @@ -118,7 +128,7 @@ You can update these values in [`app-config.ts`](./app-config.ts) to customize b

#### Environment Variables

Integrated runs should keep runtime variables in `../lex-voice/.env`; this
Integrated runs should keep runtime variables in the LexVoice repository `.env`; this
repository's `.env.example` is documentation-only. Only create
`agent-starter-react/.env.local` for standalone frontend development launched
directly with `pnpm dev`.
Expand All @@ -130,7 +140,7 @@ LIVEKIT_URL=https://your-livekit-server-url
```

The frontend defaults to the browser camera/microphone input when no input
source is provided. Configure `INPUT_SOURCE` only in `../lex-voice/.env` for
source is provided. Configure `INPUT_SOURCE` only in the LexVoice repository `.env` for
integrated backend runs. The LiveKit variables above are required for
standalone voice agent functionality to work with your LiveKit project.

Expand Down
2 changes: 2 additions & 0 deletions app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface AppConfig {

// for LiveKit Cloud Sandbox
sandboxId?: string;
voiceSessionId?: string;
agentName?: string;
inputSource?: string;
audioInputDevice?: string;
Expand Down Expand Up @@ -227,6 +228,7 @@ export const APP_CONFIG_DEFAULTS: AppConfig = {

// for LiveKit Cloud Sandbox
sandboxId: undefined,
voiceSessionId: undefined,
agentName: undefined,
inputSource: undefined,
audioInputDevice: undefined,
Expand Down
100 changes: 53 additions & 47 deletions app/api/session/dispatch/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { NextResponse } from 'next/server';
import { AgentDispatchClient, RoomServiceClient } from 'livekit-server-sdk';
import { type ParticipantInfo, ParticipantInfo_Kind } from '@livekit/protocol';
import { type ParticipantInfo } from '@livekit/protocol';
import {
deriveLiveKitRoomName,
deriveSessionIdFromLiveKitRoomName,
isValidConnectionRoomId,
} from '@/lib/connection-room-id';
import {
type AgentParticipantMatchOptions,
findAgentParticipantInList,
findReusableAgentParticipant as findReusableAgentParticipantInList,
} from '@/lib/session-dispatch-readiness';
import { resolveLiveKitHttpUrl } from '@/lib/session-stop';
import {
type RoomSessionToken,
Expand All @@ -23,10 +28,6 @@ const AGENT_DISPATCH_POLL_MS = readPositiveIntEnv('AGENT_DISPATCH_POLL_MS', 200)
export const runtime = 'nodejs';
export const revalidate = 0;

type AgentParticipantMatchOptions = {
allowAnonymousLiveKitAgentFallback?: boolean;
};

class RoomSessionCancelledError extends Error {
constructor(session: RoomSessionToken) {
super(
Expand Down Expand Up @@ -157,11 +158,15 @@ async function createAgentDispatchWithRetry(
try {
throwIfSessionCancelled(session);

const alreadyJoined = await roomHasAgentParticipant(roomClient, roomName, agentName);
const alreadyJoined = await findReusableAgentParticipant(roomClient, roomName, agentName);
throwIfSessionCancelled(session);
if (alreadyJoined) {
markRoomSessionRunning(session);
return { attempts, alreadyJoined: true };
return {
attempts,
alreadyJoined: true,
agentParticipant: summarizeAgentParticipant(alreadyJoined),
};
}

const dispatch = await dispatchClient.createDispatch(roomName, agentName);
Expand All @@ -174,21 +179,28 @@ async function createAgentDispatchWithRetry(
throw new RoomSessionCancelledError(session);
}

if (
await waitForAgentParticipant(
roomClient,
roomName,
agentName,
remainingDispatchTime(startedAt),
session
)
) {
// A successful LiveKit dispatch often needs multiple seconds before the
// agent worker joins the room. Wait for the full remaining session-start
// budget here; the retry loop is for API/listParticipants failures, not
// for repeatedly recreating a healthy dispatch every retry interval.
const agentParticipant = await waitForAgentParticipant(
roomClient,
roomName,
agentName,
remainingDispatchTime(startedAt),
session
);
if (agentParticipant) {
if (isRoomSessionCancelled(session)) {
await deleteLiveKitRoomQuietly(roomClient, roomName);
throw new RoomSessionCancelledError(session);
}
markRoomSessionRunning(session);
return { attempts, dispatchId: dispatch.id };
return {
attempts,
dispatchId: dispatch.id,
agentParticipant: summarizeAgentParticipant(agentParticipant),
};
}

lastError = new Error('agent participant did not join before retry');
Expand Down Expand Up @@ -231,17 +243,16 @@ async function waitForAgentParticipant(
maxWaitMs: number,
session: RoomSessionToken
) {
const deadline = Date.now() + Math.min(maxWaitMs, AGENT_DISPATCH_RETRY_MS);
const deadline = Date.now() + maxWaitMs;

do {
throwIfSessionCancelled(session);
if (
await roomHasAgentParticipant(roomClient, roomName, agentName, {
allowAnonymousLiveKitAgentFallback: true,
})
) {
const participant = await findAgentParticipant(roomClient, roomName, agentName, {
allowAnonymousLiveKitAgentFallback: true,
});
if (participant) {
throwIfSessionCancelled(session);
return true;
return participant;
}

const waitMs = Math.min(AGENT_DISPATCH_POLL_MS, deadline - Date.now());
Expand All @@ -251,43 +262,38 @@ async function waitForAgentParticipant(
} while (Date.now() < deadline);

throwIfSessionCancelled(session);
return roomHasAgentParticipant(roomClient, roomName, agentName, {
return findAgentParticipant(roomClient, roomName, agentName, {
allowAnonymousLiveKitAgentFallback: true,
});
}

async function roomHasAgentParticipant(
async function findAgentParticipant(
roomClient: RoomServiceClient,
roomName: string,
agentName: string,
options: AgentParticipantMatchOptions = {}
) {
const participants = await roomClient.listParticipants(roomName);
if (participants.some((participant) => isExpectedAgentParticipant(participant, agentName))) {
return true;
}
if (!options.allowAnonymousLiveKitAgentFallback) {
return false;
}

// Local LiveKit may omit agent attributes; fresh per-session rooms keep this fallback bounded.
const anonymousLiveKitAgents = participants.filter(isAnonymousLiveKitAgentParticipant);
return anonymousLiveKitAgents.length === 1;
return findAgentParticipantInList(participants, agentName, options);
}

function isExpectedAgentParticipant(participant: ParticipantInfo, agentName: string) {
const attributes = participant.attributes ?? {};
return attributes['lk.agent.name'] === agentName || attributes['lk.agent_name'] === agentName;
async function findReusableAgentParticipant(
roomClient: RoomServiceClient,
roomName: string,
agentName: string
) {
const participants = await roomClient.listParticipants(roomName);
return findReusableAgentParticipantInList(participants, agentName);
}

function isAnonymousLiveKitAgentParticipant(participant: ParticipantInfo) {
const attributes = participant.attributes ?? {};
return (
participant.kind === ParticipantInfo_Kind.AGENT &&
participant.identity.startsWith('agent-') &&
!attributes['lk.agent.name'] &&
!attributes['lk.agent_name']
);
function summarizeAgentParticipant(participant: ParticipantInfo | null) {
if (!participant) {
return null;
}

return {
identity: participant.identity,
};
}

async function deleteDispatchQuietly(
Expand Down
1 change: 1 addition & 0 deletions components/app/session-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const DEFAULT_BROWSER_SOURCE_CLIENT: BrowserSourceClient = {
videoTrack: null,
audioPending: false,
videoPending: false,
setAudioDeviceId: async () => {},
setAudioEnabled: async () => {},
setVideoEnabled: async () => {},
start: async () => {},
Expand Down
16 changes: 15 additions & 1 deletion components/livekit/agent-control-bar/agent-control-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ export function AgentControlBar({
[browserSourceClient, handleDeviceError]
);

const handleAudioDeviceSelect = useCallback(
(deviceId: string) => {
handleAudioDeviceChange(deviceId);
if (!usesBrowserRawAudioInput) {
return;
}

void browserSourceClient.setAudioDeviceId(deviceId).catch((error) => {
handleDeviceError({ source: Track.Source.Microphone, error });
});
},
[browserSourceClient, handleAudioDeviceChange, handleDeviceError, usesBrowserRawAudioInput]
);

const handleRawVideoToggle = useCallback(
async (enabled: boolean) => {
try {
Expand Down Expand Up @@ -195,7 +209,7 @@ export function AgentControlBar({
usesBrowserRawAudioInput ? handleRawMicrophoneToggle : microphoneToggle.toggle
}
onMediaDeviceError={handleMicrophoneDeviceSelectError}
onActiveDeviceChange={handleAudioDeviceChange}
onActiveDeviceChange={handleAudioDeviceSelect}
/>
)}

Expand Down
Loading
Loading