Skip to content
Open
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
96 changes: 96 additions & 0 deletions complex-agents/xai-patient-intake/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# xAI Patient Intake

A family-medicine front desk you can call: the agent identifies a caller against a chart,
books and moves appointments, answers practice-policy questions, collects pre-visit
clinical intake, and routes a possible emergency to urgent care — all in one conversation.

The speech pipeline is xAI end to end.

| Stage | Model |
| --- | --- |
| Speech to text | `xai/stt-1` |
| Reasoning | `xai/grok-4.3` (`reasoning_effort="none"`) |
| Text to speech | `xai/tts-1` (voice `carina`) |

> The clinic is an in-memory fake. No real patient data is involved, and nothing here is a
> medical device or a source of medical advice.

## Repository layout

| Directory | Description |
| --- | --- |
| `patient-intake-agent` | Python worker: one agent, one conversation, eight typed tools over an in-memory practice. |
| `frontend` | Next.js app that dispatches the worker and renders the conversation, transcript, and tool calls. |

Each directory has its own README with deeper notes on architecture and customization.

## Design

One agent, one conversation, one fixed tool surface — no handoffs, no task framework, no
workflow state machine. The model holds the conversation in its own context and passes
what it has learned to typed tools when it needs to read or change practice state.

| Tool | Purpose |
| --- | --- |
| `read_practice_information` | Read the complete published practice guide |
| `find_open_times` | Search real slots using typed patient and scheduling facts |
| `book_appointment` | Register a new patient when necessary and book their chosen slot |
| `manage_appointment` | List, cancel, or reschedule an existing appointment |
| `take_message` | Route a refill, results, billing, referral, nurse, or records request |
| `update_insurance` | Save details from a current insurance card |
| `record_previsit_intake` | Save one completed set of pre-visit answers |
| `record_emergency_escalation` | Record a possible emergency and end ordinary work |

Every tool re-verifies identity from its arguments rather than trusting remembered state,
so a caller can book a visit, report a symptom, and update insurance in any order without
a phase machine deciding what is allowed next.

Practice policy stays out of the prompt: `patient-intake-agent/src/clinic/practice_info/`
holds the published guide as Markdown, and one argument-free tool returns all of it,
leaving interpretation to the model instead of a category table.

## Quick start

1. **Configure LiveKit**
- Create or reuse a LiveKit Cloud project.
- Grab `LIVEKIT_URL`, `LIVEKIT_API_KEY`, and `LIVEKIT_API_SECRET`.
- The agent registers as `xai-patient-intake`; the frontend's dispatch allowlist in
`frontend/app/api/agent/connection_details/route.ts` maps to that name.

2. **Run the agent**

```bash
cd complex-agents/xai-patient-intake/patient-intake-agent
uv sync
cp .env.example .env.local # add your three LiveKit values
uv run python src/agent.py console # talk to it in the terminal, no browser needed
uv run python src/agent.py dev # or register the worker for the frontend
```

3. **Run the frontend**

```bash
cd ../frontend
pnpm install
cp .env.example .env.local # the same three values
pnpm dev
```

Visit http://localhost:3000/patient-intake and click the card. The route mints a token
with an explicit agent dispatch, so the worker joins the room the visitor just created.

4. **Try these**
- *"What are your hours?"* → `read_practice_information`
- *"I'd like to book an appointment"* → `find_open_times`, then `book_appointment`
- *"I need a refill on my lisinopril"* → `take_message`

## Tests

```bash
cd patient-intake-agent
uv run pytest tests/unit -q
```

The unit tests assert the exact eight tools assembled in production and exercise booking,
rescheduling, message routing, intake, and emergency handling against the in-memory clinic
without touching the network.
10 changes: 10 additions & 0 deletions complex-agents/xai-patient-intake/frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules/
.next/
.env
.env.local
next-env.d.ts
*.tsbuildinfo
.vercel
.env*
.DS_Store
.DS_Store
88 changes: 88 additions & 0 deletions complex-agents/xai-patient-intake/frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Reference agent demo

A standalone, deployable copy of the livekit.com `/agents` experience, carrying one agent so a
partner can talk to it without an account. This deployment ships **Patient Intake**, backed by the
`xai-patient-intake` worker.

The landing page shows the agent card. Clicking it morphs the card into a live conversation panel
— transcript, tool calls, mic controls — connected to the deployed agent.

## Run it

```bash
pnpm install
cp .env.example .env.local # fill in the three LiveKit values
pnpm dev
```

## Configuration

Three environment variables, all server-side:

| Variable | Value |
| --------------------- | ---------------------------------------------------------------- |
| `LIVEKIT_URL` | `wss://<project>.livekit.cloud` for the project hosting the agent |
| `LIVEKIT_API_KEY` | API key for that project |
| `LIVEKIT_API_SECRET` | API secret for that project |

The browser never sees these. `app/api/agent/connection_details/route.ts` mints a short-lived
participant token with an explicit agent dispatch, so the worker named in the token joins the room
the visitor just created.

**The worker must be deployed to the same LiveKit project as those credentials.**

## Adding another agent

The single card is data, not structure — the page renders whatever `AGENTS` holds.

1. Add an entry to `AGENTS` in `app/(showcase)/_components/agent-metadata.ts`. Its `name` is the
public identifier and its slug is the URL (`patient_intake` → `/patient-intake`).
2. Map that name to the deployed worker name in `AGENT_DISPATCH_NAMES` in
`app/api/agent/connection_details/route.ts`. This is an allowlist — a name that isn't in it is
rejected with a 400, so a visitor can't dispatch an arbitrary worker in your project.
3. Optionally give it an accent colour in `app/(showcase)/_components/agent-themes.ts`. Agents
without one use the default cyan.

Cards lay out as a centered row, so a second or third agent needs no layout change.

## How this relates to livekit.com

The components under `app/(showcase)/_components`, `components/agents-ui`, `components/ui`, and
`hooks/agents-ui` are copied from `apps/www` in the `livekit/web` monorepo. They are not a
rewrite — the intent is that a diff against the originals stays readable. The deliberate
differences:

- **Routing.** The showcase is the whole site, so an agent sits at `/patient-intake` rather than
`/agents/patient-intake`, and the page fills the viewport instead of reserving room for the
marketing header and footer.
- **Layout.** livekit.com pins cards into a three-column grid that only centers correctly at
exactly three. Here they are a centered row that works at any count.
- **Unknown agents.** livekit.com falls back to its homepage agent; this app returns a 400. There
is no general-purpose agent to fall back to, and silently connecting someone to the wrong agent
is worse than an error.

`@repo/bytes-core` and `@repo/bytes-react` are workspace-private and can't be installed here, so
the slice this page uses is vendored:

- `styles/bytes-colors.css`, `styles/bytes-core.css`, `styles/bytes-react.css` — the design token
layer, copied verbatim. `styles/tailwind.css` is `apps/www`'s entry point with its
monorepo-only imports removed.
- `components/bytes/` — `Button` copied as-is; `Badge` and `IconButton` reproduced with their
class strings intact but their unused `ToggleTip` paths dropped.
- `lib/utils.ts` — the `cn` from bytes-react, whose extended tailwind-merge config is load
bearing. Under a plain `twMerge` the badge's `text-mono-caps` loses to the `text-xs` from its
size variant and the badge renders in sans sentence case.

To refresh against upstream, re-copy the files and re-apply those differences.

## Smoke test

`scripts/smoke-test.mjs` drives a real browser through the whole path — card, click, token,
dispatch, and the agent's opening line — and fails loudly if the agent never speaks. It needs
Playwright, which is deliberately not a dependency of this app so it stays out of Vercel builds:

```bash
pnpm add -D playwright && pnpm exec playwright install chromium
pnpm dev &
node scripts/smoke-test.mjs # or BASE=https://your-deployment.vercel.app
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { redirect } from 'next/navigation';
import { resolveActiveAgent } from '../_components/agent-metadata';

// Validates the slug and redirects when invalid. The actual UI renders in ../layout.tsx (see its
// comment for why) — this component's own output is never shown.
export default async function AgentPage({ params }: { params: Promise<{ name?: string[] }> }) {
const { name } = await params;
const [slug, ...rest] = name ?? [];

// Temporary redirect, not a 404: an invalid or coming-soon slug today may be a valid agent
// tomorrow, so this shouldn't be cached as a permanent dead link.
if (slug !== undefined && (rest.length > 0 || !resolveActiveAgent(slug))) {
redirect('/');
}

return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
'use client';

import { useEffect, useState } from 'react';
import { type AgentState } from '@livekit/components-react';
import Link from 'next/link';
import { SiGithub } from '@icons-pack/react-simple-icons';
import { Badge, Button, cn } from '@/components/bytes';
import { motion } from 'motion/react';

import { agentAccentStyle } from '@/app/(showcase)/_components/agent-themes';
import { AgentAudioVisualizerGrid } from '@/components/voice-agent/agent-audio-visualizer-grid';
import { slugFromAgentName, type AgentMetadata } from './agent-metadata';
import { agentMorphName, MORPH_SPRING } from './utils';

const CARD_BASE =
'border-separator1 bg-bg1 flex lg:w-[23rem] max-w-full shrink-0 flex-col gap-6 rounded-xl border p-8 origin-center';

interface AgentCardProps {
agent: AgentMetadata;
onSelect: (reference: string) => void;
justExitedAgentName: string | null;
}

export function AgentCard({ agent, onSelect, justExitedAgentName }: AgentCardProps) {
const reference = slugFromAgentName(agent.name);
const accentStyle = agentAccentStyle(agent.name);
const justExited = agent.name === justExitedAgentName;
// True while this card's own content is fading out, before the parent swaps to the
// conversation view.
const [isLeaving, setIsLeaving] = useState(false);
// Gates the content behind the box's own resize completing when this card is reappearing
// from a just-closed conversation; otherwise there's nothing to wait for.
const [contentRevealed, setContentRevealed] = useState(!justExited);

const handleSelect = () => {
setIsLeaving(true);
// Let the content fade fully before the parent swaps to the conversation view — otherwise
// React batches both changes into one commit and the fade never gets a chance to paint.
setTimeout(() => onSelect(reference), 100);
};

useEffect(() => {
setTimeout(() => setContentRevealed(true), 150);
});

if (agent.comingSoon) {
return (
// Fades in/out as the grid itself mounts/unmounts around a conversation starting or
// ending — the parent AnimatePresence (in AgentShowcase.tsx) keeps this mounted just long
// enough to play `exit` before the grid is actually removed.
<motion.div
layout
key={agentMorphName(agent.name)}
layoutId={agentMorphName(agent.name)}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 0.5, scale: 0.99 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.3 }}
style={accentStyle}
className={cn(CARD_BASE, 'relative z-10')}
>
<Visualizer state="disconnected" />
<div className="flex flex-col gap-2">
<h2 className="text-fg0 text-xl font-semibold">{agent.title}</h2>
{agent.description && <p className="text-fg3 text-sm">{agent.description}</p>}
</div>
<Badge variant="muted" size="large" className="self-start">
Coming soon
</Badge>
</motion.div>
);
}

return (
<motion.div
layout
key={agentMorphName(agent.name)}
layoutId={agentMorphName(agent.name)}
transition={MORPH_SPRING}
className={cn(CARD_BASE, 'relative z-20')}
style={{ borderRadius: 12, ...accentStyle }}
>
<motion.div
className="flex flex-1 flex-col gap-6"
initial={false}
animate={{ opacity: isLeaving ? 0 : contentRevealed ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
<Visualizer state="connecting" />
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<h2 className="text-fg0 text-xl font-semibold">{agent.title}</h2>
{agent.headlineModel && (
<Badge variant="muted" size="large">
{agent.headlineModel}
</Badge>
)}
</div>
{agent.description && <p className="text-fg3 text-sm">{agent.description}</p>}
</div>
<div className="mt-auto flex flex-col gap-3">
<Button variant="primary" size="sm" className="w-full" onClick={handleSelect}>
Start conversation
</Button>
{agent.repoUrl && (
<Button variant="outline" size="sm" className="w-full" asChild>
<Link href={agent.repoUrl} target="_blank" rel="noopener noreferrer">
<SiGithub className="size-4" />
Clone agent
</Link>
</Button>
)}
</div>
</motion.div>
</motion.div>
);
}

interface VisualizerProps {
state: AgentState;
}

function Visualizer({ state }: VisualizerProps) {
return (
<div className="border-separator1 mx-auto grid w-fit place-content-center rounded-lg border p-3">
<AgentAudioVisualizerGrid
size="md"
radius={3}
interval={80}
rowCount={11}
columnCount={11}
state={state}
className="*:bg-bg3 *:data-[lk-highlighted=true]:bg-fgAccentPrimary1 aspect-square gap-[9px] *:size-[3px] *:rounded-none *:data-[lk-highlighted=true]:scale-125 *:data-[lk-highlighted=true]:shadow-[0px_0px_6.8px_2px_var(--agent-accent-glow)]"
/>
</div>
);
}
Loading