Build a phone assistant that books appointments
Give a clinic's front desk an AI agent that answers the phone, recognizes returning patients, books appointments and confirms by text
What you'll build
- An agent with its own phone number that answers calls in a natural voice and can be called from a web page as well.
- The agent recognizes returning patients by their caller ID, verifies them with a code sent by SMS, registers first-time callers, and answers questions about the clinic from its website.
- It finds free slots, books the appointment, texts a confirmation, and hands urgent cases to the staff with a briefing.
This tutorial uses the following features:
| AI Abilities | Connectors | Backend Functions |
|---|---|---|
| AI Functions, Twilio ability | Twilio, OpenAI Voice, Generic Site Ingester, Built-in Database | Voice agents, Security rules |
What you'll learn
- How to give an agent a phone number and a voice, and how to test it from the browser before anyone calls.
- How AI functions use the call context to identify and verify the caller.
- How the agent books, confirms by text and transfers to a person.
What you'll need
- The Squid CLI
- An account in the Squid Console
- A Twilio account with a phone number that has voice and SMS capabilities. A trial account works, as long as the numbers you call and text are verified in it.
- An OpenAI project with API access
- Some experience with TypeScript
Create the application and its connectors
-
Navigate to the Squid Console and create a new application named Clinic Front Desk.
-
Open the Connectors tab, switch to Available Connectors and add a Twilio connector with the id
twilio. Enter your Twilio Account SID and Auth Token (Twilio Console, Account Info). -
Add an OpenAI Voice connector with the id
openai_voice. Enter an API Key of your OpenAI project and the Project ID (proj_…, shown in the project's settings). Leave the Webhook Secret empty for now; you will fill it in once the webhook exists. -
Add a Generic Site Ingester connector with the id
clinic_siteand the URL of your clinic website'ssitemap.xml, so the agent knows the opening hours, locations, providers and FAQs published there. If you have no site to point it at, a knowledge base with a few uploaded documents does the same job.
Initialize the backend
-
On the application overview page, scroll to the Backend project section, click Initialize Backend and run the command it shows in an empty folder.
-
In that folder, install the Twilio client package, which the backend uses to text patients:
npm install @squidcloud/twilio-client
Write the front desk's tools
The agent gets seven AI functions: look the caller up, send a verification code, confirm it, register a new patient, find open slots, book an appointment, and brief the staff before a transfer. Patients and appointments live in the built-in database; the phone number the agent is talking to comes from the call record the Twilio connector keeps.
Create 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();
}
}
A few things to notice:
- Every function answers with a sentence that tells the agent what to do next. That steers the conversation better than raw data.
- The caller's number comes from the call record (
twilio_calls) that the Twilio connector keeps, found throughctx.agentContext.twilioCallSid. The number is a hint, never proof: personal details and bookings wait for the SMS code. - Verifications are keyed by the conversation, so a code only works on the call it was sent for.
- The transfer itself is not code: the agent's Twilio ability includes a
transferTwilioCallfunction, andbriefStafftells the agent to use it.
Add a couple of patients to try it with. In the console's Database tab, or from a one-off executable, insert into patients documents such as { patientId: 'PAT-1', phone: '+15551234567', firstName: 'Dana', lastName: 'Levi', dateOfBirth: '1988-04-02' }, with a phone number you can call from.
Start the backend:
squid start
Create the agent
-
Open the Studio tab in the Squid Console and click Create AI Agent. Use the id
front-deskand the description "The front desk of Bright Smile Dental: answers the phone, identifies patients and books appointments". -
Paste these 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.
-
Click Add Abilities:
- Under AI Functions, add
lookupCaller,sendVerificationCode,confirmVerificationCode,registerPatient,findOpenSlots,bookAppointmentandbriefStaff. - Under SaaS, add the
clinic_siteconnector, with the description "Use this to answer questions about the clinic: hours, locations, providers, services and policies". - Under Communication, add the
twilioconnector. Pick your Phone number, set the Voice engine to OpenAI voice, choose a voice, set the Language, write a Greeting such as "Thanks for calling Bright Smile Dental. How can I help you today?", and set Opening line to "Composed by the agent", so a known patient is greeted by name.
- Under AI Functions, add
-
Save the Twilio ability. Its status line reports that the number rings this agent and shows a webhook URL. Copy it.
Register the OpenAI webhook
OpenAI announces incoming phone calls to a webhook of the project, which must point at your application:
- In the OpenAI platform, open the project's Settings > Webhooks and create an endpoint with the URL you copied, subscribed to the
realtime.call.incomingevent. - Copy the endpoint's signing secret into the Webhook Secret of the
openai_voiceconnector in the Squid Console and save it.
Test from the browser
Before dialing, talk to the agent from a page: it exercises the same agent, tools and voice, and needs no phone minutes. A browser call has no caller ID, so the agent asks for the number on file and texts the code there.
Add this to your frontend, where squid is an authenticated Squid client:
// 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'));
Say "Hi, I would like to book a cleaning next Tuesday". The agent asks for your number, texts a code, confirms it, offers slots and books. Your phone receives the confirmation text.
Call the number
Call the agent's number from the phone whose number is on file. The agent greets you by name, verifies you and books like before. Ask for a dentist: it briefs the staff by text and transfers the call.
In the console's Database tab, the twilio_calls collection holds the call and twilio_call_transcripts every spoken turn; appointments, verifications and transfer_briefs hold what the functions wrote.
Next steps
- Summaries and follow-ups. Subscribe to the voice session events to write a summary of every call for the staff.
- A real calendar. Replace
findOpenSlotsand theappointmentscollection with the Google Calendar connector or your practice management system's API through an HTTP API connector. - Email confirmations. Add a Mail connector and send the confirmation by email as well as by text.
- Warm transfer into a conference. For a transfer where the staff joins with the briefing on screen, take the call over with the human handoff operation instead of
transferTwilioCall. - Another voice. Add an ElevenLabs connector and switch the line's engine to hear the agent with an ElevenLabs voice.