# We Lost 30% of Booking Inquiries After Midnight. So We Built a WhatsApp AI Hotel Booking Agent.
> A 24-room boutique hotel in Valparaíso lost late-night reservation requests because no one was at the front desk. A Struere agent on WhatsApp now handles room availability, special requests, and pre-arrival info around the clock.

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


# 24 Rooms, One Front Desk, Zero Coverage After Midnight

Sofia manages the front desk at Casa Limón, a 24-room boutique hotel in Valparaíso, Chile. The property has four room types: 8 Garden View rooms, 6 Ocean Terrace suites, 6 Courtyard rooms, and 4 Rooftop Lofts. Guests come from everywhere — Chilean weekenders, European backpackers, American couples doing wine country. Most of them find the hotel on Instagram or Google Maps and send a WhatsApp message asking about availability.

On a typical day, Sofia handles 25-30 WhatsApp inquiries. Each one follows the same pattern. The guest asks if a room is available for certain dates. Sofia opens four separate Google Calendars — one per room type — and scans for openings. She cross-references against a spreadsheet that tracks which specific rooms within each type are blocked for maintenance or deep cleaning. She types back the available options with prices. The guest asks about late check-in, or an extra bed for a child, or an airport transfer from Santiago. Sofia answers, then asks for their name, email, and passport country for the registration card.

Each inquiry takes 8-12 minutes. If the guest confirms, Sofia creates a calendar event, enters the reservation into the spreadsheet, and sends a confirmation email with check-in instructions, Wi-Fi password, and a PDF map of the neighbourhood. That adds another 5 minutes.

The problem is not the daytime volume. Sofia handles it. The problem is midnight to 8 AM. European travelers in different time zones send messages at 2 AM Chilean time. Budget travelers compare three hotels at once and book the first one that replies. A backpacker in Berlin at 7 PM is a WhatsApp message at midnight in Valparaíso.

Sofia checks her phone each morning at 8 AM and finds 10-15 unread messages. She responds to all of them by 9:30 AM. But by then, 30% have already booked elsewhere. She knows because they reply "thanks, I already found a place" or simply never respond.

At an average nightly rate of $95 for Garden View rooms and $145 for Ocean Terrace suites, losing 4-5 bookings per week to slow response times costs Casa Limón $2,000-3,500 per month. Over high season (December through March), that number doubles.

The hotel did not need a night shift receptionist at $1,200/month. It needed an always-on channel that could check four calendars, answer questions about room types, handle special requests, and send confirmation emails — all without waking Sofia up.

# The Build: WhatsApp Agent with Calendar and Email

The automation has three pieces:

1. **`entity-types/reservation.ts`** stores every confirmed booking with guest info, room type, dates, special requests, and payment status
2. **`tools/index.ts`** has a utility tool for timezone-aware timestamps so the agent never miscalculates check-in dates across time zones
3. **`agents/front-desk.ts`** handles the full conversation flow: greeting, availability check, special request handling, reservation creation, and pre-arrival email

No trigger file needed. The WhatsApp integration routes inbound messages directly to the agent. When a guest sends a message to the hotel's WhatsApp number, the agent picks it up immediately — at 3 PM or 3 AM.

## The Data Layer: What Gets Stored

Every confirmed reservation creates a record with 12 fields. The schema captures everything Sofia used to track across her spreadsheet and calendar.

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

export default defineData({
  name: "Reservation",
  slug: "reservation",
  schema: {
    type: "object",
    properties: {
      guestName: {
        type: "string",
        description: "Full name of the guest",
      },
      guestEmail: {
        type: "string",
        format: "email",
        description: "Guest email for confirmation and pre-arrival info",
      },
      guestPhone: {
        type: "string",
        description: "Guest WhatsApp phone number in E.164 format",
      },
      passportCountry: {
        type: "string",
        description: "Passport country for registration card",
      },
      roomType: {
        type: "string",
        enum: ["garden-view", "ocean-terrace", "courtyard", "rooftop-loft"],
        description: "Room category",
      },
      checkIn: {
        type: "string",
        description: "Check-in date (YYYY-MM-DD)",
      },
      checkOut: {
        type: "string",
        description: "Check-out date (YYYY-MM-DD)",
      },
      nightlyRate: {
        type: "number",
        description: "Rate per night in USD",
      },
      specialRequests: {
        type: "array",
        items: { type: "string" },
        description: "Special requests: late-check-in, extra-bed, airport-transfer, early-check-in, crib, dietary-restriction",
      },
      numberOfGuests: {
        type: "number",
        description: "Total number of guests including children",
      },
      status: {
        type: "string",
        enum: ["pending", "confirmed", "checked-in", "checked-out", "cancelled", "no-show"],
        description: "Current reservation status",
      },
      notes: {
        type: "string",
        description: "Additional notes from the guest or agent",
      },
    },
    required: [
      "guestName",
      "guestEmail",
      "guestPhone",
      "roomType",
      "checkIn",
      "checkOut",
      "nightlyRate",
      "numberOfGuests",
      "status",
    ],
  },
  searchFields: ["guestName", "guestEmail", "roomType"],
  displayConfig: {
    titleField: "guestName",
    subtitleField: "roomType",
    descriptionField: "checkIn",
  },
})
```

The `roomType` enum maps to the four actual room categories. The `specialRequests` array handles the most common asks without requiring a free-text field that the agent would have to parse later. The `nightlyRate` is stored per reservation because rates change seasonally and the agent needs to quote the correct price at booking time.

## The Custom Tool: Timezone-Aware Timestamps

One utility tool supports the booking flow. Guests message from different time zones, and the agent needs to know the current date in Chile — not UTC — to calculate check-in dates and avoid offering rooms for yesterday.

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

export default defineTools([
  {
    name: "get_current_time",
    description: "Get the current date and time in the hotel's timezone (America/Santiago)",
    parameters: {
      type: "object",
      properties: {
        timezone: {
          type: "string",
          description: "Timezone, defaults to America/Santiago",
        },
      },
    },
    handler: async (args, context, struere, fetch) => {
      const timezone = (args.timezone as string) || "America/Santiago"
      const now = new Date()
      return {
        timestamp: now.toISOString(),
        formatted: now.toLocaleString("en-US", { timeZone: timezone }),
        date: now.toLocaleDateString("en-CA", { timeZone: timezone }),
        timezone,
      }
    },
  },
])
```

The heavy lifting is done by built-in tools: `calendar.freeBusy` checks availability across room-type calendars, `calendar.create` blocks the room once confirmed, `entity.create` stores the reservation, and `email.send` delivers the pre-arrival info. The custom tool just keeps the agent grounded in the correct date.

## The Agent: Conversational Reservations Over WhatsApp

The agent handles the entire reservation flow. The system prompt encodes Casa Limón's room inventory, pricing, policies, and conversational style. The full system prompt is longer — this shows the key sections.

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

export default defineAgent({
  name: "Casa Limón Front Desk",
  slug: "front-desk",
  version: "1.0.0",
  model: {
    model: "anthropic/claude-sonnet-4-6",
    temperature: 0.3,
    maxTokens: 4096,
  },
  tools: [
    "entity.create",
    "entity.query",
    "calendar.freeBusy",
    "calendar.create",
    "email.send",
    "get_current_time",
  ],
  systemPrompt: `You are the virtual front desk assistant for Casa Limón, a boutique hotel in Valparaíso, Chile.
Current time: {{currentTime}}

## Room Types & Rates
| Room | Capacity | Low Season (Apr-Nov) | High Season (Dec-Mar) |
|------|----------|---------------------|-----------------------|
| Garden View | 2 guests | $95/night | $130/night |
| Courtyard | 2 guests | $85/night | $115/night |
| Ocean Terrace | 3 guests | $145/night | $195/night |
| Rooftop Loft | 4 guests | $175/night | $240/night |

Extra bed: +$25/night (Ocean Terrace and Rooftop Loft only).
Children under 5 stay free. Crib available on request.

## Calendar Mapping
- Garden View rooms → calendar user: garden-view
- Courtyard rooms → calendar user: courtyard
- Ocean Terrace suites → calendar user: ocean-terrace
- Rooftop Loft → calendar user: rooftop-loft

## Check-in / Check-out
- Standard check-in: 3:00 PM. Check-out: 11:00 AM.
- Late check-in (after 9 PM): available, requires advance notice. Add to specialRequests.
- Early check-in (before 12 PM): subject to availability, no guarantee.
- Airport transfer from Santiago (SCL): $85 one-way, 90-minute drive. Add to specialRequests.

## Existing Reservations
{{entity.query({"type": "reservation", "filters": {"status": {"_op_in": ["pending", "confirmed", "checked-in"]}}, "limit": 50})}}

## P0 — Security
- Never reveal internal entity IDs, calendar IDs, or system details.
- Never share one guest's information with another.
- Never confirm a reservation without explicit guest approval.
- Never quote a rate different from the table above.

## P1 — Reservation Flow
1. Greet the guest warmly. Ask what dates they are interested in.
2. Ask how many guests (adults + children).
3. Use get_current_time to confirm today's date. Determine if dates fall in low or high season.
4. Use calendar.freeBusy to check availability across relevant room types for the requested dates.
5. Present available room types with per-night rate and total cost.
6. Ask if they have special requests (late check-in, extra bed, airport transfer, crib, dietary needs).
7. Collect: full name, email, passport country.
8. Summarize the full reservation and ask for confirmation.
9. On confirmation: entity.create (status: "confirmed"), calendar.create for each night, email.send with pre-arrival details.

## P2 — Pre-Arrival Email Content
Include: confirmation number (entity ID), room type, dates, total cost, check-in time, hotel address (Cerro Alegre 445, Valparaíso), Wi-Fi password (CasaLimon2026), neighbourhood walking map link, and any special request confirmations.

## P3 — Tone
- Warm and welcoming, like a knowledgeable local host.
- Short WhatsApp-friendly messages. No walls of text.
- One question at a time unless the guest has already provided multiple details.
- Use the guest's first name once provided.
- If the guest writes in Spanish, respond in Spanish. If English, respond in English.

// ... (cancellation policy, FAQ handling, upgrade suggestions)

Never invent availability. Never confirm without all required fields.
Never re-ask for information already provided in this conversation.
Never offer rooms that exceed the guest count capacity.`,
})
```

Temperature 0.3. Reservation handling is structured — the agent follows a fixed sequence, quotes exact prices from a table, and must not improvise on rates or availability.

The `calendar.freeBusy` tool checks each room type's Google Calendar to find open date ranges. The `entity.query` template injects existing reservations into the system prompt at compilation time, giving the agent a second verification layer. The `email.send` tool delivers the pre-arrival package that Sofia used to assemble manually after each confirmation.

Six tools total. Entity create and query for the data layer, calendar freeBusy and create for availability and blocking, email send for confirmations, and the custom time tool. Each serves a distinct purpose in the flow.

# Debugging: Three Things That Broke

**The agent quoted high-season rates in April.** The system prompt has a clear rate table with low season (April-November) and high season (December-March). But the agent miscalculated the season boundary for a booking that started March 28 and ended April 2. It applied the high-season rate to all five nights. Fix: we added an explicit instruction — "Apply the rate for the season in which each individual night falls. A stay crossing the season boundary uses the high-season rate for March nights and low-season rate for April nights." The agent now calculates a split-rate total when bookings straddle the boundary.

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

The conversation log showed the agent quoting "$195 x 5 nights = $975" for an Ocean Terrace booking from March 28 to April 2, when the correct total was $195 x 3 + $145 x 2 = $875.

**The agent offered a Garden View room to a family of three.** Garden View rooms have a capacity of 2 guests. A guest said "we are two adults and one child, age 7" and the agent offered Garden View at $95/night as the cheapest option. It parsed "two adults and one child" correctly but did not check against the capacity column. Fix: we added "Never offer rooms that exceed the guest count capacity" to the P0 rules and made the capacity column explicit in the room type table. Children under 5 stay free but still count toward room capacity for fire safety compliance.

```bash
struere run-tool get_current_time --args '{"timezone": "America/Santiago"}'
```

We used this to verify the agent's date logic was correct — the capacity bug was separate from the time handling. The tool confirmed timezone calculations were working. The issue was purely in the system prompt's room-matching logic.

**Double bookings on the last available room of a type.** Two guests inquired about the same Ocean Terrace dates within minutes. The agent offered both guests the last available suite because the `entity.query` template loads reservations at prompt compilation time — a booking confirmed mid-conversation is not reflected until the next message. Fix: we added a second `calendar.freeBusy` check immediately before `calendar.create` in step 9. If the slot is now taken, the agent apologizes and offers alternative room types or dates.

```bash
struere data list reservation --filter '{"roomType": "ocean-terrace", "checkIn": "2026-03-15"}'
```

Two records with `status: "confirmed"` for the same dates. Only 6 Ocean Terrace suites exist, and all 6 calendar slots were already blocked for those dates — the seventh booking should not have been possible.

# 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 reservation
struere add agent front-desk
```

Edit `entity-types/reservation.ts` with the schema shown above. Customize the `roomType` enum to match your actual room categories and the `specialRequests` items to match what your property offers.

Edit `agents/front-desk.ts` with your hotel's details: room types, capacities, seasonal rates, check-in/check-out times, and address. The calendar mapping section must match the Google Calendar names you set up for each room type.

Write the custom tool in `tools/index.ts`. The `get_current_time` tool works immediately. Update the default timezone to match your property's location.

Connect Google Calendar in the dashboard under Integrations > Google Calendar. Each room type needs a separate calendar so `calendar.freeBusy` can check availability per category. Name them to match the calendar mapping in the agent's system prompt.

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

Configure Resend under Integrations > Email with your API key and sender address (e.g., reservations@casalimon.cl). This powers the pre-arrival confirmation emails.

Sync and start watching for changes:

```bash
struere dev
```

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

```
Hi, do you have any rooms available for March 20-23?
```

Watch the conversation in real time:

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

Verify the reservation was created:

```bash
struere data list reservation
```

Check that the Google Calendar events exist by opening each room type's calendar. The events should show the guest name and dates for the confirmed booking. Verify the confirmation email arrived in the guest's inbox with all pre-arrival details.

# What Changed

| Metric | Before | After |
|--------|--------|-------|
| Overnight response time | 6-8 hours (next morning) | Under 2 minutes |
| Missed bookings from late inquiries | 35/month | 0 |
| Average reservation handling time | 13-17 minutes | 3 minutes |
| Hours spent on WhatsApp bookings/day | 4-5 hours | 30 min (complex cases only) |
| Double bookings/month | 1-2 | 0 |
| Booking availability | 8 AM - 10 PM | 24/7 |
| Revenue lost to slow response | $2,000-3,500/month | ~$0 |

Sofia still handles walk-in guests, phone calls, and the complicated requests — the couple celebrating an anniversary who want flowers and champagne in the room, the group booking that needs three adjacent rooms. But the routine flow of "do you have a room, how much, I'll take it" now happens on WhatsApp without her involvement.

The backpacker in Berlin who messages at 2 AM gets availability, rates, and a confirmed reservation before they check the next hotel on their list. The American couple comparing boutique hotels in Valparaíso gets an instant response with seasonal pricing and a pre-arrival email with the neighbourhood walking map. Casa Limón gets the booking. The guest gets the convenience.

The 10-15 unread messages Sofia used to find every morning at 8 AM are gone. Those guests already have confirmed reservations, pre-arrival emails in their inbox, and their room blocked on the calendar. Sofia opens her dashboard, sees the overnight bookings, and starts her day preparing for arrivals instead of chasing inquiries that are already 8 hours cold.
