<?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: kabariyashyam10-ui</title>
    <description>The latest articles on DEV Community by kabariyashyam10-ui (@kabariyashyam10ui).</description>
    <link>https://dev.to/kabariyashyam10ui</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%2F4201424%2F880da852-f20c-4ccf-922a-80e208d64d8e.png</url>
      <title>DEV Community: kabariyashyam10-ui</title>
      <link>https://dev.to/kabariyashyam10ui</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kabariyashyam10ui"/>
    <language>en</language>
    <item>
      <title>How I Built an AI Phone Receptionist: Webhooks, Stateless Turns and Structured LLM Output</title>
      <dc:creator>kabariyashyam10-ui</dc:creator>
      <pubDate>Fri, 23 Oct 2026 01:51:34 +0000</pubDate>
      <link>https://dev.to/kabariyashyam10ui/how-i-built-an-ai-phone-receptionist-webhooks-stateless-turns-and-structured-llm-output-3d6h</link>
      <guid>https://dev.to/kabariyashyam10ui/how-i-built-an-ai-phone-receptionist-webhooks-stateless-turns-and-structured-llm-output-3d6h</guid>
      <description>&lt;p&gt;Small businesses miss a lot of calls. A plumber is under a sink, a dentist's front desk is checking in patients, a salon owner is mid-colour — and the caller hangs up and dials the next business on Google.&lt;/p&gt;

&lt;p&gt;I built an AI phone receptionist for small businesses in Canada (&lt;a href="https://aethely.com/ai-receptionist" rel="noopener noreferrer"&gt;Aethely&lt;/a&gt;) to cover exactly those calls. This post walks through how a call actually flows, the design decisions that mattered, and a few things I'd tell anyone building something similar.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of a call
&lt;/h2&gt;

&lt;p&gt;The whole thing is a loop of webhooks between the phone network and a small serverless backend:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A customer calls the business.&lt;/strong&gt; If nobody answers (or it's after hours), the carrier forwards the call to a phone number we control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The telephony provider hits our "incoming call" webhook.&lt;/strong&gt; We reply with instructions: say a greeting, then &lt;em&gt;listen&lt;/em&gt; for speech.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The caller speaks.&lt;/strong&gt; The provider transcribes it and posts the text to our "turn" webhook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We ask an LLM what to say next&lt;/strong&gt;, given the business's details and the conversation so far.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We reply with the next line&lt;/strong&gt; and listen again. Repeat until the call ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the call ends&lt;/strong&gt;, we text the owner a summary: who called, what they need, how urgent it is, and what was booked.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step is a stateless HTTP request, which makes it cheap to run on serverless functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Greeting and listening
&lt;/h2&gt;

&lt;p&gt;The provider (Twilio, in my case) expects XML instructions. The greeting is deliberately honest — it says it's an AI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Response&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Gather&lt;/span&gt; &lt;span class="na"&gt;input=&lt;/span&gt;&lt;span class="s"&gt;"speech"&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"/api/voice/turn?s=STATE"&lt;/span&gt;
          &lt;span class="na"&gt;speechTimeout=&lt;/span&gt;&lt;span class="s"&gt;"auto"&lt;/span&gt; &lt;span class="na"&gt;language=&lt;/span&gt;&lt;span class="s"&gt;"en-CA"&lt;/span&gt; &lt;span class="na"&gt;actionOnEmptyResult=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;Say&amp;gt;&lt;/span&gt;Hi, thanks for calling Northside Heating. I'm the virtual assistant,
    an AI. How can I help?&lt;span class="nt"&gt;&amp;lt;/Say&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/Gather&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Response&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;actionOnEmptyResult="true"&lt;/code&gt; matters: if the caller says nothing, we still get a webhook, so we can handle silence ourselves instead of the call just ending.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Keeping state without a database
&lt;/h2&gt;

&lt;p&gt;Each turn is a separate request, so where does the conversation history live? You could use a database or a cache keyed by call ID. I went simpler: &lt;strong&gt;compress the history and carry it in the callback URL.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;deflateRawSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inflateRawSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:zlib&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;packState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;deflateRawSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unpackState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;inflateRawSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical call is under ten turns, so the compressed history stays small. No database, no cleanup jobs, and every function invocation has everything it needs.&lt;/p&gt;

&lt;p&gt;(If you do this, cap the number of turns and validate everything you unpack — it came from a URL.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Asking the model for structured output
&lt;/h2&gt;

&lt;p&gt;The key decision: &lt;strong&gt;don't ask the model for free text. Ask for a JSON object&lt;/strong&gt; with both what to say and what it has learned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TURN_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;say&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;          &lt;span class="c1"&gt;// next line to speak&lt;/span&gt;
    &lt;span class="na"&gt;end_call&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;boolean&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;     &lt;span class="c1"&gt;// conversation is finished&lt;/span&gt;
    &lt;span class="na"&gt;urgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;boolean&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;       &lt;span class="c1"&gt;// matches the owner's urgent rules&lt;/span&gt;
    &lt;span class="na"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;boolean&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;     &lt;span class="c1"&gt;// put through to the owner now&lt;/span&gt;
    &lt;span class="na"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;need&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;say&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;end_call&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;urgent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transfer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;details&lt;/span&gt;&lt;span class="dl"&gt;'&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;With a schema, the backend never has to parse prose. &lt;code&gt;transfer: true&lt;/code&gt; becomes a &lt;code&gt;&amp;lt;Dial&amp;gt;&lt;/code&gt; to the owner's mobile, &lt;code&gt;end_call: true&lt;/code&gt; hangs up and sends the summary, and &lt;code&gt;details&lt;/code&gt; fills the text message — all deterministic code paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The system prompt is mostly &lt;em&gt;rules&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The prompt contains the business's hours, services, prices it's allowed to share, booking windows and what counts as urgent. But the most important part is the list of things it must &lt;strong&gt;not&lt;/strong&gt; do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only answer from the information provided. If you don't know, say so and take a message.&lt;/li&gt;
&lt;li&gt;Never give medical, legal or financial advice.&lt;/li&gt;
&lt;li&gt;Never invent prices, availability or promises.&lt;/li&gt;
&lt;li&gt;Keep replies short — this is a phone call, not a chat window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Owners trusted the system once they saw those limits. Features didn't build trust; boundaries did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Handling the awkward parts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Silence.&lt;/strong&gt; If the caller says nothing, ask once ("Sorry, are you still there?"). On the second silence, end politely and still send the owner whatever was captured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short calls and hang-ups.&lt;/strong&gt; A status callback fires when the call ends. If it lasted only a few seconds, or was never answered, we send the caller a friendly text from the business's number so the lead isn't lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security.&lt;/strong&gt; Every webhook verifies the provider's request signature (an HMAC over the URL and parameters) before doing anything. Otherwise anyone could post fake "calls" to your endpoints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; Phone conversations feel broken if replies take too long. Keep the model's output short, keep the prompt cached, and do the summary SMS after the call — not during it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: The summary is the product
&lt;/h2&gt;

&lt;p&gt;The moment that made owners say "oh, I get it" wasn't the call itself. It was getting a text like this seconds after putting down a wrench or finishing with a client:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;URGENT — New call for Northside Heating&lt;br&gt;
Caller: Jordan · (780) 555-0142&lt;br&gt;
Needs: Furnace not working, house is cold&lt;br&gt;
Booked/requested: Today 2–4 PM&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No voicemail to dig through. Everything else in the system exists to produce that message reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone building this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disclose that it's an AI.&lt;/strong&gt; It's the right thing to do, and callers mostly care that someone helpful picked up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use structured output&lt;/strong&gt; so the model can't break your control flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design the failure cases first:&lt;/strong&gt; silence, hang-ups, "I don't know", and emergencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make call forwarding painless.&lt;/strong&gt; Most owners want to keep their number. A clear guide to carrier forwarding codes (we wrote one for &lt;a href="https://aethely.com/setup/call-forwarding" rel="noopener noreferrer"&gt;Canadian carriers&lt;/a&gt;) removes the biggest setup hurdle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're curious what this looks like for a real business, there's a live demo and pricing on the &lt;a href="https://aethely.com/ai-receptionist" rel="noopener noreferrer"&gt;Aethely AI receptionist page&lt;/a&gt;. I'm happy to answer questions about the architecture in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>twilio</category>
    </item>
  </channel>
</rss>
