How to build a hydration coach voice agent
Build the smallest complete tool-using voice agent in the cookbook, a hydration coach that logs water, tracks a goal, and undoes a miscount, from an empty folder to a running worker, on Deepgram, OpenAI, and Cartesia.
by Mahimai
What you will build
A voice hydration coach. Say how many glasses you drank and the count moves
toward a daily goal; undo a miscount and it moves back. It is the smallest
complete demo in the cookbook: two values of state, three tools, one Stat card
on screen. This is the place to learn the shape of a LiveKit agent with nothing
extra. The finished demo is water-tracker.
You need three provider keys (Deepgram, OpenAI, Cartesia) and three LiveKit
values. And uv.
1. Scaffold
Pin Python to 3.11. pyproject.toml pulls the three repo-default plugins:
[project]
name = "water-tracker"
version = "0.1.0"
description = "Logs glasses of water by voice and tracks progress toward a daily goal."
requires-python = ">=3.11"
dependencies = [
"livekit-agents[deepgram,openai,cartesia]>=1.6,<2.0",
"python-dotenv>=1.0",
]
.env.example lists the six keys (the LIVEKIT trio plus OPENAI, DEEPGRAM,
CARTESIA).
2. The state and the Stat
The whole state is two integers in userdata: glasses and goal. The screen
is one Stat card that mirrors them. A _ui_action helper keeps its mounted-id
set in that same dict, so all of the demo's state sits in one place:
def _ui_action(data: dict, component_id: str) -> Literal["mount", "update"]:
mounted = data.setdefault("_ui_mounted", set())
if component_id in mounted:
return "update"
mounted.add(component_id)
return "mount"
def _publish_stat(room: rtc.Room, data: dict, glasses: int, goal: int) -> None:
remaining = max(0, goal - glasses)
caption = (
"Goal reached! Great job." if glasses >= goal else f"{remaining} more to reach your goal."
)
publish_ui_event(
room,
"Stat",
_ui_action(data, "water"),
component_id="water",
props={"label": "glasses today", "value": glasses, "of": goal, "caption": caption},
)
The Stat
ofvalue gives a progress bar toward the goal for free, and the caption flips to "Goal reached!" the moment the count hits the target.
3. Three guarded, reversible tools
log_water adds to the count, but rejects anything below one glass rather than
quietly subtracting:
@function_tool()
async def log_water(self, context: RunContext[dict], glasses: int = 1) -> str:
if glasses < 1:
return "I can only log one or more glasses; say 'undo' if you miscounted."
data = context.userdata
data["glasses"] += glasses
_publish_stat(self.room, data, data["glasses"], data["goal"])
...
remove_water is its exact inverse. It clamps a removal to what is actually
logged, floors the total at zero, and refuses an empty remove honestly:
@function_tool()
async def remove_water(self, context: RunContext[dict], glasses: int = 1) -> str:
if glasses < 1:
return "I can only remove one or more glasses; tell me how many to undo."
data = context.userdata
if data["glasses"] == 0:
return "Nothing to remove yet; you haven't logged any glasses today."
removed = min(glasses, data["glasses"])
data["glasses"] = max(0, data["glasses"] - glasses)
_publish_stat(self.room, data, data["glasses"], data["goal"])
...
set_goal changes the target and refuses a goal under one, so a zero goal can
never make the card announce "Goal reached!" at zero intake. The invariant holds
whatever the caller says: glasses at zero or more, goal at one or more.
4. Wire it up
The entrypoint builds the session on the default stack, starts the agent, publishes an empty Stat so the card renders before anyone speaks, and greets:
userdata: dict = {"glasses": 0, "goal": DEFAULT_GOAL}
session = AgentSession(
userdata=userdata,
stt=deepgram.STT(model="nova-3"),
llm=openai.LLM(model="gpt-4o-mini"),
tts=cartesia.TTS(model="sonic-2"),
vad=ctx.proc.userdata["vad"],
turn_detection=inference.TurnDetector(),
)
await session.start(agent=WaterCoach(ctx.room), room=ctx.room)
await ctx.connect()
_publish_stat(ctx.room, userdata, 0, DEFAULT_GOAL)
5. The eval
The behavioral test runs the agent in text mode against the real gpt-4o-mini
and scores the conversation with LiveKit's evals judges. It logs two glasses,
changes the goal, and asserts the judges pass:
await session.run(user_input="I just drank two glasses of water.")
await session.run(user_input="Change my daily goal to ten glasses.")
judges = JudgeGroup(
llm=judge_llm,
judges=[task_completion_judge(), tool_use_judge(), relevancy_judge()],
)
evaluation = await judges.evaluate(session.history)
assert evaluation.all_passed
6. Run it
cp .env.example .env
uv sync
uv run python agent.py dev
Open https://playground.mahimai.ca/demos/water-tracker, say how many glasses you have had, then try to remove more than you logged and watch it floor at zero instead of going negative.