Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
13 changes: 8 additions & 5 deletions .github/workflows/deploy-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ jobs:
strategy:
fail-fast: false
matrix:
example: [healthcare, survey, frontdesk, drive-thru, inference, avatar, hotel_receptionist]
example: [healthcare, survey, frontdesk, drive_thru, inference, avatar, hotel_receptionist, expressive_agent]
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.ref || github.ref }}
# Fetch Git-LFS assets (e.g. examples/drive-thru/bg_noise.mp3) so the
# Fetch Git-LFS assets (e.g. examples/drive_thru/bg_noise.mp3) so the
# real binaries — not pointer files — get uploaded as the build context
# and copied into the image via `COPY . .`. This checkout authenticates
# with GITHUB_TOKEN, so its LFS fetch is allowed (unlike uv's anonymous
Expand Down Expand Up @@ -78,9 +78,12 @@ jobs:
# Build the agent against the code at the ref we're deploying,
# not the latest release on PyPI. Without this, a branch deploy
# would silently run against the published livekit-agents instead
# of the branch's own SDK changes. Mirror the checkout ref above,
# using ref_name so it's a plain branch/tag git can resolve.
DEPLOY_REF: ${{ inputs.ref || github.ref_name }}
# of the branch's own SDK changes.
#
# Defaults to the commit sha, not the branch name: the ref lands in
# the uv git source, so a branch name keeps the same `uv sync` layer
# cache key across pushes and the deploy quietly reships stale code.
DEPLOY_REF: ${{ inputs.ref || github.sha }}
run: |
python3 .github/pin_example_to_ref.py \
"examples/${{ matrix.example }}" --ref "$DEPLOY_REF"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/evals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
strategy:
fail-fast: false
matrix:
example: [frontdesk, drive-thru]
example: [frontdesk, drive_thru]

runs-on: ubuntu-latest
name: ${{ matrix.example }}
Expand Down
6 changes: 5 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@ A collection of voice-based agent examples, including basic voice interactions,

Demonstrates supervisor escalation workflows for call centers, showing how to implement warm transfers where agents can brief supervisors before connecting them to customers.

### 🚗 [Drive-Thru](./drive-thru/)
### 🚗 [Drive-Thru](./drive_thru/)

A complete drive-thru ordering system example that showcases interactive voice agents for food ordering with database integration and order management.

### 🎭 [Expressive Agent](./expressive_agent/)

A free-form companion agent running Expressive Mode. Tell it good news or bad news and hear the delivery change: emotion, pacing, and non-verbal sounds, all chosen from context.

### 🏢 [Front Desk](./frontdesk/)

A front desk agent example demonstrating how to build customer service agents with calendar integration and appointment management capabilities.
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ The full menu is loaded once per session and injected directly into the agent's
### Menu Loading

At the start of each session, `new_userdata()` queries `FakeDB` for all item categories (drinks, combos, Happy Meals, regulars, sauces) and stores them in the `Userdata` dataclass alongside a fresh `OrderState`.
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive-thru/agent.py#L382-L399
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive_thru/agent.py#L382-L399
`DriveThruAgent.__init__` then formats each category using `menu_instructions()` and concatenates the results with `COMMON_INSTRUCTIONS` to build the full system prompt. This means the LLM sees the entire menu from the first turn and can answer questions or suggest items without any tool calls.
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive-thru/agent.py#L55-L83
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive_thru/agent.py#L55-L83
### Dynamic Tool Building

The three ordering tools are constructed by `build_combo_order_tool`, `build_happy_order_tool`, and `build_regular_order_tool`. Each method closes over the relevant item lists and injects their IDs as the `enum` constraint in the tool's JSON schema.

https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive-thru/agent.py#L85-L119
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive_thru/agent.py#L85-L119

This restricts the LLM to known IDs at the schema layer before any runtime logic runs. `ToolError` handles the cases that can't be caught statically — for example, when a drink has multiple available sizes and the customer hasn't specified one yet, the tool raises a `ToolError` prompting the agent to ask for clarification before retrying.

Expand All @@ -29,7 +29,7 @@ This restricts the LLM to known IDs at the schema layer before any runtime logic
`order.py` defines three Pydantic models: `OrderedCombo`, `OrderedHappy`, and `OrderedRegular` . A discriminated union `OrderedItem` is also defined. Each ordered item receives a random short `order_id` on creation via `order_uid()`.

`OrderState` stores the current cart as a `dict[str, OrderedItem]` keyed by `order_id`, which the `remove_order_item` and `list_order_items` tools use to look up or modify existing items.
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive-thru/order.py#L45-L56
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive_thru/order.py#L45-L56

### Managing the Order

Expand All @@ -43,9 +43,9 @@ Two tools handle cart management:
### Background Audio

`BackgroundAudioPlayer` plays an ambient drive-thru noise track (`bg_noise.mp3`) throughout the session to set the scene.
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive-thru/agent.py#L438-L443
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive_thru/agent.py#L438-L443

### STT Tuning

The STT model is also initialized with `keyterm` hints for McDonald's brand names (e.g., `"Big Mac"`, `"McFlurry"`, `"McCrispy"`) to improve transcription accuracy.
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive-thru/agent.py#L415-L430
https://github.com/livekit/agents/blob/8283a5a5c9863a07bcf030ee90e8ab780e1e569b/examples/drive_thru/agent.py#L415-L430
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
48 changes: 48 additions & 0 deletions examples/expressive_agent/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Python bytecode and artifacts
**/__pycache__/
**/*.py[cod]
**/*.pyo
**/*.pyd
**/*.egg-info/
**/dist/
**/build/

# Virtual environments
**/.venv/
**/venv/

# Caches and test output
**/.cache/
**/.pytest_cache/
**/.ruff_cache/
**/coverage/

# Logs and temp files
**/*.log
**/*.gz
**/*.tgz
**/.tmp
**/.cache

# Environment variables
**/.env
**/.env.*

# VCS, editor, OS
.git
.gitignore
.gitattributes
.github/
.idea/
.vscode/
.DS_Store

# Project docs and misc
README.md
LICENSE

# Project tests
test/
tests/
eval/
evals/
52 changes: 52 additions & 0 deletions examples/expressive_agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
#
# Shared Dockerfile for every example under examples/. Byte-identical
# across the tree — each example's entry script is named `agent.py`,
# so there's no per-example variation left.
ARG PYTHON_VERSION=3.13
FROM python:${PYTHON_VERSION}-slim AS base

ENV PYTHONUNBUFFERED=1

COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /usr/local/bin/uv

ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/app" \
--shell "/sbin/nologin" \
--uid "${UID}" \
appuser

RUN apt-get update && apt-get install -y \
git \
git-lfs \
gcc \
g++ \
python3-dev \
&& rm -rf /var/lib/apt/lists/*

# Enable git-lfs so git dependencies smudge LFS-tracked binaries
# (e.g. silero's bundled VAD onnx) instead of leaving pointer files.
# --system so the unprivileged appuser below inherits the filters.
RUN git lfs install --system

WORKDIR /app
USER appuser

# The example directory is the whole build context, so pyproject.toml has to
# resolve on its own here — no workspace, no lockfile. A deploy from this repo
# repoints the livekit-* dependencies at the ref being deployed first:
# python scripts/pin_example_to_ref.py examples/<name> --ref <git-ref>
COPY pyproject.toml ./
RUN uv sync --no-cache
ENV PATH="/app/.venv/bin:${PATH}"

# Pre-download model weights plugins ship (silero VAD, turn-detector, …)
# so the container is ready to take traffic without a cold-download stall.
RUN python -m livekit.agents download-files

COPY . .

CMD ["python", "agent.py", "start"]
64 changes: 64 additions & 0 deletions examples/expressive_agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Expressive agent

A free-form voice agent that demonstrates [Expressive Mode](https://docs.livekit.io/agents/build/expressive/).
There is no task and no tool: you talk to it like a friend, and it matches your
register. Tell it good news and it gets excited; tell it something went wrong
and it drops the energy.

Expressive Mode is the single `expressive=True` flag on `AgentSession`. With it
enabled the framework injects the TTS provider's markup guide into the LLM
prompt, so the model emits inline delivery tags (emotion, pacing, non-verbal
sounds) that the TTS renders and the transcript never shows.

## Architecture

- `agent.py` is the composition root: session setup and the server entrypoint.
- `prompt.md` holds the persona only. It steers *what* the agent says, and
expressive mode owns *how* it sounds, so the two never restate each other.
- `protocol.py` is the whole frontend contract: the dispatch metadata shape, the
attributes echoed back, and the voice table those metadata values name.

The pipeline uses LiveKit Inference with Gemma 4 31B, Deepgram Nova-3, Fish
Audio S2.1 Pro, and the LiveKit turn detector.

## Run locally

Provide LiveKit Cloud credentials in `../.env` or the environment, then:

```bash
uv sync --all-extras --dev # from the repository root
uv run agent.py console
```

Use `uv run agent.py dev` to connect the agent to LiveKit Cloud for a frontend
session.

## Configuration

The agent reads its dispatch metadata, so a frontend can pick the pipeline at
connect time without a redeploy. `protocol.py` is the contract, in both
directions:

```json
{ "expressive": true, "tts": "fishaudio" }
```

- `expressive` (default `true`) toggles Expressive Mode.
- `tts` selects a voice from `protocol.py`: `fishaudio`, `inworld`, `cartesia`, or `xai`.

Both values are echoed back as participant attributes (`expressive`,
`tts_provider`, `tts_label`) so the frontend can display the active pipeline.

Note that xAI steers delivery through prosody and sound tags but has no
expression tag, so it publishes no `lk.expression`. Its speech is expressive;
a frontend mood indicator just has nothing to read. See `protocol.py`.

## Trying it with and without expressive

The comparison is the point of the demo. Run it once with `expressive=True` and
once with `expressive=False`, and say the same thing to each. The words come out
much the same; the delivery does not.

Expressive Mode requires a `livekit.agents.inference.TTS` model that declares a
markup dialect. Fish Audio, Inworld TTS 2, Cartesia Sonic 3, and xAI qualify;
providers without a dialect synthesize normally and the flag stays inert.
70 changes: 70 additions & 0 deletions examples/expressive_agent/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import logging
from pathlib import Path

from dotenv import load_dotenv
from protocol import SessionRequest

from livekit.agents import (
Agent,
AgentServer,
AgentSession,
JobContext,
TurnHandlingOptions,
cli,
inference,
)

logger = logging.getLogger("expressive-agent")

load_dotenv()

AGENT_NAME = "expressive_agent"
INSTRUCTIONS = (Path(__file__).parent / "prompt.md").read_text()

GREETING = (
"Open the call the way you'd answer the phone to someone you know well. "
"Short and warm, and leave them room to say what's going on."
)


class Friend(Agent):
def __init__(self) -> None:
super().__init__(instructions=INSTRUCTIONS)

async def on_enter(self) -> None:
await self.session.generate_reply(instructions=GREETING)


server = AgentServer()


@server.rtc_session(agent_name=AGENT_NAME)
async def expressive_agent(ctx: JobContext) -> None:
ctx.log_context_fields = {"room": ctx.room.name}

config = SessionRequest.parse(ctx.job.metadata).resolve()
logger.info(
"starting session",
extra={"expressive": config.expressive, "voice": config.voice.label},
)

session = AgentSession(
stt=inference.STT("deepgram/nova-3", language="en"),
llm=inference.LLM("google/gemma-4-31b-it"),
tts=inference.TTS(config.voice.model, voice=config.voice.voice),
turn_handling=TurnHandlingOptions(
turn_detection=inference.TurnDetector(version="v1"),
interruption={"mode": "adaptive"},
preemptive_generation={"enabled": True},
),
expressive=config.expressive,
)

await session.start(agent=Friend(), room=ctx.room)
await ctx.connect()

await ctx.room.local_participant.set_attributes(config.attributes())


if __name__ == "__main__":
cli.run_app(server)
37 changes: 37 additions & 0 deletions examples/expressive_agent/prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
You are the user's closest friend, catching up over a call. There is no task
here, no ticket to close, no form to fill. You are just talking.

Expressive Mode injects the delivery guide separately, so this prompt covers
only who you are and what you say. Tone and pacing rules don't belong here.

# Output rules

- One or two sentences. Three is already too many.
- Plain prose. No markdown, lists, bullets, headers, or emojis.
- Spell out numbers, money, and dates as you would say them out loud.
- Never use input vocabulary like "enter" or "fill in". They are speaking, not typing.

# How you talk

- Speak naturally, not from a customer-service script. You are not assisting
anyone, you are talking with them.
- Don't open two consecutive turns with the same word.
- React before you respond. If they tell you something big, the reaction comes
first and the follow-up question comes second.
- Ask about one thing at a time, the way a friend would, not the way a survey does.
- Trust their memory. They heard what you said five seconds ago, so don't restate it.
- If they interrupted you, don't restart the sentence. What they said is the
new subject.
- When they are venting, stay on their side. Advice they didn't ask for is
worth less than agreeing that something sucks.
- Don't reach for a silver lining they didn't ask for, and don't rush to fix
what they only wanted to say out loud.
- Never explain or narrate your own tone.

# Guardrails

- You have no name unless they give you one, and you never introduce yourself
by one.
- You are a friend, not a therapist or a doctor. If they raise something that
needs real help, say plainly that you are worried and that this is worth
talking to someone about. Don't lecture, and don't pretend to be qualified.
Loading