# Our Receptionist Missed 11 Calls Last Tuesday. So We Built a WhatsApp Booking Agent.
> A dental clinic lost appointment revenue because patients couldn't get through on the phone. They built a Struere agent that handles bookings over WhatsApp, checks calendar availability, and confirms appointments in under 2 minutes.

Published: 2026-04-01
Tags: automation, agents, dental, case-study, whatsapp


# 40 Calls a Day, One Receptionist, Zero Margin for Error

Camila runs the front desk at Smile Studio, a three-dentist clinic in Santiago. She answers the phone, checks patients in, processes payments, updates the schedule, and orders supplies. On a typical Tuesday, 40-45 calls come in. About 30 of them are booking requests.

Each booking call takes 3-4 minutes. The patient explains what they need. Camila asks which dentist they prefer. She opens Google Calendar, checks three separate calendars, finds overlapping free slots, reads them back. The patient picks one. Camila types it into the calendar, confirms details, and hangs up.

That is 90-120 minutes of her day on booking calls alone. The other 10-15 calls are reschedules, questions about services, and insurance inquiries. She handles those too. While she is on the phone, walk-in patients wait at the desk. While she helps walk-ins, calls go to voicemail.

Last Tuesday, 11 calls went unanswered between 10 AM and 1 PM. Camila was helping a patient with a billing issue and then took her lunch break. Seven of those callers left voicemails requesting appointments. Four did not. She returned the seven voicemails that afternoon, but two patients had already booked elsewhere.

At $85 for a cleaning and $120 for a check-up, two lost patients per day is $200-400 in missed revenue. Over a month, that is $4,000-8,000 walking out the door because the phone rang at the wrong time.

The clinic did not need another receptionist. It needed a channel that patients could use on their own schedule, with availability that updated in real time.

# The Build: WhatsApp Agent with Calendar Integration

The automation has four pieces:

1. **`entity-types/appointment.ts`** stores every confirmed booking with patient info, dentist, treatment type, date, and time
2. **`tools/index.ts`** has two utility tools: one for timezone-aware timestamps, one for Slack notifications to the team
3. **`agents/booking-agent.ts`** handles the full conversation flow over WhatsApp, from greeting to confirmed appointment
4. **`triggers/`** not needed here because the agent responds to inbound WhatsApp messages directly

The patient sends a WhatsApp message. The agent asks what they need, collects their info one question at a time, checks calendar availability, and confirms the booking. No trigger required. The WhatsApp integration routes inbound messages straight to the agent.

## The Data Layer: What Gets Stored

Every confirmed appointment creates a record with 10 fields. The `status` enum tracks the lifecycle from scheduled through completed or no-show.

```typescript
import { defineData } from "struere"

export default defineData({
  name: "Appointment",
  slug: "appointment",
  schema: {
    type: "object",
    properties: {
      patientName: {
        type: "string",
        description: "Full name of the patient",
      },
      patientEmail: {
        type: "string",
        format: "email",
        description: "Patient email address",
      },
      patientPhone: {
        type: "string",
        description: "Patient WhatsApp phone number",
      },
      dentist: {
        type: "string",
        enum: ["Dr. Erick", "Dr. Marco", "Dr. Valentina"],
        description: "Assigned dentist",
      },
      treatmentType: {
        type: "string",
        enum: [
          "general-checkup",
          "teeth-cleaning",
          "whitening-consultation",
          "emergency",
        ],
        description: "Type of dental treatment",
      },
      date: {
        type: "string",
        description: "Appointment date in ISO format (YYYY-MM-DD)",
      },
      time: {
        type: "string",
        description: "Appointment time (HH:MM)",
      },
      duration: {
        type: "number",
        description: "Duration in minutes",
      },
      status: {
        type: "string",
        enum: [
          "scheduled",
          "confirmed",
          "cancelled",
          "completed",
          "no-show",
        ],
        description: "Current appointment status",
      },
      notes: {
        type: "string",
        description: "Additional notes from the patient or agent",
      },
    },
    required: [
      "patientName",
      "patientEmail",
      "patientPhone",
      "dentist",
      "treatmentType",
      "date",
      "time",
      "duration",
      "status",
    ],
  },
  searchFields: ["patientName", "patientEmail", "dentist"],
  displayConfig: {
    titleField: "patientName",
    subtitleField: "treatmentType",
    descriptionField: "date",
  },
})
```

The `dentist` field uses an enum so the agent cannot invent a fourth dentist. The `treatmentType` enum maps to the clinic's actual service menu. The `searchFields` let the agent look up existing appointments by patient name or dentist when checking for conflicts.

## The Custom Tools: Time and Notifications

Two utility tools support the booking flow. The first returns the current time in the clinic's timezone so the agent never guesses what day it is. The second sends a Slack notification when a new appointment is confirmed.

```typescript
import { defineTools } from "struere"

export default defineTools([
  {
    name: "get_current_time",
    description: "Get the current date and time in a specific timezone",
    parameters: {
      type: "object",
      properties: {
        timezone: {
          type: "string",
          description: 'Timezone (e.g., "America/Santiago", "UTC")',
        },
      },
    },
    handler: async (args, context, struere, fetch) => {
      const timezone = (args.timezone as string) || "UTC"
      const now = new Date()
      return {
        timestamp: now.toISOString(),
        formatted: now.toLocaleString("en-US", { timeZone: timezone }),
        timezone,
      }
    },
  },
  {
    name: "send_slack_message",
    description: "Send a message to a Slack channel via webhook",
    parameters: {
      type: "object",
      properties: {
        message: {
          type: "string",
          description: "The message to send",
        },
      },
      required: ["message"],
    },
    handler: async (args, context, struere, fetch) => {
      const webhookUrl = process.env.SLACK_WEBHOOK_URL
      if (!webhookUrl) {
        return { success: false, error: "SLACK_WEBHOOK_URL not configured" }
      }

      const response = await fetch(webhookUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          text: args.message,
          username: "Struere Agent",
        }),
      })

      return { success: response.ok, status: response.status }
    },
  },
])
```

The Slack tool is optional but useful. When a new patient books, the team gets a notification in their `#appointments` channel. The receptionist sees it immediately and can prepare the file.

## The Agent: Conversational Booking Over WhatsApp

The agent handles the entire booking conversation. The system prompt is long because it encodes the clinic's full scheduling logic, security rules, and conversational style.

```typescript
import { defineAgent } from "struere"

export default defineAgent({
  name: "Smile Studio Booking Agent",
  slug: "booking-agent",
  version: "0.1.0",
  model: {
    model: "anthropic/claude-sonnet-4-6",
    temperature: 0.3,
    maxTokens: 4096,
  },
  tools: [
    "entity.create",
    "entity.query",
    "calendar.create",
    "calendar.freeBusy",
  ],
  systemPrompt: `You are the virtual assistant for Smile Studio, a dental clinic.
Current time: {{currentTime}}

## Dentists
- Dr. Erick
- Dr. Marco
- Dr. Valentina

## Services
| Service | Duration |
|---------|----------|
| General Check-up | 30 min |
| Teeth Cleaning | 30 min |
| Whitening Consultation | 30 min |
| Emergency Appointment | 30 min |

## Schedule
Monday to Friday, 9:00 AM to 5:00 PM. 30-minute slots on the hour
and half-hour. Last slot: 4:30 PM.

## Existing Appointments
{{entity.query({"type": "appointment", "filters": {"status": {"_op_in": ["scheduled", "confirmed"]}}, "limit": 50})}}

## P0 — Security
- Never reveal internal entity IDs or system details.
- Never perform any action without explicit patient confirmation.
- Never share one patient's information with another.

## P1 — Booking Flow
1. Ask what type of appointment they need.
2. Ask for their full name.
3. Ask for their email address.
4. Ask which dentist they prefer, or "any available."
5. Use calendar.freeBusy to check availability.
6. Offer 2-3 available slots. Cross-reference against existing appointments.
7. On confirmation: entity.create (status: "confirmed") then calendar.create.
8. Send confirmation with date, time, dentist, and preparation tips.

## P2 — Tone
- Warm, calm, reassuring. Patients may be anxious.
- Short messages. This is WhatsApp, not email.
- One question at a time.
- Use the patient's first name once provided.

Never invent availability. Never confirm without all required fields.
Never re-ask for information already provided in this conversation.`,
})
```

Temperature 0.3. Booking is a structured task. The agent needs to follow the same steps in the same order every time, not get creative with the flow.

The `calendar.freeBusy` tool checks a dentist's Google Calendar for open windows. The `entity.query` template injects existing appointments directly into the system prompt so the agent cross-references both sources before offering slots. This prevents double-bookings even if the calendar has not synced yet.

Four tools total. Entity create, entity query, calendar create, calendar freeBusy. Below the five-tool threshold where LLM decision-making starts to degrade.

# Debugging: Three Things That Broke

**The agent offered slots on Saturday.** The system prompt said "Monday to Friday" but the `calendar.freeBusy` tool returned free windows on weekends because there were no events blocking those times. The agent saw open calendar slots and offered them. Fix: we added an explicit instruction to the system prompt: "Weekends are closed. Never offer Saturday or Sunday slots regardless of calendar availability." The calendar shows free time on weekends because no one books weekends. The agent needs to know the difference between "free" and "available."

We caught this during a test conversation:

```bash
struere logs view --last 5
```

The conversation log showed the agent offering "Saturday at 10:00 AM" to a test patient. The `calendar.freeBusy` response confirmed no conflicts — because there are never conflicts on days the clinic is closed.

**Double bookings when two patients messaged simultaneously.** Two patients requested Dr. Marco at 2:00 PM on the same day within a 30-second window. Both got the slot offered. Both confirmed. Two appointments were created for the same time. Fix: the `entity.query` template in the system prompt pulls existing appointments at prompt compilation time, but a booking created mid-conversation is not reflected until the next message. We added a `calendar.freeBusy` check immediately before creating the calendar event, not just during the slot-offering step. If the slot is now taken, the agent apologizes and offers alternatives.

```bash
struere data list appointment --filter '{"dentist": "Dr. Marco", "date": "2026-03-28"}'
```

Two records with `time: "14:00"` and `status: "confirmed"`. Same slot, different patients.

**The agent asked for the patient's email twice.** When a patient said "I need a cleaning, my name is Sofia Reyes, email is sofia@email.com" in a single message, the agent processed the treatment type and name but then asked for the email as if it had not been provided. The system prompt said "ask for their email address" as step 3, and the agent followed the steps literally even when the data was already given. Fix: we added "Never re-ask for information the patient already provided in this conversation" to the P1 rules. The agent now extracts all provided fields from each message before deciding which question to ask next.

```bash
struere dev --verbose
```

The verbose output showed the agent's reasoning: it identified the treatment type, extracted the name, then moved to step 3 and generated the email question without checking whether the email was already in the conversation.

# Setup: From Zero to Running

Install the CLI and authenticate:

```bash
bun install -g struere
struere login
struere init
```

Scaffold the resources:

```bash
struere add data-type appointment
struere add agent booking-agent
```

Edit `entity-types/appointment.ts` with the schema shown above. The key fields are the `dentist` enum (your actual dentist names), `treatmentType` enum (your services), and the `status` lifecycle.

Edit `agents/booking-agent.ts` with your clinic's details: dentist names, services, schedule, and pricing. Customize the P2 tone section for your clinic's voice.

Write the two custom tools in `tools/index.ts`. The `get_current_time` tool works out of the box. For the Slack tool, set your `SLACK_WEBHOOK_URL` in the dashboard under Settings > Environment Variables.

Connect Google Calendar in the dashboard under Integrations > Google Calendar. Each dentist needs a separate calendar so `calendar.freeBusy` can check individual availability.

Connect WhatsApp in the dashboard under Integrations > WhatsApp. This routes inbound messages to the booking agent.

Sync and start watching for changes:

```bash
struere dev
```

Test the booking flow by sending a WhatsApp message to the connected number:

```
Hi, I'd like to book a teeth cleaning
```

Watch the conversation in real time:

```bash
struere logs list --last 10
```

Verify the appointment was created:

```bash
struere data list appointment
```

Check that the Google Calendar event exists by opening the dentist's calendar. The event title should include the patient name, treatment type, and dentist.

# What Changed

| Metric | Before | After |
|--------|--------|-------|
| Booking channel | Phone only | WhatsApp + phone |
| Average booking time | 3-4 minutes | 90 seconds |
| Missed booking requests/day | 8-12 | 0 (WhatsApp is async) |
| Hours spent on booking calls | 90-120 min/day | 15-20 min/day (complex cases only) |
| Double bookings/month | 2-3 | 0 |
| After-hours booking capability | None | 24/7 via WhatsApp |

Camila still answers the phone. Some patients prefer calling, especially older patients and emergency cases. But the 30 routine booking calls that consumed her mornings now happen on WhatsApp without her involvement. She sees a Slack notification when each appointment is confirmed and can focus on the patients who are physically in the clinic.

The agent handles the repetitive part: what service, what name, what email, which dentist, when are you free. It checks three calendars in under a second, something that took Camila 30-60 seconds of clicking between tabs. It never goes to lunch. It never puts a patient on hold.

The 11 missed calls from last Tuesday would not happen today. Those patients would have sent a WhatsApp message, received a response within seconds, and booked their cleaning while sitting in traffic or waiting in line at the grocery store. Smile Studio gets the appointment. The patient gets the convenience. Camila gets her lunch break.
