2026年5月のTwilio年次イベントにてTwilio Conversationsという新製品群がリリースされました。その中から、一番理解しづらいTwilio Agent Connectという製品を使った実装についてのベースの機能を紹介してみたいと思います。
本来であれば、新製品群を連携するために使う製品ですが、今回はTAC単独で利用した場合について書いています。
はじめに
TwilioでAI音声アプリを作ろうとすると、これまではかなりの量のコードが必要でした。
- TwiMLを返すHTTPエンドポイント
- ConversationRelayのWebSocketサーバ
- WebSocketのメッセージパース・ルーティング
- TTSやSTTのプロバイダ設定
- 通話状態の管理
- コールバックの処理
これらを自前で実装し、それぞれを正しく繋ぎ合わせる必要がありました。
Twilio Agent Connect (TAC) を使うと、こうした「お決まりの下準備コード」がほぼ消えます。開発者が書くのは「AIに何をさせるか」のロジックだけです。
従来のアプローチ
従来、ConversationRelayを使ってAI電話アプリを構築する場合、ざっくりこんな実装が必要でした。
// TwiMLサーバ
app.post("/twiml", (req, res) => {
const response = new VoiceResponse();
const connect = response.connect();
connect.conversationRelay({
url: `wss://${domain}/ws`,
ttsProvider: "google",
voice: "ja-JP-Neural2-B",
// ...その他設定
});
res.type("text/xml").send(response.toString());
});
// WebSocketサーバ
wss.on("connection", (ws) => {
let conversationId: string;
ws.on("message", async (data) => {
const msg = JSON.parse(data.toString());
switch (msg.type) {
case "setup":
conversationId = msg.callSid;
// 初期化処理...
break;
case "prompt":
// ユーザーの発話を受け取る
const userText = msg.voicePrompt;
// AIに投げる
const response = await callAI(userText);
// 音声で返す
ws.send(JSON.stringify({ type: "text", token: response }));
break;
case "interrupt":
// 割り込み処理...
break;
// ...他にもハンドリングが必要
}
});
});
// コールバックエンドポイント
app.post("/callback", (req, res) => {
// 通話終了時の処理...
});
WebSocketのメッセージ種別のハンドリング、JSONのパース、状態管理、エラーハンドリング...。AIのロジックにたどり着く前にやることが多すぎます。
TAC を使ったアプローチ
同じことをTACで書くとこうなります。
import "dotenv/config";
import OpenAI from "openai";
import { TAC, TACConfig, TACServer, VoiceChannel } from "twilio-agent-connect";
const openai = new OpenAI();
const history: Record<
string,
Array<{ role: "user" | "assistant"; content: string }>
> = {};
const SYSTEM_PROMPT = `あなたはレストランの予約受付AIです。丁寧な日本語で簡潔に応答してください。
予約に必要な情報: お名前、日付、時間、人数。一つずつ確認してください。
営業時間: 11:00-22:00、定休日: 月曜、最大8名。`;
async function main() {
const tac = await TAC.create({ config: TACConfig.fromEnv() });
const voiceChannel = new VoiceChannel(tac, { memoryMode: "never" });
tac.registerChannel(voiceChannel);
tac.onMessageReady(async ({ conversationId, message, abortSignal }) => {
if (!history[conversationId]) history[conversationId] = [];
history[conversationId].push({ role: "user", content: message });
if (abortSignal?.aborted) return;
const stream = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
...history[conversationId],
],
stream: true,
});
async function* tokens() {
for await (const chunk of stream) {
if (chunk.choices?.[0]?.delta?.content) {
yield chunk.choices[0].delta.content;
}
}
}
const full = await voiceChannel.sendStreamingResponse(
conversationId,
tokens(),
abortSignal ? { signal: abortSignal } : undefined,
);
history[conversationId].push({ role: "assistant", content: full });
});
const server = new TACServer(tac, {
host: "0.0.0.0",
port: Number(process.env.PORT || 8000),
conversationRelayConfig: {
welcomeGreeting:
"お電話ありがとうございます。ご予約についてお伺いします。",
ttsProvider: "google",
voice: "ja-JP-Neural2-B",
ttsLanguage: "ja-JP",
transcriptionProvider: "google",
transcriptionLanguage: "ja-JP",
speechModel: "telephony",
},
});
await server.start();
console.log("Server running on port", process.env.PORT || 8000);
}
main().catch(console.error);
これが全コードです。 ファイル1つ、約50行。
何が消えたのか
TACが内部で処理してくれるもの:
| 従来自分で書いていたもの | TACでは |
|---|---|
| TwiMLエンドポイント |
TACServer が自動生成 |
| WebSocketサーバの実装 |
TACServer が自動管理 |
| メッセージのパース・ルーティング |
onMessageReady に抽象化 |
| 割り込み(barge-in)処理 |
abortSignal として提供 |
| TTS/STTプロバイダ設定 |
conversationRelayConfig で宣言的に指定 |
| 通話ごとの状態分離 |
conversationId で自動分離 |
開発者が集中すべきは「ユーザーの発話に対してAIがどう応答するか」だけ。TACはそれ以外のインフラ層を引き受けてくれます。
ポイント解説
ストリーミングレスポンス
const full = await voiceChannel.sendStreamingResponse(
conversationId,
tokens(),
abortSignal ? { signal: abortSignal } : undefined,
);
ジェネレータでトークンを1つずつ yield するだけで、TACがリアルタイムにTTSへ流してくれます。ユーザーはAIの応答を待たされず、生成されたそばから音声が聞こえます。
割り込み対応
if (abortSignal?.aborted) return;
ユーザーがAIの応答中に話し始めた場合、abortSignal が発火します。従来はWebSocketの interrupt メッセージを自分でハンドリングする必要がありましたが、TACでは標準の AbortSignal パターンで扱えます。
環境変数だけで接続設定が完了
TWILIO_ACCOUNT_SID=ACxxxxx
TWILIO_AUTH_TOKEN=xxxxx
TWILIO_API_KEY=SKxxxxx
TWILIO_API_SECRET=xxxxx
TWILIO_PHONE_NUMBER=+81xxxxxxxxxx
TWILIO_VOICE_PUBLIC_DOMAIN=xxxx.ngrok-free.app
PORT=8000
TACConfig.fromEnv() がこれらを読み取り、Twilioとの接続を自動で構成します。TWILIO_VOICE_PUBLIC_DOMAIN にはプロトコル(https://)を含めず、ホスト名だけを指定する点に注意してください。
Voice-Only モード
Conversation Orchestrator(会話の状態管理やハンドオフ機能)を使わない場合、TWILIO_CONVERSATION_CONFIGURATION_ID を設定しなければ voice-only モードで動作します。シンプルなAI電話アプリならこれで十分です。
まとめ
TACを使うことで:
- コード量が大幅に減る - お決まりの下準備コードが不要に
-
AIロジックに集中できる -
onMessageReadyの中身だけ書けばいい - ストリーミングが簡単 - ジェネレータを渡すだけ
- 標準的なパターン - AbortSignal、async/await など馴染みのあるAPIで操作できる
さらに、TACを土台にしておけば、Twilioの新機能である Conversations Memory(会話の記憶・文脈保持)、Orchestrator(会話フローの管理・エージェント間ハンドオフ)、Intelligence(通話分析・感情検出)とも設定を追加するだけですぐに連携できます。最小構成から始めて、必要に応じて機能を拡張していける設計です。
TIPS: デプロイリージョンとレイテンシー
本番環境では US-EAST-1(バージニア) リージョンにデプロイするのがおすすめです。TwilioのメディアサーバやConversationRelayの基盤がUS-EAST-1に近いため、音声のやりとりのレイテンシーがかなり良くなります。
ローカル環境(ngrok経由など)での実行は、ネットワークの往復が増える分どうしても遅延が目立ちます。ローカルはあくまで動作確認・検証目的と割り切って、実際のユーザー体験を評価するときはクラウド上のUS-EAST-1環境で試してみてください。
まとめ(再掲)
「電話 x AI」の開発体験が、Webアプリ開発に近い感覚になります。