I tried putting together a scenario using Twilio ConversationRelay with a restaurant as the subject, where "first-line phone responses are handled by AI"

I tried putting together a scenario using Twilio ConversationRelay with a restaurant as the subject, where "first-line phone responses are handled by AI"

We implemented a demo that delegates the initial phone response for a restaurant to AI, covering everything from incoming calls through FAQ responses, SMS and email sending, and escalation to a staff member, all in a single scenario. We'll walk you through the architecture and the key insights we discovered by working through the implementation challenges.
2026.07.18

This page has been translated by machine translation. View original

Introduction

This article introduces a demo implementation that delegates the initial response of phone calls to AI, covering the entire scenario from incoming calls, FAQ responses, SMS and email sending, to escalation to staff. The subject is inquiry handling for a fictional restaurant. The overall structure is as follows.

There are many tips when working with audio implementations that you can only discover by actually running them, and this article focuses on those key points. The complete working code is included in the appendix at the end, so please refer to it.

What is Twilio

Twilio is a cloud service that provides communication features such as SMS, voice calls, video calls, and email sending as APIs. You can incorporate full-fledged communication features into your application with just a small amount of code.

Verification Environment

  • Voice Infrastructure: Twilio ConversationRelay
  • Generative AI: Amazon Bedrock (Claude Haiku 4.5, us-east-1)
  • Email Sending: SendGrid
  • Notifications: Slack (Incoming Webhook)
  • Runtime Environment: fly.io (Node.js 24, Fastify)

Target Audience

  • Those considering whether generative AI can reduce the burden of initial phone response
  • Those who want to know specific configurations and key points for building voice AI with Twilio ConversationRelay
  • Those who want to see an implementation example connecting voice, SMS, email, and chat in a single scenario

References

Background and Challenges

Initial phone response has three challenges.

The first is handling repetitive inquiries. Routine inquiries such as business hours and pricing come in repeatedly, requiring staff to stop what they are doing each time. According to a survey by NTT Com Online, 70.9% of people attempt to self-resolve on the web before calling a call center (Source: NTT Com Online 2023 Survey Report). Inquiries that still don't get resolved and flow to phone calls create a burden on the floor.

The second is recruitment and retention of staff. According to a survey by CCAJ (Japan Contact Center Association), 82% of companies responded that they struggle with recruitment (Source: CCAJ 2025 Contact Center Corporate Survey). Training also takes time, and if turnover continues, the quality of response becomes unstable.

The third is after-hours response. If business hours are 9 hours out of 24 hours in a day, phones cannot be answered for the remaining 15 hours. If calls don't connect, customers will go to competitors, leading to missed opportunities.

Therefore, we implemented and verified whether AI could handle this initial response, using restaurant inquiries as the subject, running the entire flow from incoming calls to responses.

Configuration and Implementation

The scenario created is as follows. When a customer calls, the AI responds in Japanese and answers FAQs about business hours, location, and so on. When the call ends, the confirmed information is sent by SMS to the caller, and a call summary is emailed to the staff. When the AI determines it cannot handle the situation, a summary is sent to Slack and the case is handed off to a staff member.

The centerpiece of the configuration is Twilio ConversationRelay. Since ConversationRelay handles speech recognition and speech synthesis, the server side can focus on receiving speech text, calling the generative AI, and returning response text. On incoming calls, TwiML is returned that uses ConversationRelay inside Connect, specifying the WebSocket server URL and Japanese voice.

<Response>
  <Connect action="https://your-app.example.com/voice/action">
    <ConversationRelay url="wss://your-app.example.com/ws"
      welcomeGreeting="お電話ありがとうございます。AI が一次対応いたします。"
      language="ja-JP" ttsProvider="Google" voice="ja-JP-Chirp3-HD-Kore"
      transcriptionProvider="Google" />
  </Connect>
</Response>

This time we used Google's generative voice ja-JP-Chirp3-HD-Kore.

For the LLM, prioritizing speed, we called Amazon Bedrock's Claude Haiku 4.5 in us-east-1.

Hosting is on fly.io. Since ConversationRelay requires persistent WebSocket connections, request-response function platforms are not suitable, and an environment where a persistent server can be placed is needed. Since IAM roles cannot be used from fly.io, we created an IAM user with minimum permissions to call Bedrock using Terraform, and set the access key as a secret.

Over WebSocket, caller information is received in setup immediately after connection, and speech text is received in prompt. Responses are returned as text messages, and when handing off to staff, end is sent. This conversation logic is designed to inject sending, generative AI calls, notifications, and SMS, making it unit-testable (full text in the appendix).

For reproduction, you will need a Twilio phone number, an AWS environment with Bedrock enabled, fly.io and SendGrid accounts, and a Slack Incoming Webhook. Deploy with fly deploy, and point the phone number's voice webhook to the endpoint that returns the above TwiML, and it will work. When actually calling, the AI answered questions like business hours in Japanese, SMS and email arrived after the call, and when asking for a refund, a summary was sent to Slack and the call ended.

Insights Gained from Implementation

Don't Wait for the Complete Response Before Playing

Initially, we waited for the complete response from the generative AI before passing it to text-to-speech, but it felt slow subjectively. When measured in logs, it took about 1 second from the end of speech to the first token. The cause was not the speed of the generative AI, but the design that waits for the full text to complete.

So we switched to Bedrock's ConverseStream and send text tokens each time a delta arrives. Playback starts when the first sentence arrives, reducing wait time.

app/src/bedrock.js (excerpt)
for await (const ev of res?.stream ?? []) {
  const delta = ev?.contentBlockDelta?.delta?.text;
  if (!delta) continue;
  full += delta;
  hold += delta;
  // Leave the marker length at the end to prevent control markers at the end from leaking into playback
  if (hold.length > marker.length) {
    const cut = hold.length - marker.length;
    emit(hold.slice(0, cut)); // emit sends text tokens via onToken
    hold = hold.slice(cut);
  }
}
emit(hold);

For escalation detection, we have [[ESCALATE]] as a control marker appended to the end of responses, but if sent incrementally as-is, the marker would be read aloud. By holding back the end by the length of the marker before sending as shown above, we prevent the marker from leaking into the audio.

Don't Miss Speech While AI is Talking

During testing, queries sometimes didn't reach the server. The cause was that the default for ConversationRelay's reportInputDuringAgentSpeech is none, meaning speech during AI talking is not notified by specification (this default changed from any to none in May 2025. Source: ConversationRelay TwiML Reference).

To enable interruption while speaking (= barge-in), set reportInputDuringAgentSpeech and interruptible to speech.

app/src/twiml.js (excerpt)
connect.conversationRelay({
  url: wsUrl,
  language: 'ja-JP',
  ttsProvider: 'Google',
  voice: 'ja-JP-Chirp3-HD-Kore',
  transcriptionProvider: 'Google',
  interruptible: 'speech',
  reportInputDuringAgentSpeech: 'speech', // Default none means voice during speech doesn't arrive
});

Only Disconnect in the End Hook

Initially, when handing off to staff, we were having a message read aloud in the Connect action callback. This caused the same content to play twice in duplicate, in a different default voice from the AI's voice.

As a fix, have the AI say the handoff message, and only perform the disconnection in action without reading aloud.

app/src/server.js (excerpt)
// action callback when escalation ends. Since AI has already given the message, don't read aloud
fastify.post('/voice/action', async (request, reply) => {
  const response = new twilioSdk.twiml.VoiceResponse();
  response.hangup();
  reply.header('content-type', 'text/xml').send(response.toString());
});

Emails Not Sent from Authenticated Domains Go to Spam

The call summary emails were initially classified as spam. This was because the sender was set to a custom domain with only sender authentication. SPF/DKIM doesn't work, and the receiving side judges them as emails that failed authentication. Even if registered as a safe sender in the recipient's personal settings, corporate email security may override this, making it ineffective.

The fix is to send from a domain authenticated in SendGrid. Just by changing the sender to an address of that domain, DKIM signing is applied, and the email arrives in the inbox. We didn't need to touch DNS because there was already an authenticated domain in the account.

Summary

We ran the initial phone response scenario end-to-end, from incoming calls through FAQ responses, SMS and email, to handoff to staff. While speech recognition and synthesis can be delegated to ConversationRelay, key points specific to voice AI — such as sending responses incrementally, not missing speech during talking, avoiding duplicate messages, and sending emails from authenticated domains — only became apparent after actually running it. We hope this article serves as a reference when considering automation of initial phone response.

Webinar Announcement

Classmethod holds webinars on utilizing Twilio.

mailsns_w1200h630 (1)

Those interested in the latest Twilio topics and implementation examples are welcome to visit the Twilio Webinar.

Appendix: Source Code

The full text of the main files is included for reproduction. Some parts such as measurement logs have been simplified for readability.

app/src/twiml.js
app/src/twiml.js
// Generates TwiML including ConversationRelay.

import twilio from 'twilio';

const { VoiceResponse } = twilio.twiml;

export const DEFAULT_VOICE = 'ja-JP-Chirp3-HD-Kore';

export function buildConnectTwiml({
  wsUrl,
  actionUrl,
  welcomeGreeting = 'お電話ありがとうございます。AI が一次対応いたします。ご用件をお話しください。',
  voice = DEFAULT_VOICE,
}) {
  const response = new VoiceResponse();
  const connect = actionUrl ? response.connect({ action: actionUrl }) : response.connect();
  connect.conversationRelay({
    url: wsUrl,
    welcomeGreeting,
    language: 'ja-JP',
    ttsProvider: 'Google',
    voice,
    transcriptionProvider: 'Google',
    // Enable interruption while AI is speaking (barge-in).
    interruptible: 'speech',
    reportInputDuringAgentSpeech: 'speech',
  });
  return response.toString();
}
app/src/faq.js (FAQ knowledge and system prompt for speech)
app/src/faq.js
// Builds the FAQ knowledge for a fictional demo restaurant and the AI's system prompt.

export const RESTAURANT = {
  name: 'ビストロ クラスメソッド',
  hours: '11:00〜15:00 と 17:00〜22:00 (ラストオーダー 21:00)。水曜定休。',
  location: '東京都千代田区丸の内 1-1-1。JR 東京駅 丸の内南口から徒歩 3 分。',
  budget: 'ランチはお一人 1,200 円前後、ディナーはお一人 4,000 円前後です。',
  reservation: 'お電話またはこの AI で承ります。ご希望の日時と人数をお知らせください。',
  parking: '専用駐車場はありません。近隣のコインパーキングをご利用ください。',
};

export function buildSystemPrompt(faq = RESTAURANT) {
  return [
    `あなたは飲食店「${faq.name}」の電話一次対応 AI です。電話越しにお客様と話しています。`,
    '',
    '店舗情報:',
    `- 営業時間: ${faq.hours}`,
    `- 場所: ${faq.location}`,
    `- 予算: ${faq.budget}`,
    `- 予約: ${faq.reservation}`,
    `- 駐車場: ${faq.parking}`,
    '',
    '話し方の決まり:',
    '- 音声で読み上げられます。記号や markdown は使わないでください。アスタリスクや番号付き・箇条書きの記号を出力してはいけません。',
    '- 英語やアルファベットをそのまま書かず、カタカナで言ってください。',
    '- 自然で聞き取りやすい丁寧語で話してください。不自然な言い回し (たとえば「ご件」) は使わないでください。',
    '- 一度の返答は 1 文から 2 文までにし、短く答えてください。',
    '',
    '対応の方針:',
    '- 店舗情報で答えられることは、その範囲で正確に答えてください。',
    '- 次のいずれかに当てはまる場合は、返答の一番最後に [[ESCALATE]] という印だけを付けてください。担当者に引き継ぐ合図です。',
    '  - 店舗情報にない内容で、確かな回答ができないとき',
    '  - クレームや強い不満を示されたとき',
    '  - 契約・解約・返金など、意思決定を伴う判断が必要なとき',
    '- [[ESCALATE]] を付けるときは、その前に「担当者から折り返しご連絡します」と一言だけ伝えてください。',
  ].join('\n');
}
app/src/bedrock.js (Streaming and control marker removal)
app/src/bedrock.js
// Calls Amazon Bedrock's Claude Haiku 4.5 with ConverseStream.

import { BedrockRuntimeClient, ConverseStreamCommand } from '@aws-sdk/client-bedrock-runtime';

import { ESCALATION_MARKER } from './escalation.js';
import { sanitizeForSpeech, collapseWhitespace } from './sanitize.js';

export function createBedrockClient({ region }) {
  return new BedrockRuntimeClient({ region });
}

// Streaming version. Passes generated tokens to onToken incrementally to start playback sooner.
// Holds back the end by the marker length and removes it to prevent escalation markers from leaking into playback.
export async function streamReply({ client, modelId, systemPrompt, history, onToken, maxTokens = 300, temperature = 0.3 }) {
  const command = new ConverseStreamCommand({
    modelId,
    system: [{ text: systemPrompt }],
    messages: history.map((m) => ({ role: m.role, content: [{ text: m.content }] })),
    inferenceConfig: { maxTokens, temperature },
  });
  const res = await client.send(command);

  const marker = ESCALATION_MARKER;
  let full = '';
  let hold = '';

  const emit = (raw) => {
    const speak = sanitizeForSpeech(raw.split(marker).join(''));
    if (speak && onToken) onToken(speak);
  };

  for await (const ev of res?.stream ?? []) {
    const delta = ev?.contentBlockDelta?.delta?.text;
    if (!delta) continue;
    full += delta;
    hold += delta;
    // Leave the marker length at the end to prevent markers at the end from leaking into playback.
    if (hold.length > marker.length) {
      const cut = hold.length - marker.length;
      emit(hold.slice(0, cut));
      hold = hold.slice(cut);
    }
  }
  emit(hold);

  const escalate = full.includes(marker);
  const fullText = collapseWhitespace(sanitizeForSpeech(full.split(marker).join('')));
  return { fullText, escalate };
}
app/src/ws-handler.js (Conversation logic)
app/src/ws-handler.js
// Handles the ConversationRelay WebSocket protocol conversation logic.
// Injects sending (emit), LLM streaming, notifications, and SMS to make unit testing possible.

export function createConversation(deps) {
  const session = { history: [], from: null, to: null, callSid: null, lastAssistant: null };

  async function onMessage(msg) {
    if (!msg || typeof msg !== 'object') return {};

    if (msg.type === 'setup') {
      session.from = msg.from ?? null;
      session.to = msg.to ?? null;
      session.callSid = msg.callSid ?? null;
      return {};
    }

    if (msg.type === 'prompt') {
      session.history.push({ role: 'user', content: msg.voicePrompt });

      const { fullText, escalate } = await deps.streamReply({
        client: deps.client,
        modelId: deps.modelId,
        systemPrompt: deps.systemPrompt,
        history: session.history,
        onToken: (token) => deps.emit({ type: 'text', token, last: false }),
      });

      // Notify end of playback turn.
      deps.emit({ type: 'text', token: '', last: true });

      session.history.push({ role: 'assistant', content: fullText });
      session.lastAssistant = fullText;

      if (escalate) {
        const lastUser = [...session.history].reverse().find((m) => m.role === 'user');
        await deps.notifyEscalation({
          from: session.from,
          to: session.to,
          request: lastUser?.content ?? '',
          reply: fullText,
          transcript: session.history,
          time: deps.now ? deps.now() : null,
        });
        deps.emit({
          type: 'end',
          handoffData: JSON.stringify({ reasonCode: 'live-agent-handoff', reason: fullText }),
        });
      }

      return { escalate, reply: fullText };
    }

    return {};
  }

  async function onClose() {
    if (deps.sendSms && session.from && session.lastAssistant) {
      await deps.sendSms({ to: session.from, summary: session.lastAssistant });
    }
    if (deps.sendEmail && session.lastAssistant) {
      await deps.sendEmail({
        caller: session.from,
        called: session.to,
        summary: session.lastAssistant,
        transcript: session.history,
        time: deps.now ? deps.now() : null,
      });
    }
  }

  return { session, onMessage, onClose };
}
app/src/server.js (HTTP and WebSocket assembly)
app/src/server.js
// Fastify server. Handles TwiML-returning HTTP and ConversationRelay WebSocket in a single process.

import Fastify from 'fastify';
import formbody from '@fastify/formbody';
import websocket from '@fastify/websocket';
import twilioSdk from 'twilio';

import { loadConfig } from './config.js';
import { buildConnectTwiml } from './twiml.js';
import { buildSystemPrompt } from './faq.js';
import { createBedrockClient, streamReply } from './bedrock.js';
import { notifyEscalation } from './slack.js';
import { sendSms } from './sms.js';
import { buildSummaryEmail, sendSummaryEmail } from './email.js';
import { createConversation } from './ws-handler.js';

export function buildServer(overrides = {}) {
  const config = overrides.config ?? loadConfig();
  const fastify = Fastify({ logger: overrides.logger ?? true });

  fastify.register(formbody);
  fastify.register(websocket);

  const systemPrompt = buildSystemPrompt();
  const bedrockClient = overrides.bedrockClient ?? createBedrockClient({ region: config.awsRegion });
  const twilioClient =
    overrides.twilioClient ??
    (config.twilioAccountSid && config.twilioAuthToken
      ? twilioSdk(config.twilioAccountSid, config.twilioAuthToken)
      : null);

  const wsUrl = () => (config.publicBaseUrl ? config.publicBaseUrl.replace(/^http/, 'ws') : '') + '/ws';
  const actionUrl = () => (config.publicBaseUrl ? config.publicBaseUrl : '') + '/voice/action';

  fastify.get('/', async () => 'ok');

  const voiceHandler = async (request, reply) => {
    const twiml = buildConnectTwiml({ wsUrl: wsUrl(), actionUrl: actionUrl() });
    reply.header('content-type', 'text/xml').send(twiml);
  };
  fastify.get('/voice', voiceHandler);
  fastify.post('/voice', voiceHandler);

  // action callback when escalation ends. Since AI has already delivered the message in voice, only disconnect here.
  fastify.post('/voice/action', async (request, reply) => {
    const response = new twilioSdk.twiml.VoiceResponse();
    response.hangup();
    reply.header('content-type', 'text/xml').send(response.toString());
  });

  fastify.register(async (instance) => {
    instance.get('/ws', { websocket: true }, (socket) => {
      const conversation = createConversation({
        client: bedrockClient,
        modelId: config.bedrockModelId,
        systemPrompt,
        streamReply,
        emit: (obj) => socket.send(JSON.stringify(obj)),
        now: () => new Date().toISOString(),
        notifyEscalation: (args) =>
          config.slackWebhookUrl ? notifyEscalation({ webhookUrl: config.slackWebhookUrl, ...args }) : Promise.resolve(false),
        sendSms: (twilioClient && config.twilioFromNumber)
          ? ({ to, summary }) =>
              sendSms({ client: twilioClient, from: config.twilioFromNumber, to, body: `本日はお電話ありがとうございました。${summary}` })
          : null,
        sendEmail: (config.sendgridApiKey && config.emailFrom && config.emailTo)
          ? async ({ caller, called, summary, transcript, time }) => {
              const { subject, body } = buildSummaryEmail({ from: caller, to: called, summary, transcript, time });
              return sendSummaryEmail({ apiKey: config.sendgridApiKey, fromEmail: config.emailFrom, toEmail: config.emailTo, subject, body });
            }
          : null,
      });

      socket.on('message', async (data) => {
        let msg;
        try {
          msg = JSON.parse(data.toString());
        } catch {
          return;
        }
        await conversation.onMessage(msg); // Responses are sent via emit inside onMessage
      });

      socket.on('close', () => {
        conversation.onClose().catch((err) => fastify.log.error(err));
      });
    });
  });

  return fastify;
}

AI白書2026 配布中

クラスメソッドが独自に行なったAI診断調査をもとに、企業のAI活用の現在地を調査レポートとしてまとめました。企業規模別の活用度傾向に加え、規模を超えてAI活用を進める企業に共通する取り組みまで、自社の現在地を捉えるためのヒントにぜひ。

AI白書2026

無料でダウンロードする

Share this article

AWSのお困り事はクラスメソッドへ