Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,11 @@
# `OBSERVABILITY_ENABLED=1` uses the same unified switch as the backend. When
# enabled by the LexVoice runtime, browser-side probes publish LiveKit data
# packets for the local observability report.

# AgentWidget host delivery is authenticated server-to-server. Use the same
# high-entropy channel id in the host tool and the browser's
# `?agentwidgetChannel=...` query; never expose this token to the browser.
# AGENTWIDGET_HOST_TOKEN=
# AGENTWIDGET_COMPOSER_API_KEY=
# AGENTWIDGET_COMPOSER_BASE_URL=
# AGENTWIDGET_COMPOSER_MODEL_ID=
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ Also available for:
### Features:

- Real-time voice interaction with LiveKit Agents
- AgentWidget surfaces delivered from Codex, MCP, or another host agent
- Camera video streaming support
- Screen sharing capabilities
- Audio visualization and level monitoring
- Virtual avatar integration
- Light/dark theme switching with system preference detection
- Customizable branding, colors, and UI text via configuration

This template is built with Next.js and is free for you to use or modify as you see fit.
Expand Down Expand Up @@ -91,6 +91,29 @@ And open http://localhost:3000 in your browser.
You'll also need a LiveKit server and an agent worker. In integrated workspaces,
those are normally provided by the LexVoice project.

### AgentWidget AI frontdesk

The main page composes AgentWidget presentation over the existing
`SessionProvider` and its one LiveKit room. It does not create a second LexVoice
connection. A host tool publishes an established result to:

```text
POST /api/agentwidget/spawn
Authorization: Bearer $AGENTWIDGET_HOST_TOKEN
x-agentwidget-channel-id: <high-entropy channel id>
```

Open the UI with the same channel as
`?agentwidgetChannel=<high-entropy channel id>`. The browser subscribes to the
surface stream and renders the SDK Catalog; it never receives the host token.
Set `AGENTWIDGET_HOST_TOKEN` on the Next.js server. Unknown result shapes also
require the server-side `AGENTWIDGET_COMPOSER_*` variables documented by the
AgentWidget SDK.

Surface delivery is currently process-local, like the session lifecycle API.
Deploy `/api/agentwidget/*` on one Next.js instance or with sticky routing until
the channel hub is replaced by shared pub/sub.

## Configuration

This starter is designed to be flexible so you can adapt it to your specific agent use case. You can easily configure it to work with different types of inputs and outputs:
Expand Down
6 changes: 3 additions & 3 deletions app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function resolveInputDeviceConfig({
usesServerRoomInput,
supportsScreenShare: usesBrowserRawVideoInput ? false : APP_CONFIG_DEFAULTS.supportsScreenShare,
showDefaultCameraPreview: usesBrowserRawVideoInput
? false
? !usesServerRoomInput
: (APP_CONFIG_DEFAULTS.showDefaultCameraPreview ?? true),
};
}
Expand Down Expand Up @@ -193,8 +193,8 @@ export function buildDefaultVideoTracks(
];
}

export function getDefaultVideoTrack(): string {
return ROOM_INPUT_VIDEO_TRACK_NAME;
export function getDefaultVideoTrack(isBrowserInput = false): string {
return isBrowserInput ? BROWSER_VIDEO_TRACK_NAME : ROOM_INPUT_VIDEO_TRACK_NAME;
}

export const APP_CONFIG_DEFAULTS: AppConfig = {
Expand Down
43 changes: 2 additions & 41 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,7 @@
import { headers } from 'next/headers';
import { getAppConfig } from '@/lib/utils';

interface LayoutProps {
children: React.ReactNode;
}

export default async function Layout({ children }: LayoutProps) {
const hdrs = await headers();
const { companyName, logo, logoDark } = await getAppConfig(hdrs);

return (
<>
<header className="fixed top-0 left-0 z-50 hidden w-full flex-row justify-between p-6 md:flex">
<a
target="_blank"
rel="noopener noreferrer"
href="https://livekit.io"
className="scale-100 transition-transform duration-300 hover:scale-110"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logo} alt={`${companyName} Logo`} className="block size-6 dark:hidden" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoDark ?? logo}
alt={`${companyName} Logo`}
className="hidden size-6 dark:block"
/>
</a>
<span className="text-foreground font-mono text-xs font-bold tracking-wider uppercase">
Built with{' '}
<a
target="_blank"
rel="noopener noreferrer"
href="https://docs.livekit.io/agents"
className="underline underline-offset-4"
>
Lexmount Agent Studio
</a>
</span>
</header>

{children}
</>
);
export default function Layout({ children }: LayoutProps) {
return children;
}
72 changes: 72 additions & 0 deletions app/api/agentwidget/spawn/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { timingSafeEqual } from 'node:crypto';
import {
AgentWidgetComposerError,
createOpenAICompatibleWidgetComposerFromEnv,
createSpawnWidgetResult,
} from '@lexmount/agentwidget-sdk/host';
import {
getAgentWidgetSurfaceChannel,
parseAgentWidgetChannelId,
} from '@/lib/agentwidget/surface-channel';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const CHANNEL_HEADER = 'x-agentwidget-channel-id';
const MAX_BODY_BYTES = 16 * 1024;
let composer;

function json(payload, status = 200) {
return Response.json(payload, {
status,
headers: { 'cache-control': 'no-store' },
});
}

function hasValidBearer(request, expectedToken) {
if (!expectedToken) return true;
const supplied = request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ?? '';
const expected = Buffer.from(expectedToken);
const actual = Buffer.from(supplied);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}

export async function POST(request) {
const channelHeader = request.headers.get(CHANNEL_HEADER);
const token = process.env.AGENTWIDGET_HOST_TOKEN;
if (channelHeader && !token) return json({ error: 'HOST_TOKEN_NOT_CONFIGURED' }, 503);
if (!hasValidBearer(request, token)) return json({ error: 'UNAUTHORIZED' }, 401);

try {
if (request.headers.get('content-type')?.split(';', 1)[0] !== 'application/json') {
return json({ error: 'UNSUPPORTED_MEDIA_TYPE' }, 415);
}
const text = await request.text();
if (!text || Buffer.byteLength(text, 'utf8') > MAX_BODY_BYTES) {
return json({ error: 'INVALID_REQUEST' }, 400);
}
const input = JSON.parse(text);
const result = await createSpawnWidgetResult(input, {
getComposer: () => {
composer ??= createOpenAICompatibleWidgetComposerFromEnv(process.env);
return composer;
},
});
if (channelHeader) {
getAgentWidgetSurfaceChannel().publish(
parseAgentWidgetChannelId(channelHeader),
result.structuredContent
);
}
return json({ structuredContent: result.structuredContent });
} catch (error) {
if (error instanceof AgentWidgetComposerError) {
return json({ error: error.code }, error.code === 'MODEL_NOT_CONFIGURED' ? 503 : 502);
}
if (error instanceof SyntaxError || error instanceof TypeError || error?.name === 'ZodError') {
return json({ error: 'INVALID_REQUEST' }, 400);
}
console.error('[agentwidget] spawn failed', error);
return json({ error: 'COMPOSER_FAILED' }, 500);
}
}
53 changes: 53 additions & 0 deletions app/api/agentwidget/surfaces/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
getAgentWidgetSurfaceChannel,
parseAgentWidgetChannelId,
} from '@/lib/agentwidget/surface-channel';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const encoder = new TextEncoder();

export function GET(request) {
let channelId;
try {
channelId = parseAgentWidgetChannelId(new URL(request.url).searchParams.get('channel'));
} catch {
return Response.json({ error: 'INVALID_CHANNEL' }, { status: 400 });
}

let close = () => undefined;
const stream = new ReadableStream({
start(controller) {
let closed = false;
const send = (value) => {
if (!closed) controller.enqueue(encoder.encode(value));
};
send('retry: 1000\n\n');
const unsubscribe = getAgentWidgetSurfaceChannel().subscribe(channelId, (envelope) => {
send(`event: surface\ndata: ${JSON.stringify(envelope)}\n\n`);
});
const heartbeat = setInterval(() => send(': keep-alive\n\n'), 15_000);
close = () => {
if (closed) return;
closed = true;
clearInterval(heartbeat);
unsubscribe();
controller.close();
};
request.signal.addEventListener('abort', close, { once: true });
},
cancel() {
close();
},
});

return new Response(stream, {
headers: {
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
'content-type': 'text/event-stream; charset=utf-8',
'x-accel-buffering': 'no',
},
});
}
11 changes: 4 additions & 7 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { ApplyThemeScript, ThemeToggle } from '@/components/app/theme-toggle';
import '@lexmount/agentwidget-sdk/styles.css';
import { ApplyThemeScript } from '@/components/app/theme-toggle';
import { cn, getAppConfig, getStyles } from '@/lib/utils';
import '@/styles/agentwidget-frontdesk.css';
import '@/styles/globals.css';

const metadataBaseUrl =
Expand Down Expand Up @@ -30,12 +32,7 @@ export default async function RootLayout({ children }: RootLayoutProps) {
<meta name="description" content={pageDescription} />
<ApplyThemeScript />
</head>
<body className="overflow-x-hidden">
{children}
<div className="group fixed bottom-0 left-1/2 z-50 mb-2 -translate-x-1/2">
<ThemeToggle className="translate-y-20 transition-transform delay-150 duration-300 group-hover:translate-y-0" />
</div>
</body>
<body className="overflow-x-hidden">{children}</body>
</html>
);
}
Loading
Loading