Dashboard

Stop an AI Receptionist Double Booking Slots

An AI receptionist double booking appointments is almost never a prompt problem. It is a missing hold between offering a slot and confirming it.

Steve Jefferson
Steve Jefferson
Developer Advocate
1 October 20261 min read

An AI receptionist double booking appointments is almost never a problem with the AI. It is a timing gap between the moment it offers a slot and the moment it writes that slot down. Two callers can both be standing in that gap, and both will be told yes.

Which is why rewriting the prompt does not fix it. The instruction to check availability first is already being followed. It is being followed twice, correctly, against the same free slot.

The sequence that makes an AI receptionist double book appointments

Thursday, 10:00 is free. Two calls arrive seconds apart.

Time

Call A

Call B

Calendar

09:14:02

Agent reads calendar, sees 10:00 free

10:00 free

09:14:05

Agent offers Thursday 10:00

Agent reads calendar, sees 10:00 free

10:00 free

09:14:09

Caller is spelling their surname

Agent offers Thursday 10:00

10:00 free

09:14:21

Agent writes the booking

Caller confirms

10:00 booked, A

09:14:24

Agent writes the booking

10:00 booked twice

Nineteen seconds separate the read from the write on call A, because a human was talking in between. That window is the bug. Voice agents have unusually long windows because conversations are slow, which is why this shows up far more on phone bookings than on web forms.

The fix: hold the slot when you offer it

The rule is one line. Never offer a slot you have not reserved. Write a provisional hold at the moment of offering, convert it to a confirmed booking when the caller agrees, and expire it if they do not.

That means three states, not two:

  • held: offered to a caller, not yet confirmed, expires in a few minutes

  • confirmed: the caller said yes

  • cancelled or expired: released back to availability

And availability must treat held and confirmed identically. A slot with a live hold is not free, even though nobody has agreed to it yet.

Making the write safe at the database level

A hold only works if two processes cannot both create one. Enforce that in the database rather than in the agent's logic, because the database is the only thing that sees both calls:

sql
-- one row per slot, so a second insert for the same slot fails
create table appointments (
  id          uuid primary key default gen_random_uuid(),
  staff_id    uuid not null,
  slot_start  timestamptz not null,
  state       text not null default 'held'
                check (state in ('held','confirmed','cancelled')),
  held_until  timestamptz,
  customer    text,
  created_at  timestamptz not null default now()
);

-- the line that actually prevents the double booking
create unique index appointments_slot_live
  on appointments (staff_id, slot_start)
  where state in ('held','confirmed');

A partial unique index is the whole mechanism. Two concurrent inserts for the same staff member and start time cannot both succeed: one returns a unique violation, and that is your signal to offer a different slot rather than to retry.

Claiming a slot then becomes a single statement:

sql
-- returns a row if the hold was won, nothing if someone else got there first
insert into appointments (staff_id, slot_start, state, held_until, customer)
values ($1, $2, 'held', now() + interval '4 minutes', $3)
on conflict do nothing
returning id;

If that returns no row, the agent says the slot has just gone and offers the next one. Callers accept this without complaint, because it is how human receptionists sound anyway.

Expiring holds

Holds must release themselves, or an abandoned call blocks a slot forever. Two options, and the second is better:

Approach

How

Trade-off

Scheduled sweep

A job every minute sets expired holds to cancelled

Simple, but a slot can look busy for up to a minute after it is free

Expiry in the query

Availability ignores holds whose held_until has passed

Always correct, no job to monitor, slightly more complex read

The second looks like this, and it means a stale hold never blocks anything:

sql
select s.slot_start
from   generate_series($1::timestamptz, $2::timestamptz, interval '30 min') s(slot_start)
where  not exists (
  select 1 from appointments a
  where  a.staff_id   = $3
    and  a.slot_start = s.slot_start
    and (a.state = 'confirmed'
         or (a.state = 'held' and a.held_until > now()))
);

Keep the sweep as well, so the table does not accumulate dead holds, but do not depend on it for correctness.

Set the hold window from your actual call length

Too short and callers lose the slot while they are still talking. Too long and a hung-up call blocks the diary. Measure rather than guess: take your last fifty bookings, find the time from first slot offer to confirmation, and set the hold to the p95 plus a minute.

For most phone bookings that lands between three and five minutes. Web bookings are much faster and tolerate ninety seconds. If a caller needs longer, have the agent renew the hold rather than extending every hold for everyone.

The failure modes a hold does not cover

  • Two calendars. If the agent writes to your system and staff write to a separate Google Calendar, the unique index cannot see both. One calendar is the source of truth, and everything else syncs from it.

  • Overlapping durations. A 30-minute slot index does not stop a 60-minute appointment at 10:00 colliding with a 30-minute one at 10:30. If you book variable lengths, you need a range type and an exclusion constraint rather than an index on start time alone.

  • Timezones. A slot stored in local time breaks twice a year. Store timestamptz, convert at the edges.

  • Staff who are double-bookable on purpose. Some businesses genuinely overlap appointments. If yours does, the constraint belongs on capacity per slot, not on existence of a row.

That third point is worth the five minutes it takes to check, because the symptom looks identical to a race condition and the cause is not. For the wider picture of what a phone agent should and should not be trusted with, see whether it is safe to let AI answer your business phone, and AI receptionists for small business for the setup.

Tell the caller the slot is held

A hold changes what the agent should say, and getting the wording right removes most of the friction. Three phrasings that work, in the order a call goes:

  • On offering: I can hold Thursday at 10 for you while we finish up. This sets the expectation that the slot is reserved and not yet booked.

  • On losing a race: that one has just been taken, the next is Thursday at 11. Say it as fact, not as an apology, and have the alternative ready in the same breath.

  • On a hold about to lapse: I will need to release Thursday at 10 shortly, shall I confirm it? This converts a timeout into a close.

Avoid saying the slot is booked before it is confirmed, because a caller who then hangs up believes they have an appointment they do not have. That failure is worse than a double booking, because nobody finds out until they arrive.

After the fix

Double bookings that have already happened still need handling, and the agent should not be the one to do it. Route a detected conflict to a human with both customer names and let them make the call about who moves. The guide to using AI to handle overbooked appointments covers the recovery conversation.

And once holds exist, you get a useful by-product: every expired hold is a caller who was offered a slot and did not take it, which is a cleaner signal than most no-show data. Reducing no-shows with AI scheduling covers what to do with it. For the broader set of jobs worth handing over, see AI for small business.

Questions

Can I fix this in the prompt instead?

No. The prompt cannot make a read and a write atomic. You can reduce the window by having the agent confirm faster, which makes collisions rarer and no less possible.

My booking tool has no API for holds. Now what?

Keep your own holds table in front of it and write to the tool only on confirmation. Your table is the source of truth for availability; the tool becomes a downstream record.

How do I prove this is what happened?

Log the slot offer with a timestamp, not just the booking. Two offers of the same slot within your hold window, followed by two writes, is the signature. Without offer logs you are guessing.

Does this happen with one caller at a time?

It can, if the agent offers three slots and the caller picks the third while another channel books the first. Hold every slot you offer, or offer one at a time.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.