<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mayank Gaur</title>
    <description>The latest articles on DEV Community by Mayank Gaur (@mayank-cs50).</description>
    <link>https://dev.to/mayank-cs50</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3920182%2F302f0ace-b3e7-4873-9253-588c06c5441d.png</url>
      <title>DEV Community: Mayank Gaur</title>
      <link>https://dev.to/mayank-cs50</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mayank-cs50"/>
    <language>en</language>
    <item>
      <title>I built a Hindi-speaking voice tutor for robotics students — and learned that prompts can't hold guardrails</title>
      <dc:creator>Mayank Gaur</dc:creator>
      <pubDate>Sat, 15 Aug 2026 11:12:01 +0000</pubDate>
      <link>https://dev.to/mayank-cs50/i-built-a-hindi-speaking-voice-tutor-for-robotics-students-and-learned-that-prompts-cant-hold-4boo</link>
      <guid>https://dev.to/mayank-cs50/i-built-a-hindi-speaking-voice-tutor-for-robotics-students-and-learned-that-prompts-cant-hold-4boo</guid>
      <description>&lt;p&gt;It's 11 P.m. . A student has a line follower robot on the table, two IR sensors, an L298N motor driver, and a robot that zig-zags across the black line like it's avoiding it on purpose. The workshop mentor went home at six. The only help on the internet is a YouTube video, in English,&lt;br&gt;
about somebody else's chassis.&lt;/p&gt;

&lt;p&gt;I spent ten days building the thing I wanted at that hour: &lt;strong&gt;ऋषिका (Rishika)&lt;/strong&gt;, a voice agent that takes the question in the form it actually arrives in — &lt;em&gt;"मेरा robot line पर ज़िग-ज़ैग कर रहा है"&lt;/em&gt; — and answers in the same breath of Hindi and English.&lt;/p&gt;

&lt;p&gt;This post is both the story and the guide. Code is at the bottom, and every claim in here has a&lt;br&gt;
file you can go read.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why voice, specifically
&lt;/h2&gt;

&lt;p&gt;I want to be honest about this, because "let's add voice" is often a solution looking for a problem. Two things made it the right interface here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Their hands are busy.&lt;/strong&gt; A student debugging a robot is holding a multimeter in one hand and turning a trimpot with the other. Typing means putting the robot down, which means losing the state they were testing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The question isn't in English.&lt;/strong&gt; It's in Hinglish, and the code-switch lands mid-sentence :&lt;br&gt;
&lt;em&gt;"Kp बढ़ाया तो oscillation और बढ़ गया"&lt;/em&gt;. A chat box invites the student to translate first, and the translation is where the actual symptom gets lost. Speech doesn't ask them to do that.&lt;/p&gt;

&lt;p&gt;The track I picked was &lt;strong&gt;Learning &amp;amp; Literacy&lt;/strong&gt;, and the user is specific: a school or first-year engineering student building their first robot, more comfortable in Hindi than English, with nobody to ask after class.&lt;/p&gt;


&lt;h2&gt;
  
  
  What she does
&lt;/h2&gt;

&lt;p&gt;Rishika is a generalist tutor for one narrow domain. She:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speaks Indian English and Hindi&lt;/strong&gt; — Murf Falcon's &lt;code&gt;Anisha&lt;/code&gt; voice, with Deepgram &lt;code&gt;nova-3&lt;/code&gt;
running &lt;code&gt;language="multi"&lt;/code&gt; so a Hinglish sentence transcribes without a language switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remembers returning students&lt;/strong&gt; — level, topics covered, mistakes they've made before. Only
after they say yes out loud, and deleted the moment they ask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teaches with live data&lt;/strong&gt; — Free Dictionary API for terms, Open Trivia DB for quiz questions,
and a scoring tool that rates a spoken answer 0–100 with one line of feedback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shows her work on screen&lt;/strong&gt; — quiz questions arrive as clickable cards pushed over the LiveKit
data channel; clicking an option answers her out loud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calls students for practice&lt;/strong&gt; — an outbound call that opens by saying who's calling, why,
and how to stop it, before anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetches a human when she should&lt;/strong&gt; — a burning smell from the motor driver isn't a debugging
problem, it's a safety one. That becomes a mentor request with a reference ID, after asking
permission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Records how every call went&lt;/strong&gt; — one outcome row per session, on a local ops page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hands off PID tuning to a specialist&lt;/strong&gt; — Kabir, a second agent with his own voice, who
inherits the entire conversation.&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  How the system works
&lt;/h2&gt;

&lt;p&gt;Every voice agent, regardless of framework, is the same four boxes:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8dm2zsnvc7arqblmrmto.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8dm2zsnvc7arqblmrmto.png" alt="System Architecture" width="800" height="194"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;STT&lt;/strong&gt; — speech to text. Deepgram &lt;code&gt;nova-3&lt;/code&gt; with &lt;code&gt;language="multi"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM&lt;/strong&gt; — decides what to say and which tool to call. Gemini 3.5 Flash Lite; cheap and fast matters more than clever here, because latency is the whole product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTS&lt;/strong&gt; — text back to speech. &lt;strong&gt;Murf Falcon&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time transport&lt;/strong&gt; — moves audio both ways. LiveKit, which also handles turn detection, voice activity detection, and interruptions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The nice property is that these are independent. Swapping the voice is one argument; swapping the LLM is one line.&lt;/p&gt;
&lt;h3&gt;
  
  
  Where the latency budget goes
&lt;/h3&gt;

&lt;p&gt;The student's felt delay is STT + LLM + TTS, and you don't get to spend much. Falcon's numbers are the reason I didn't have to think hard about the TTS leg:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;55 ms&lt;/strong&gt; model latency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;130 ms&lt;/strong&gt; time-to-first-audio&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;$0.01 per 1000 characters&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TTS sits in the worst possible place in the chain — after the agent has decided what to say, but before the student hears anything. Every millisecond there is dead air in a conversation.&lt;/p&gt;

&lt;p&gt;One thing I'd flag for anyone starting: the LiveKit Murf plugin takes a tokenizer, and the choice matters more than it looks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;murf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TTS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;voice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Anisha&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Casual&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SentenceTokenizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_sentence_len&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;text_pacing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SentenceTokenizer&lt;/code&gt; streams audio sentence by sentence instead of waiting for the full reply, so the student hears the first sentence while the LLM is still writing the third. Come back to that &lt;code&gt;min_sentence_len=2&lt;/code&gt; in a minute — it caused one of my bugs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Five features worth your time
&lt;/h2&gt;

&lt;p&gt;I'm skipping the day-by-day. These are the five that changed how the thing feels.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A voice that sounds like it's from here
&lt;/h3&gt;

&lt;p&gt;Falcon's &lt;code&gt;Anisha&lt;/code&gt; is Indian English, and that does more work than I expected. Students stop translating themselves. The prompt then has one hard rule: replies are written in &lt;strong&gt;Devanagari&lt;/strong&gt;, for Hindi, never romanised — because TTS reads &lt;code&gt;"mera robot"&lt;/code&gt; with English phonetics and it comes&lt;br&gt;
out wrong. Script discipline in the prompt is pronunciation engineering.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Memory that asks first
&lt;/h3&gt;

&lt;p&gt;Three tools: &lt;code&gt;lookup_user&lt;/code&gt;, &lt;code&gt;save_user_memory&lt;/code&gt;, &lt;code&gt;forget_user_memory&lt;/code&gt;. A returning student gets &lt;em&gt;"पिछली बार हमने IR sensors किए थे"&lt;/em&gt; instead of starting over.&lt;/p&gt;

&lt;p&gt;The part I'd defend hardest is that &lt;strong&gt;nothing is written without spoken consent&lt;/strong&gt;, and that rule does not live in the prompt. More on why in the challenges section.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Tool results on screen, clickable
&lt;/h3&gt;

&lt;p&gt;Voice-only has a real failure mode: a four-option MCQ read aloud is impossible to hold in your head. So quiz questions get pushed over the LiveKit data channel as a &lt;code&gt;TOOL_RESULT_CARD&lt;/code&gt; payload and render as buttons. Click one and it answers Rishika out loud — the click becomes a turn in the voice conversation rather than a separate UI mode.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Knowing when to stop being an AI
&lt;/h3&gt;

&lt;p&gt;Two escalation triggers only:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;needs_human_mentor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Burning smell, motor driver too hot to touch, smoke&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;learner_distress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Crying, quitting, self-doubt that survives one round of encouragement&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Difficulty is explicitly &lt;em&gt;not&lt;/em&gt; a trigger — a hard PID question is her job. But a burning smell isn't a debugging problem, and no amount of voice agent helps. She stops, lists exactly what she'll send, asks permission, and returns a reference ID with an honest timeline: "within one&lt;br&gt;
working day", never "right now".&lt;/p&gt;
&lt;h3&gt;
  
  
  5. A specialist with a different voice
&lt;/h3&gt;

&lt;p&gt;PID tuning is where LFR builds actually die, and it needs a different mode of attention — three numbers, one at a time, over several turns. So it got its own agent: &lt;strong&gt;Kabir&lt;/strong&gt;, who tunes Kp, Ki and Kd and does nothing else. Rishika has 10 tools; Kabir has exactly one, the exit.&lt;/p&gt;

&lt;p&gt;Three things make the handoff not feel like a phone tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PID_COACH_PROMPT&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;_briefing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;learner_request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;chat_ctx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat_ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# he inherits the whole conversation
&lt;/span&gt;    &lt;span class="n"&gt;tts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;murf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TTS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;voice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Samar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Conversation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...),&lt;/span&gt;   &lt;span class="c1"&gt;# and a male voice
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;He &lt;strong&gt;inherits &lt;code&gt;chat_ctx&lt;/code&gt;&lt;/strong&gt;, so he already knows the student's name and what the robot is doing — nobody explains their problem twice. He &lt;strong&gt;sounds different&lt;/strong&gt;, because a handoff you can only read isn't a handoff. And the &lt;code&gt;tts=&lt;/code&gt; override lives on the agent rather than the session, so handing&lt;br&gt;
back restores Rishika's voice automatically with no bookkeeping.&lt;/p&gt;


&lt;h2&gt;
  
  
  Three things that went wrong
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Prompts cannot hold a guardrail
&lt;/h3&gt;

&lt;p&gt;This is the lesson I'd keep if I forgot everything else.&lt;/p&gt;

&lt;p&gt;I had written, in clear capital letters, that the escalation tool must only be called after the student agrees out loud. It worked most of the time. "Most of the time" is not what you want from the code path that decides whether a child's name and problem get posted to a mentor channel.&lt;/p&gt;

&lt;p&gt;Same story with the handoff. The tool's docstring says PID symptoms only, and lists what stays with Rishika. The LLM cheerfully sent an IR-sensor threshold question to the PID coach anyway, because "sensor" and "tuning" live near each other in embedding space.&lt;/p&gt;

&lt;p&gt;The fix was to stop asking and start checking. The guard moved &lt;strong&gt;inside the tool&lt;/strong&gt;, where the LLM can't negotiate with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@function_tool&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transfer_to_pid_specialist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;learner_request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;specialists&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;needs_pid_coach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;learner_request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NOT_TRANSFERRED: that request has no PID or tuning symptom in it, so it is &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yours to answer. Handle it yourself now, and only transfer if they describe &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zig-zagging, wobbling, oscillation, overshoot, or ask about Kp, Ki or Kd.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;needs_pid_coach()&lt;/code&gt; is a narrow regex — &lt;code&gt;PID&lt;/code&gt;, &lt;code&gt;Kp/Ki/Kd&lt;/code&gt;, oscillation, wobble, zig-zag, overshoot, in English and Devanagari. Bare "tune" doesn't match. When it refuses, it returns a &lt;em&gt;string&lt;/em&gt; rather than an agent, so LiveKit doesn't switch and the LLM gets told to answer the question itself.&lt;/p&gt;

&lt;p&gt;The consent path is gated twice, in the tool and again in &lt;code&gt;escalations.create_or_update()&lt;/code&gt;, so an LLM that forgets to ask cannot create a row even if it passes &lt;code&gt;consent_confirmed=True&lt;/code&gt; by accident in one layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The prompt asks nicely. The code decides.&lt;/strong&gt; If a rule matters, write it where the model can't reach it — and then write a test, because a regression here is invisible in a demo.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Murf said "pid"
&lt;/h3&gt;

&lt;p&gt;Small bug, disproportionately embarrassing. Every time the agent said &lt;code&gt;PID&lt;/code&gt;, the TTS pronounced it as a word — "pid" — which instantly destroys the illusion that you're talking to someone who has built a robot.&lt;/p&gt;

&lt;p&gt;First fix: write it as &lt;code&gt;P.I.D.&lt;/code&gt;. This was worse. Remember SentenceTokenizer(min_sentence_len=2)&lt;code&gt;? It splits on periods. So&lt;/code&gt;P.I.D.` became three sentence fragments, each streamed as its own audio chunk, and the speech went choppy exactly where I'd tried to make it clearer.&lt;/p&gt;

&lt;p&gt;What actually works is spelling it out with spaces — &lt;code&gt;पी आई डी&lt;/code&gt; in Hindi, &lt;code&gt;P I D&lt;/code&gt; in English — enforced in all three places text reaches the TTS: the fixed handoff announcement, Rishika's system prompt, and Kabir's prompt. Then a test, because this is precisely the kind of rule an LLM&lt;br&gt;
quietly reverts:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;python&lt;br&gt;
def test_pid_is_never_spoken_as_one_word():&lt;br&gt;
    assert "PID" not in static_intents.HANDOFF_TO_PID_FILLER&lt;br&gt;
    assert "पी आई डी" in static_intents.HANDOFF_TO_PID_FILLER&lt;br&gt;
    assert "पी आई डी" in specialists.PID_COACH_PROMPT&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Generalisable lesson: acronyms, phone numbers, IDs and units all need spelling rules in the prompt, and the tokenizer's behaviour decides which spelling works.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A UI signal that vanished a second after it arrived
&lt;/h3&gt;

&lt;p&gt;The most interesting one, and it took the longest.&lt;/p&gt;

&lt;p&gt;Both Rishika and Kabir share &lt;strong&gt;one room participant&lt;/strong&gt;. Nothing in the audio stream or the transcript stream says which of them is talking. So the backend publishes a participant attribute on handoff:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;python&lt;br&gt;
await get_job_context().room.local_participant.set_attributes({"active_agent": "kabir"})&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The frontend reads it, turns the transcript cyan and relabels the speaker. Clean. It also didn't work — the transcript stayed pink and labelled &lt;code&gt;RISHIKA&lt;/code&gt; forever, while the backend logs happily showed the attribute being set.&lt;/p&gt;

&lt;p&gt;I'd been reading it through the React hook:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;tsx&lt;br&gt;
const { attributes } = useAgent();&lt;br&gt;
const activeAgent = attributes?.active_agent;   // undefined, always&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Reading the compiled hook source explained it. The &lt;code&gt;AttributesChanged&lt;/code&gt; listener replaces the whole state with only the &lt;strong&gt;changed&lt;/strong&gt; keys:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;js&lt;br&gt;
const S = (_) =&amp;gt; { w(_); };   // w = setState, _ = changedAttributes&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;And the LiveKit agents framework writes &lt;code&gt;lk.agent.state&lt;/code&gt; on &lt;em&gt;every&lt;/em&gt; listening → thinking → speaking transition. So within about a second of Kabir setting &lt;code&gt;active_agent&lt;/code&gt;, the hook's state collapsed to &lt;code&gt;{'lk.agent.state': 'speaking'}&lt;/code&gt; and my key was gone.&lt;/p&gt;

&lt;p&gt;The fix reads the participant's own full attribute map instead of the hook's delta cache:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;tsx&lt;br&gt;
const { state: agentState, internal } = useAgent();&lt;br&gt;
const activeAgent = internal.agentParticipant?.attributes.active_agent;&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two takeaways. When a value that should be sticky isn't, check whether something between you and the source is &lt;em&gt;replacing&lt;/em&gt; state where it should be &lt;em&gt;merging&lt;/em&gt; it. And when the docs don't explain a hook's behaviour, &lt;code&gt;node_modules&lt;/code&gt; will — reading the compiled source took twenty minutes and three wrong theories cost me an hour before that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build your own Rishika (Or any other)
&lt;/h2&gt;

&lt;p&gt;The fastest starting point is the &lt;a href="https://github.com/murf-ai/murf-livekit-starter" rel="noopener noreferrer"&gt;Murf LiveKit starter&lt;/a&gt; —&lt;br&gt;
it gets you a working voice loop, and then you change the prompt. My repo is that starter plus nine&lt;br&gt;
days of additions.&lt;/p&gt;

&lt;p&gt;You need Python 3.10+, &lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt;, Node 18+, pnpm, and free accounts at&lt;br&gt;
LiveKit Cloud, Murf, Deepgram and Google AI Studio.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;bash&lt;br&gt;
git clone https://github.com/Mayank-CS50/murf-livekit-starter.git&lt;br&gt;
cd murf-livekit-starter&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the API keys go
&lt;/h3&gt;

&lt;p&gt;This is worth doing properly on day one, not day nine. Keys go in &lt;strong&gt;&lt;code&gt;.env.local&lt;/code&gt;&lt;/strong&gt;, which is gitignored in both &lt;code&gt;backend/&lt;/code&gt; and &lt;code&gt;frontend/&lt;/code&gt;. The tracked &lt;code&gt;.env.example&lt;/code&gt; files are templates with placeholders — never put a real key in one, because that's the file that gets committed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;bash&lt;br&gt;
cp backend/.env.example  backend/.env.local&lt;br&gt;
cp frontend/.env.example frontend/.env.local&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Fill in &lt;code&gt;LIVEKIT_URL&lt;/code&gt;, &lt;code&gt;LIVEKIT_API_KEY&lt;/code&gt;, &lt;code&gt;LIVEKIT_API_SECRET&lt;/code&gt;, &lt;code&gt;MURF_API_KEY&lt;/code&gt;, &lt;code&gt;DEEPGRAM_API_KEY&lt;/code&gt;, &lt;code&gt;GOOGLE_API_KEY&lt;/code&gt;. The three &lt;code&gt;LIVEKIT_*&lt;/code&gt; values go in the frontend too — the frontend and backend never call each other, they meet inside the same LiveKit project.&lt;/p&gt;

&lt;p&gt;Then check before every commit:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;bash&lt;br&gt;
git status --short      # no .env.local, no *.db&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Learn from me on that second one: I had a SQLite file with test student profiles tracked in git for a week without noticing. &lt;code&gt;.gitignore&lt;/code&gt; covered &lt;code&gt;.env.*&lt;/code&gt; and nothing else. If your agent stores anything about a user, add the database to &lt;code&gt;.gitignore&lt;/code&gt; before your first commit, not after.&lt;/p&gt;

&lt;h3&gt;
  
  
  Install and run
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;bash&lt;br&gt;
cd backend  &amp;amp;&amp;amp; uv sync &amp;amp;&amp;amp; uv run python src/agent.py download-files&lt;br&gt;
cd ../frontend &amp;amp;&amp;amp; pnpm install&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two terminals:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;bash&lt;br&gt;
cd backend  &amp;amp;&amp;amp; uv run python src/agent.py dev     # the agent&lt;br&gt;
cd frontend &amp;amp;&amp;amp; pnpm dev                            # the UI&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt;&lt;/strong&gt;, click &lt;strong&gt;Start talking&lt;/strong&gt;, allow the mic. Or skip the browser entirely with &lt;code&gt;uv run python src/agent.py console&lt;/code&gt;, which runs the whole loop in the terminal — the fastest way to iterate on a prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test that it actually works
&lt;/h3&gt;

&lt;p&gt;Say these in order; each exercises a different layer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Say&lt;/th&gt;
&lt;th&gt;Expect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"नमस्ते"&lt;/td&gt;
&lt;td&gt;Instant greeting, zero LLM tokens (regex interceptor)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"मैं रमेश हूँ"&lt;/td&gt;
&lt;td&gt;She learns your name and asks before saving&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"मुझे एक quiz दो"&lt;/td&gt;
&lt;td&gt;Filler audio, then a clickable question card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"IR sensor का threshold कैसे set करूं?"&lt;/td&gt;
&lt;td&gt;She answers herself — no handoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"मेरा robot line पर ज़िग-ज़ैग कर रहा है"&lt;/td&gt;
&lt;td&gt;Handoff announced, Kabir takes over in a male voice, transcript turns cyan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"L298N गरम है, जलने की smell आ रही है"&lt;/td&gt;
&lt;td&gt;She stops debugging and asks to alert a human&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the agent never responds, it's almost always one of three things: the backend isn't running, the frontend and backend are pointed at different LiveKit projects, or the mic permission was denied. Check the backend terminal first — it logs the room join.&lt;/p&gt;

&lt;h3&gt;
  
  
  35 tests, no network
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;bash&lt;br&gt;
cd backend &amp;amp;&amp;amp; uv run python -m pytest tests -q&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;They run with no API keys and no LiveKit session, and they cover exactly the places a prompt can't be trusted: consent enforcement, PID routing (ten real student sentences — five that must stay with Rishika, five that must not), opt-out persistence, outcome classification, and an assertion that nothing private can reach the dashboard.&lt;/p&gt;




&lt;h2&gt;
  
  
  Privacy, briefly
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F54kxefax9t9dw1at96ak.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F54kxefax9t9dw1at96ak.png" alt="PID Specialist" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Worth stating because a tutor for students collects exactly the data you'd least want leaked. The call analytics table has &lt;strong&gt;no transcript column, no message log, and no phone number field&lt;/strong&gt; — a call is stored as turn counts, an outcome, and a duration. Student names are stripped to letters&lt;br&gt;
before they reach the dashboard, so a hallucinated name carrying digits can't leak. Escalation summaries run through a PII scrub that strips phone numbers, OTPs, PINs and account numbers while leaving &lt;code&gt;L298N&lt;/code&gt; and &lt;code&gt;PID&lt;/code&gt; intact. And the ops page has no authentication, so it binds to&lt;br&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt; and stays there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx9vd1vbl7xbd9ob8tc1a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx9vd1vbl7xbd9ob8tc1a.png" alt="DashBoard Monitor" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd improve next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A real phone call.&lt;/strong&gt; The outbound SIP path, opening disclosure and opt-out all work in code and   in tests, but I never attached a paid trunk, so it has never rung an actual phone. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency-to-first-speech on the dashboard.&lt;/strong&gt; I'm quoting Falcon's published numbers in this post rather than my own measurements, which is exactly the sort of thing that should be a chart from my own &lt;code&gt;metrics_collected&lt;/code&gt; stream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An IR-calibration specialist&lt;/strong&gt;, the obvious second agent. Skipped deliberately — one working handoff satisfied the requirement and a second doubles the routing surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real students.&lt;/strong&gt; Everything above is validated by me and a test suite. That's not the same as a fifteen-year-old at 11pm, who will do things to this agent I haven't imagined.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/Mayank-CS50/murf-livekit-starter" rel="noopener noreferrer"&gt;github.com/Mayank-CS50/murf-livekit-starter&lt;/a&gt;
— &lt;code&gt;handoff.md&lt;/code&gt; in the repo is the build log, including what I skipped and why&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starter template:&lt;/strong&gt; &lt;a href="https://github.com/murf-ai/murf-livekit-starter" rel="noopener noreferrer"&gt;murf-ai/murf-livekit-starter&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Murf Falcon:&lt;/strong&gt; &lt;a href="https://murf.ai/api/docs/text-to-speech-models/falcon-2" rel="noopener noreferrer"&gt;docs&lt;/a&gt; ·
&lt;a href="https://murf.ai/falcon/benchmarks" rel="noopener noreferrer"&gt;benchmarks&lt;/a&gt; ·
&lt;a href="https://murf.ai/api/docs/voices-styles/voice-library" rel="noopener noreferrer"&gt;voice library&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LiveKit Agents:&lt;/strong&gt; &lt;a href="https://docs.livekit.io/agents/start/voice-ai/" rel="noopener noreferrer"&gt;voice AI quickstart&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're picking this up: build the boring loop first — mic in, voice out, one prompt. Everything interesting I built came from listening to that loop being wrong, and none of it was on my plan on day one.&lt;/p&gt;

&lt;p&gt;Built for &lt;strong&gt;10 Days of Voice Agents — VoiceForBharat Edition&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>voiceagent</category>
      <category>ai</category>
      <category>voiceai</category>
      <category>python</category>
    </item>
  </channel>
</rss>
