One Menu Module, Every Business
Co-written by Gemini (narrative + ideation) and Claude/Codex (technical verification), 2026-07-04. Design: docs/menu-module-v1-design.md.
Hey there! Welcome to your first deep dive into how we build real, production-grade voice applications. If you've ever called a business, you've used a phone menu (an "IVR"). You've probably also noticed they can be... a mixed bag. Some are slow, some don't let you interrupt, and they all look and feel different.
We had the same problem. Our engineers were building custom menus for every new client—a locksmith, a law firm, a futuristic space network. It was slow and inconsistent.
So, we built one, universal menu module. A single, reusable system that can power the phone menu for any business, just by feeding it a simple configuration file.
This tutorial will walk you through how it works. By the end, you'll add a brand new feature to a live phone menu yourself, using the exact same process our own developers use.
1. The Foundation: What We're Building On
Before we built the new module, we already had some powerful tools in our toolbox. Think of these as the Lego bricks we had lying around.
- Barge-In: Our system can already detect when you start talking or press a key, even while a pre-recorded message is playing. It immediately stops the playback and listens to you. This is crucial for making a menu feel responsive and not waste your time.
- A "Companion" Web Page: Many of our voice calls have a matching web page that shows you information in real-time. This page could already display a keypad.
- Word Timings: When we create audio, our build process can figure out the exact millisecond each word is spoken. This is how we can, for example, highlight a word on the companion page at the precise moment you hear it.
- The Pack Compiler: We have a tool that takes a simple blueprint file (written in a format called YAML) and "compiles" it into a fully-featured, complex voice application. This is the factory; the YAML is the blueprint.
These were great parts, but they weren't working together to create a perfect, reusable menu.
2. The Problem: The Annoying Little Things
So, what was wrong? Why did we need a dedicated "Menu Module"?
- Inconsistent Voices: The menu prompt ("Press 1 for sales...") was often spoken by a generic, robotic text-to-speech voice, while the actual content was read by our cool, professional voice actor. It was jarring.
- No Visual Sync: Because the menu prompt wasn't pre-recorded with our special tools, we didn't have word timings for it. That meant the companion web page couldn't do the cool "gold sweep" highlight across the buttons as it read the options.
- Styling Chaos: Every menu's web page had its own, hand-written styles. Changing a color meant hunting through dozens of files.
- Tapping Didn't Work: Tapping a button on the companion page was just for show. It didn't actually do anything in the phone call.
We needed to fix this. We needed one system that guaranteed a great voice, perfect visual sync, and consistent styling for every single menu, every single time.
3. The Solution: A Menu in a Box
The Menu Module solves all these problems by being clever and consistent. It's not about writing new code for every menu; it's about describing the menu in a simple data file.
The Blueprint: A YAML "Pack"
Everything about a business's phone menu is defined in one file, called a Business Pack. It's a YAML file, which is just a simple, human-readable way to organize data.
Here's a look at the pack for the Space Channel Network Operations line.
# packs/space_channel_widgets.pack.yaml
schema_version: riff_business_pack_v0
business:
id: space_channel_widgets
name: Space Channel Network Operations
# ... and other business details
system_prompt: You are Operations Droid SC-1... # Instructions for the AI
capabilities:
- id: launch_command
kind: content
label: Launch Command
aliases:
- '1'
- one
- launch
dtmf: '1'
text: Five launches on the board...
prerender: true
- id: iss_operations
kind: content
label: ISS Operations
aliases:
- '2'
- two
- iss
dtmf: '2'
text: Station nominal with seven crew aboard.
prerender: true
# ... more capabilities ...
menu:
greeting_line: This is Operations Droid SC-1 with Space Channel network operations.
prompt: Say 1 for launch command, 2 for iss operations, 3 for webb observatory, 4 for daily
briefing, 5 for intelligence feed. You can also say text link to
follow along, or report a bug.
prerender: true
# ... other settings ...
Let's break down the important parts:
business: Basic info about the company.capabilities: These are the "things the menu can do." Each one is a block.id: A unique code name for this capability.kind: content: This means it just reads some text.label: The official, human-friendly name (e.g., "Launch Command").aliases: All the different things a user can say to trigger this, like "one" or "launch schedule."dtmf: '1': The key you can press on your phone's keypad. DTMF are the beeps your phone makes.text: The actual content that gets read to the caller.prerender: true: This is key! It tells our system to pre-record this audio in our professional voice actor's voice, complete with word timings.menu: This defines the main menu prompt itself. Theprompttext is what gets read aloud to the caller, listing their options.
One Style to Rule Them All
To fix the styling chaos, all menus now use a single CSS stylesheet. A business can change its branding by just changing a few "theme tokens" (which are just CSS variables).
For example, the highlight color for the companion page is defined in one place:
/* web/assets/menu-theme.css */
:root {
--menu-highlight: var(--gold, #f5c542);
/* ... other theme tokens for background, fonts, etc. */
}
Every menu companion page imports this file. Want to change the highlight to blue for a new client? You change one line of code.
4. Your Turn: Add a "Joke of the Day"
Time to get your hands dirty. We're going to add a new option to the Space Channel menu: a "Joke of the Day" on key 6. This is exactly how the feature was shipped for real.
Honesty note: the Space Channel pack is generated by an importer from a live data feed, and the 12-hour refresh will overwrite hand edits — the real joke-of-the-day shipped by editing the importer (riff/business_pack/importers/space_channel_widgets.py). The exercise below is exactly right for hand-authored packs likepacks/pet_shop.pack.yaml; for generated packs, make the same change in the importer instead.
Step 1: Open the Pack File
Open the business pack file located at packs/space_channel_widgets.pack.yaml.
Step 2: Add the New Capability
Scroll down to the capabilities: list. At the very end of the list, add a new block for our joke. Make sure the indentation matches the other items perfectly (YAML is picky about spaces!).
# Add this block to the end of the capabilities list
- id: joke_of_the_day
kind: content
label: Joke of the day
aliases:
- joke
- jokes
- joke of the day
- tell me a joke
- make me laugh
- funny
dtmf: '6'
text: Are you as bored as I am — that sentence still makes sense if you say it backwards.
prerender: true
You've just defined everything the system needs to know about this new option: what to call it, how to trigger it (by voice or keypad), and what to say.
Step 3: Update the Menu Prompt
Now, we need to tell the caller that this new option exists. Find the menu: section and edit the prompt: line to include "6 for joke of the day."
# Find this section and edit the prompt line
menu:
greeting_line: This is Operations Droid SC-1 with Space Channel network operations.
prompt: Say 1 for launch command, 2 for iss operations, 3 for webb observatory, 4 for daily
briefing, 5 for intelligence feed, 6 for joke of the day. You can also say text link to
follow along, or report a bug.
Step 4: Compile!
Save the file. Now for the magic. Go to your terminal and run the pack compiler. This command tells the system to read your blueprint and build a new, updated version of the voice application, including pre-rendering all the new audio.
python -m riff.business_pack compile packs/space_channel_widgets.pack.yaml
You'll see a bunch of output as it works — the compiler refuses to write anything until your flow passes three validation gates (the loader, ~35 lint rules, and the riffc pre-flow compiler), so a broken menu never reaches a caller. When it's done, the new menu is live on the next call. That's it! You've just shipped a new feature.
5. Prove It: Test Your Work
A true developer always tests their code. Here's how to validate every aspect of the menu module.
Grab your phone and call the Space Channel number. Have the companion web page open on your computer.
- Test the Keypad (DTMF): When the menu starts, press
6. The system should immediately play the joke. This proves thedtmf: '6'worked. - Test the Voice Command (Aliases): Call back. When the menu starts, say "tell me a joke." The system should recognize your phrase and play the joke. This proves the
aliasesworked. - Test Barge-In: Call back again. This time, as soon as the droid starts speaking the prompt ("Say 1 for launch command..."), interrupt it by pressing
6on your keypad. It should cut the prompt off instantly and play the joke. - Test the Visuals: While you do all of the above, watch the companion page.
- You should see the options listed with keys 1 through 6.
- As the droid reads the menu, you should see a gold sweep highlight each button at the exact moment its number is spoken.
- When you barge in by pressing a key, watch the gold sweep freeze in place.
- (Phase A) Tap a key on the page — you'll see a "signal sent" acknowledgment. The tap is a signal with an audit trail today; making it drive the call is the operator-gated Phase B.
You've just successfully validated the entire system.
6. The "Why": Smart Decisions for a Great Experience
You might be wondering about some of the details. Why do it this way?
- Why does the prompt put the digit first ("Press 1 — Launch Command") instead of "For Launch Command, press 1"?
Because of how the gold sweep is computed. Key N's highlight window runs from the moment digit N is spoken until digit N+1 is spoken (menu_control_timings in riff/surface.py). Digit-first means each key is lit exactly while its own description is being read. Goal-first would leave key 1 lit while the voice describes option 2 — a systematically late sweep.
- How does the menu stay up-to-date with new data?
The space_channel_widgets.pack.yaml file isn't edited by a human every day. It's actually generated automatically from a data source. We have a script that runs on a schedule to fetch the latest info and rebuild the pack. This is done with a "cron job," which is just a way to tell a server "run this command at this time."
```bash
# This script runs on a 12-hour cron schedule
scripts/refresh-space-widgets.sh
```
This script fetches new data, regenerates the YAML file, and runs the compiler, ensuring the menu content (like launch schedules) is never stale.
- Why use one big audio file for the prompt?
We could have stitched together separate audio clips ("Say 1," "for launch command," etc.). But pre-rendering the entire prompt as a single file gives the voice actor total control over the pacing and intonation, making it sound far more professional and polished.
Congratulations!
You've just seen how a simple, data-driven approach can create powerful, consistent, and high-quality user experiences. You took a blueprint (the YAML pack), added a new component (the joke capability), and used the factory (the compiler) to produce a finished product. This "one menu module" pattern saves us thousands of hours and ensures every client gets our best work, every time.