予約を受け付ける電話アシスタントを作成する
クリニックの受付に、電話応対、再来患者の認識、予約、テキストによる確認を行う AI agent を提供します
作成するもの
- 自身の電話番号を持ち、自然な音声で電話に応答し、Web ページからも呼び出せる agent。
- agent は caller ID で再来患者を認識し、SMS で送信したコードで本人確認を行い、初回の発信者を登録し、クリニックの Web サイトに関する質問に答えます。
- 空き枠を探して予約を行い、確認メッセージを送信し、緊急のケースは要約とともにスタッフへ引き継ぎます。
このチュートリアルでは、以下の機能を使用します。
| AI Abilities | Connectors | Backend Functions |
|---|---|---|
| AI Functions、Twilio ability | Twilio、OpenAI Voice、Generic Site Ingester、Built-in Database | Voice agents、Security rules |
学べること
- agent に電話番号と音声を設定し、実際に電話を受ける前にブラウザからテストする方法。
- AI functions が通話コンテキストを使用して発信者を識別・本人確認する方法。
- agent が予約を行い、テキストで確認し、人に転送する方法。
必要なもの
- Squid CLI
- Squid Console のアカウント
- 音声通話および SMS 機能を備えた電話番号を持つ Twilio アカウント。トライアルアカウントでも利用できますが、発信・SMS 送信の対象となる番号がアカウント内で認証されている必要があります。
- API アクセス可能な OpenAI project
- TypeScript の基本的な経験
アプリケーションと connectors を作成する
-
Squid Console に移動し、Clinic Front Desk という名前の新しいアプリケーションを作成します。
-
Connectors タブを開き、Available Connectors に切り替えて、id を
twilioとする Twilio connector を追加します。Twilio の Account SID と Auth Token(Twilio Console の Account Info)を入力します。 -
id を
openai_voiceとする OpenAI Voice connector を追加します。OpenAI project の API Key と Project ID(proj_…、project の設定に表示されます)を入力します。Webhook Secret は今のところ空欄のままにしてください。webhook を作成した後で入力します。 -
id を
clinic_siteとする Generic Site Ingester connector を追加し、クリニック Web サイトのsitemap.xmlの URL を指定します。これにより agent は、そこに公開されている診療時間、所在地、医療提供者、FAQ を把握できます。対象となるサイトがない場合は、いくつかのドキュメントをアップロードした knowledge base でも同様のことができます。
backend を初期化する
-
アプリケーションの概要ページで Backend project セクションまでスクロールし、Initialize Backend をクリックして、表示されるコマンドを空のフォルダで実行します。
-
そのフォルダで、backend が患者にテキストを送信するために使用する Twilio client package をインストールします。
npm install @squidcloud/twilio-client
受付のツールを作成する
agent には 7 つの AI functions を与えます。発信者の検索、認証コードの送信、コードの確認、新規患者の登録、空き枠の検索、予約、転送前のスタッフへの要約です。患者と予約は built-in database に保存され、agent が会話している電話番号は Twilio connector が保持する通話レコードから取得します。
src/service/front-desk-service.ts を作成します。
import { aiFunction, AiFunctionCallContext, secureAiAgent, SquidService } from '@squidcloud/backend';
import { SquidTwilioClient, TwilioCallRecord } from '@squidcloud/twilio-client';
const AGENT_ID = 'front-desk';
const TWILIO_INTEGRATION_ID = 'twilio';
/** Where urgent callers are transferred to, and where transfer briefings are texted. */
const STAFF_PHONE = '+15550100100';
const SLOT_MINUTES = 30;
const OPENING_HOUR = 9;
const CLOSING_HOUR = 17;
const MAX_CODE_ATTEMPTS = 3;
interface Patient {
patientId: string;
phone: string;
firstName: string;
lastName: string;
dateOfBirth: string;
email?: string;
}
interface Appointment {
appointmentId: string;
patientId: string;
startsAt: string;
reason: string;
bookedAt: string;
}
/** The identity check of one conversation: a code texted to the number on file. */
interface Verification {
conversationKey: string;
patientId: string;
code: string;
attempts: number;
verifiedAt?: string;
}
interface TransferBrief {
conversationKey: string;
patientId?: string;
reason: string;
urgency: string;
summary: string;
createdAt: string;
}
/** What the Twilio and OpenAI engines put in the agent context of every function call during a phone call. */
interface CallAgentContext {
twilioCallSid?: string;
twilioIntegrationId?: string;
/** Set by the browser test page, so a web call has a conversation of its own. */
webCallerKey?: string;
}
type Ctx = AiFunctionCallContext<unknown, CallAgentContext | undefined>;
export class FrontDeskService extends SquidService {
private readonly calls = this.squid.collection<TwilioCallRecord>('twilio_calls');
private readonly patients = this.squid.collection<Patient>('patients');
private readonly appointments = this.squid.collection<Appointment>('appointments');
private readonly verifications = this.squid.collection<Verification>('verifications');
private readonly briefs = this.squid.collection<TransferBrief>('transfer_briefs');
/** Browser test calls are subject to the same rules as chats: allow signed-in users. */
@secureAiAgent(AGENT_ID)
allowSignedInUsers(): boolean {
return this.isAuthenticated();
}
@aiFunction('Looks up whether the caller is a known patient, by the number they are calling from', [])
async lookupCaller(_params: unknown, ctx: Ctx): Promise<string> {
const phone = await this.callerNumber(ctx);
if (!phone) return 'This conversation has no phone number. Ask the caller for the phone number on file.';
const patient = await this.patientByPhone(phone);
if (!patient) return 'No patient has this number on file. Offer to register the caller as a new patient.';
return `A patient named ${patient.firstName} has this number. Greet them by name, and verify them with a code before sharing details or booking.`;
}
@aiFunction('Texts a verification code to the phone number on file, to prove the caller is the patient', [
{
name: 'phone',
type: 'string',
description: 'The phone number on file in E.164 format, e.g. +15551234567; omit to use the number of this call',
required: false,
},
])
async sendVerificationCode({ phone }: { phone?: string }, ctx: Ctx): Promise<string> {
const conversationKey = this.conversationKey(ctx);
if (!conversationKey) return 'Verification is only possible on a call.';
const number = phone?.trim() || (await this.callerNumber(ctx));
if (!number) return 'Ask the caller for the phone number on file.';
const patient = await this.patientByPhone(number);
if (!patient) return 'No patient has that number on file. Ask them to double-check it.';
const code = String(Math.floor(1000 + Math.random() * 9000));
await this.verifications.doc(conversationKey).upsert({ conversationKey, patientId: patient.patientId, code, attempts: 0 });
// Text from the agent's own number, through the Twilio connector.
const twilio = new SquidTwilioClient(this.squid, TWILIO_INTEGRATION_ID);
await twilio.sendSms({
agentId: AGENT_ID,
to: patient.phone,
body: `Your Bright Smile Dental verification code is ${code}. It is only valid for this call.`,
});
return `A four-digit code was texted to the number ending in ${patient.phone.slice(-4)}. Ask the caller to read it back.`;
}
@aiFunction('Confirms the verification code the caller read back', [{ name: 'code', type: 'string', description: 'The four digits the caller read back', required: true }])
async confirmVerificationCode({ code }: { code: string }, ctx: Ctx): Promise<string> {
const conversationKey = this.conversationKey(ctx);
const verification = conversationKey ? await this.verifications.doc(conversationKey).snapshot() : undefined;
if (!conversationKey || !verification) return 'No code was sent in this conversation yet.';
if (verification.attempts >= MAX_CODE_ATTEMPTS) return 'Too many wrong attempts. Send a new code.';
const matches = verification.code === code.replace(/\D/g, '');
await this.verifications.doc(conversationKey).update({ attempts: verification.attempts + 1, ...(matches && { verifiedAt: new Date().toISOString() }) });
if (!matches) return 'That code does not match. Ask the caller to read it again.';
const patient = await this.patients.doc(verification.patientId).snapshot();
return `Verified. The caller is ${patient?.firstName ?? 'the patient'} ${patient?.lastName ?? ''}.`;
}
@aiFunction('Registers the caller as a new patient, using the number they are calling from', [
{ name: 'firstName', type: 'string', description: 'First name', required: true },
{ name: 'lastName', type: 'string', description: 'Last name', required: true },
{ name: 'dateOfBirth', type: 'string', description: 'Date of birth as YYYY-MM-DD', required: true },
{ name: 'email', type: 'string', description: 'Email address, when the caller gave one', required: false },
])
async registerPatient({ firstName, lastName, dateOfBirth, email }: { firstName: string; lastName: string; dateOfBirth: string; email?: string }, ctx: Ctx): Promise<string> {
const conversationKey = this.conversationKey(ctx);
const phone = await this.callerNumber(ctx);
if (!conversationKey || !phone) return 'Registration over the phone needs the caller to call from their own number.';
if (await this.patientByPhone(phone)) return 'A patient already has this number. Verify them with a code instead.';
const patientId = `PAT-${Date.now().toString(36).toUpperCase()}`;
await this.patients.doc(patientId).insert({ patientId, phone, firstName, lastName, dateOfBirth, email });
// A caller who just registered from their own number counts as verified for this call.
await this.verifications.doc(conversationKey).upsert({ conversationKey, patientId, code: '', attempts: 0, verifiedAt: new Date().toISOString() });
return `${firstName} ${lastName} is registered. They can book an appointment now.`;
}
@aiFunction('Lists the free appointment slots of a day', [{ name: 'date', type: 'string', description: 'The day as YYYY-MM-DD', required: true }])
async findOpenSlots({ date }: { date: string }): Promise<string> {
const booked = new Set((await this.appointments.query().like('startsAt', `${date}%`, false).snapshot()).map((doc) => doc.data.startsAt));
const free: Array<string> = [];
for (let hour = OPENING_HOUR; hour < CLOSING_HOUR; hour++) {
for (let minute = 0; minute < 60; minute += SLOT_MINUTES) {
const startsAt = `${date}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
if (!booked.has(startsAt)) free.push(startsAt);
}
}
if (free.length === 0) return `Nothing is free on ${date}. Offer another day.`;
return `Free slots on ${date}, as start times: ${free.join(', ')}. Offer two or three of them, not the whole list.`;
}
@aiFunction('Books an appointment for the verified caller and texts them a confirmation', [
{ name: 'startsAt', type: 'string', description: 'The slot to book, as YYYY-MM-DDTHH:MM', required: true },
{ name: 'reason', type: 'string', description: 'What the appointment is for, e.g. hygiene cleaning or consultation', required: true },
])
async bookAppointment({ startsAt, reason }: { startsAt: string; reason: string }, ctx: Ctx): Promise<string> {
const patient = await this.verifiedPatient(ctx);
if (!patient) return 'The caller must be verified with a code before booking.';
const [taken] = await this.appointments.query().eq('startsAt', startsAt).limit(1).snapshot();
if (taken) return 'That slot was just taken. Offer another one.';
const appointmentId = `APT-${Date.now().toString(36).toUpperCase()}`;
await this.appointments.doc(appointmentId).insert({
appointmentId,
patientId: patient.patientId,
startsAt,
reason,
bookedAt: new Date().toISOString(),
});
const twilio = new SquidTwilioClient(this.squid, TWILIO_INTEGRATION_ID);
await twilio.sendSms({
agentId: AGENT_ID,
to: patient.phone,
body: `Bright Smile Dental: your ${reason} is booked for ${startsAt.replace('T', ' at ')}. 12 Main St, 2nd floor. Reply to this text to reschedule.`,
});
return `Booked ${appointmentId} for ${startsAt}. A confirmation text is on its way. Read the date and time back to the caller.`;
}
@aiFunction('Briefs the staff before the caller is transferred to them: records why and texts them a summary', [
{ name: 'reason', type: 'string', description: 'Why the caller needs a person', required: true },
{ name: 'urgency', type: 'string', description: 'How urgent it is', required: true, enum: ['routine', 'soon', 'urgent'] },
{ name: 'summary', type: 'string', description: 'A short factual summary of the conversation so far', required: true },
])
async briefStaff({ reason, urgency, summary }: { reason: string; urgency: string; summary: string }, ctx: Ctx): Promise<string> {
const conversationKey = this.conversationKey(ctx) ?? `web_${Date.now()}`;
const patient = await this.verifiedPatient(ctx);
await this.briefs.doc(conversationKey).upsert({
conversationKey,
patientId: patient?.patientId,
reason,
urgency,
summary,
createdAt: new Date().toISOString(),
});
const who = patient ? `${patient.firstName} ${patient.lastName} (${patient.phone})` : 'an unverified caller';
const twilio = new SquidTwilioClient(this.squid, TWILIO_INTEGRATION_ID);
await twilio.sendSms({
agentId: AGENT_ID,
to: STAFF_PHONE,
body: `Incoming transfer (${urgency}): ${who}. ${reason}. ${summary}`.slice(0, 320),
});
return `The staff has been briefed. Tell the caller you are connecting them, then transfer the call to ${STAFF_PHONE}.`;
}
// Internals
/** The Twilio call SID on the phone, or the key the browser test page passes; verifications are kept per conversation. */
private conversationKey(ctx: Ctx): string | undefined {
return ctx.agentContext?.twilioCallSid ?? ctx.agentContext?.webCallerKey;
}
/** The number the caller is calling from: the origin of an inbound call, the destination of an outbound one. */
private async callerNumber(ctx: Ctx): Promise<string | undefined> {
const callSid = ctx.agentContext?.twilioCallSid;
if (!callSid) return undefined;
const call = await this.calls.doc(callSid).snapshot();
if (!call) return undefined;
return call.direction === 'inbound' ? call.from : call.to;
}
private async patientByPhone(phone: string): Promise<Patient | undefined> {
const [doc] = await this.patients.query().eq('phone', phone).limit(1).snapshot();
return doc?.data;
}
private async verifiedPatient(ctx: Ctx): Promise<Patient | undefined> {
const conversationKey = this.conversationKey(ctx);
if (!conversationKey) return undefined;
const verification = await this.verifications.doc(conversationKey).snapshot();
if (!verification?.verifiedAt) return undefined;
return this.patients.doc(verification.patientId).snapshot();
}
}
確認しておくべき点:
- 各 function は、agent が次に何をすべきかを伝える文で応答します。生データよりも会話を適切に導けます。
- 発信者の番号は、
ctx.agentContext.twilioCallSidを通じて見つかる、Twilio connector が保持する通話レコード(twilio_calls)から取得されます。この番号はヒントであり、証明ではありません。個人情報の共有と予約は、SMS コードによる本人確認後にのみ行います。 - 本人確認は会話ごとにキー付けされるため、コードは送信された通話でのみ有効です。
- 転送そのものはコードではありません。agent の Twilio ability には
transferTwilioCallfunction が含まれており、briefStaffは agent にそれを使用するよう指示します。
試すために数人の患者を追加します。Console の Database タブ、または一度限りの executable から、patients に { patientId: 'PAT-1', phone: '+15551234567', firstName: 'Dana', lastName: 'Levi', dateOfBirth: '1988-04-02' } のようなドキュメントを挿入します。電話をかけられる番号を使用してください。
backend を起動します。
squid start
agent を作成する
-
Squid Console の Studio タブを開き、Create AI Agent をクリックします。id には
front-desk、説明には「The front desk of Bright Smile Dental: answers the phone, identifies patients and books appointments」を使用します。 -
次の Instructions を貼り付けます。
You are the front desk of Bright Smile Dental. You help patients over the phone.
Flow of a call:
1. Look the caller up. Greet a known patient by first name; offer a new caller to register.
2. Before sharing any personal detail or booking anything, verify the caller: send a code to the number on file and confirm the digits they read back. A newly registered caller is already verified.
3. Answer questions about hours, locations, providers and services from the clinic website; say when you do not know.
4. To book, ask what the visit is for and which day suits them, list two or three free slots, book the one they choose and confirm the date and time out loud. The confirmation text is sent for you.
5. When the caller is in pain, asks for a dentist, or wants a person: brief the staff, tell the caller you are connecting them, and transfer the call to the staff number the briefing gives you.
6. When the caller is done, say goodbye and end the call.
Never read out dates of birth, addresses or appointment details of anyone who is not verified.
-
Add Abilities をクリックします。
- AI Functions で、
lookupCaller、sendVerificationCode、confirmVerificationCode、registerPatient、findOpenSlots、bookAppointment、briefStaffを追加します。 - SaaS で、説明を「Use this to answer questions about the clinic: hours, locations, providers, services and policies」として、
clinic_siteconnector を追加します。 - Communication で、
twilioconnector を追加します。Phone number を選択し、Voice engine を OpenAI voice に設定して音声を選び、Language を設定します。たとえば「Thanks for calling Bright Smile Dental. How can I help you today?」という Greeting を記入し、既知の患者を名前で呼びかけられるよう、Opening line を「Composed by the agent」に設定します。
- AI Functions で、
-
Twilio ability を保存します。ステータス行には、この agent に番号が着信することが表示され、webhook URL が示されます。これをコピーします。
OpenAI webhook を登録する
OpenAI は project の webhook に着信を通知します。この webhook はアプリケーションを指す必要があります。
- OpenAI platform で project の Settings > Webhooks を開き、コピーした URL を使用して endpoint を作成します。
realtime.call.incomingevent を購読します。 - endpoint の signing secret を Squid Console の
openai_voiceconnector の Webhook Secret にコピーし、保存します。
ブラウザからテストする
電話をかける前に、ページから agent と会話します。同じ agent、ツール、音声を使用でき、通話時間は不要です。ブラウザ通話には caller ID がないため、agent は登録済みの番号を尋ね、その番号にコードをテキスト送信します。
認証済みの Squid client である squid を使用する frontend に、次を追加します。
// A key for this conversation, so the verification code is scoped to it.
const webCallerKey = crypto.randomUUID();
const call = await squid
.ai()
.voice()
.startWebCall({
agentId: 'front-desk',
chatOptions: { agentContext: { webCallerKey } },
});
call.onTurn((turn) => console.log(`${turn.role}: ${turn.text}`));
call.onEnd(() => console.log('The call ended'));
「Hi, I would like to book a cleaning next Tuesday」と話しかけます。agent は番号を尋ね、コードをテキスト送信して確認し、空き枠を提示して予約します。電話には確認テキストが届きます。
番号に電話をかける
登録済みの番号を持つ電話から agent の番号に電話をかけます。agent は名前で挨拶し、本人確認を行い、先ほどと同様に予約します。歯科医を希望すると、agent はスタッフにテキストで要約を送り、通話を転送します。
Console の Database タブでは、twilio_calls collection に通話が、twilio_call_transcripts に発話ごとの内容が保存されます。appointments、verifications、transfer_briefs には functions が書き込んだ内容が保存されます。
次のステップ
- 要約とフォローアップ。 voice session events を購読して、スタッフ向けに各通話の要約を書き込みます。
- 実際のカレンダー。
findOpenSlotsとappointmentscollection を、Google Calendar connector、または HTTP API connector 経由の診療管理システム API に置き換えます。 - メール確認。 Mail connector を追加し、テキストだけでなくメールでも確認を送信します。
- conference へのウォーム転送。 スタッフが画面上の要約を見ながら参加する転送では、
transferTwilioCallではなく human handoff operation を使用して通話を引き継ぎます。 - 別の音声。 ElevenLabs connector を追加し、回線の engine を切り替えると ElevenLabs voice で agent の音声を聞けます。