mahimaivoice playground
DemosAboutContributeCookbook↗
World of Voice Agents · Built by Mahimai ↗
awesome-voice-apps ↗● ConversationalX
← all demos
restaurant · surface: drive-thru-coffee

Drive-thru coffee

Takes a coffee order, modifies items mid-flow, totals the cart. Cafes that want voice ordering without ripping out their POS.

source ↗
NO LIVEKIT CREDENTIALSPaste your LiveKit URL, key and secret to connect the scope to a live room.
SCOPE · drive-thru-coffee○ STANDBY
NO SIGNAL · PRESS CONNECT
STATUS
idle
DURATION
··
RISK
··
TRANSCRIPT

transcript will stream here

CANVAS · drive-thru-coffeewaiting
agent-mounted UIappears here on connect

Takes a coffee order, modifies items mid-flow, totals the cart. Cafes that want voice ordering without ripping out their POS.

agent may draw
OrderTotalCheckoutList
REQUIRED CREDENTIALS
livekit_urllivekit_api_keylivekit_api_secretLiveKit: ✗ add your keys
Run the worker locally:
uv run python agent.py dev
then connect: the agent joins room drive-thru-coffee.
build writeup

How to build a drive-thru coffee voice agent

by Mahimai

A drive-thru order is a small state machine: add items, change them mid-flow, total the cart, and close with a name on the cup. This agent runs that whole loop by voice and mirrors the cart to the screen as it goes. It is the cookbook's first demo, so it also sets the UI scaffold every later demo copies.

The stack is Deepgram Nova-3 for STT, gpt-4o-mini for the tools, and Cartesia Sonic-2 for the voice. Turn-taking is LiveKit's inference VAD and turn detector.

Money is integer cents everywhere, formatted only at the edge, so a total never picks up a floating-point rounding bug:

def _fmt(cents: int) -> str:
    return f"${cents // 100}.{cents % 100:02d}"

The cart is one list in userdata. add_item looks the item up in the menu, appends it, and re-publishes the Order and Total; remove_item is the inverse operation, so the cart and the screen never drift:

cart = context.userdata["cart"]
cart.append(item)
_publish_cart(self.room, cart)

submit_order closes the loop: it refuses an empty cart, takes a pickup name, and publishes a Checkout card with the total, so the customer sees the order land before they pull forward.

The scaffold worth copying is UI idempotency. The playground wants exactly one mount per component id, then update for every later change, so a helper tracks which ids are already up on the room object:

def _ui_action(room: rtc.Room, component_id: str) -> Literal["mount", "update"]:
    mounted = getattr(room, "_awesome_voice_ui_mounted", None)
    if mounted is None:
        mounted = set()
        setattr(room, "_awesome_voice_ui_mounted", mounted)
    if component_id in mounted:
        return "update"
    mounted.add(component_id)
    return "mount"

Publish every change as mount and the playground stacks a new card each time instead of updating in place. Adding a shot draws a second order card on top of the first. Mount once per id, then update.

Build it from an empty folder in the full walkthrough, or talk to the finished agent at https://playground.mahimai.ca/demos/drive-thru-coffee.

Read the full walkthrough→