Your AI booked three therapists into two rooms at 10 AM. Every booking said 'available.'
A physiotherapy clinic with four therapists and two treatment rooms discovered the problem at 10:04 on a Monday morning, when the third patient of the hour arrived and there was nowhere to put her. The front desk had tw
A physiotherapy clinic with four therapists and two treatment rooms discovered the problem at 10:04 on a Monday morning, when the third patient of the hour arrived and there was nowhere to put her.
The front desk had two options: send her home, or put her in a chair in the waiting area and have a therapist work on her shoulder in front of everyone. They chose the chair. She watched the whole session, then left a review that mentioned the word "unprofessional" four times and cancelled her remaining five appointments.
The owner assumed it was a one-off. It wasn't. When I pulled the booking logs, the pattern was arithmetic, not bad luck: in 90 days the AI receptionist created 1,180 appointments and 61 of them β 5.2% β landed in a slot where a required physical resource did not exist. A room, a piece of equipment, a second staff member. Every single one of those bookings was recorded by the AI as "available," because availability was the only thing it had been taught to check.
That's the failure mode I want to write about, because it looks like a bug and it isn't. It's a data model problem, and it is the most expensive silent defect in AI scheduling.
Why "available" lies
The AI's tool call was check_availability(therapist_id, date). It returned open slots. The model picked one. Done.
But a physiotherapy appointment isn't a therapist-shaped hole in a calendar. It's the intersection of four things:
- a therapist with the right certification for the treatment,
- a room of the right type (private, gym, hydro),
- equipment that isn't already in use (the ultrasound unit, the TENS kit),
- and a duration that matches the treatment, not the default slot length.
The tool answered one of the four. The model, having received a green light, behaved exactly as a language model should: it concluded the booking was fine, and said so in a warm, confident sentence.
Here's the part that makes this different from ordinary integration sloppiness. A human receptionist doesn't check the calendar. She checks the room. She's sitting in the building. She knows the 10:00 patient needs the gym and the 10:00 therapist is doing manual therapy in room 2. The knowledge isn't in any system β it's in her body. When you replace her with an agent that reads one API, you don't lose ten percent of her judgment. You lose the entire dimension of physical capacity, and nothing in the logs flags it, because every individual booking looks correct.
Step 1: Stop booking resources. Book bundles.
The fix starts with a refusal: the agent is never allowed to book a therapist, a time, or a room individually. It books a bundle β a pre-validated, all-or-nothing combination of every resource the service requires.
Define the bundle in your service catalog, not in the prompt:
{
"service": "shoulder_rehab_session_45",
"requires": {
"therapist": { "cert": ["ortho"], "count": 1 },
"room": { "type": "private", "count": 1 },
"equipment": [{ "sku": "ultrasound_01", "minutes": 15 }],
"duration_min": 45
},
"buffer_after_min": 10,
"overbook_ratio": 0.0
}
Two lines in that schema carry most of the weight.
buffer_after_min: 10. Without a turnover buffer, the AI will happily book 10:00β10:45 and 10:45β11:30 into the same room, and the second patient waits while the room is wiped down and the notes are written. This is the single most common complaint I see after the double-booking bug is fixed: not "there was no room," but "there was technically a room." Ten minutes is not a scheduling nicety; it's the difference between a slot and a fiction.
overbook_ratio: 0.0. Explicitly zero by default. See Step 4 for why this must be a policy number and not an inference.
Step 2: The capacity gate β atomic, or nothing
The bundle has to be reserved in one transaction. If your scheduling API can't do that, you have to build the transaction around it, because the failure mode is a race, and races are exactly what AI agents are good at creating: they answer instantly, so two conversations can request the last 10:00 room within 400 milliseconds of each other, both see "available," and both confirm.
{
"capacity_gate": {
"mode": "bundle_atomic",
"steps": [
"resolve_service_bundle",
"create_hold_all_resources_ttl_300s",
"verify_all_resource_ids_held",
"confirm_booking_with_hold_id",
"release_hold_on_any_failure"
],
"confirmation_requires": "hold_id_returned_and_all_resources_verified",
"on_partial_hold": "release_everything_and_offer_alternatives",
"on_confirm_failure": "release_hold_and_never_say_booked",
"customer_facing_rule": "never_say_confirmed_until_hold_id_exists"
},
"fallback_when_no_bundle": {
"action": "offer_two_alternatives_plus_waitlist",
"forbidden": ["book_partial", "promise_manual_fix_without_task", "reuse_stale_availability"]
}
}
The line that earns its keep is customer_facing_rule. The AI must not utter "you're all set for 10 AM" until a hold_id exists β the same discipline as "no booking, no promise," applied one layer deeper. Teams that skip it get the worst variant of this bug: the calendar shows the appointment, the room was never held, and the patient is confirmed for a session that will be conducted in a corridor.
And reuse_stale_availability deserves its own forbidden entry. Agents that "remember" the availability result from three turns earlier will re-offer a slot that has since been taken. The availability check has a shelf life measured in seconds. Re-check at confirm time, every time.
Step 3: Make the conflict visible before it happens
Gates prevent the future. They don't tell you what already happened, and every deployment I've audited has a backlog. So before you fix anything, run one query: for each upcoming appointment, join against required resources and count slots where any required resource is missing or double-assigned.
That's how we found the clinic's 61. Sorted by date, 9 were still in the future β nine patients who could be moved by a phone call instead of an apology. The backlog is not a report; it's a work queue, ordered by how soon it bites.
Then keep it running. A daily 06:00 job that lists tomorrow's conflicts should produce an empty list every day. When it doesn't, you get a day of warning instead of a Monday morning ambush.
Step 4: Deliberate overbooking is a number, not a judgment
Some businesses genuinely overbook β a 5% buffer against no-shows is standard in dentistry, less so in physio. That's a legitimate policy. What is not legitimate is the model deciding to do it, because the model will do it for the wrong reason: to make the customer happy, to avoid saying no, or because the last three transcripts ended with a booking and it has learned the shape of a good answer.
If you overbook, encode it as a number attached to a service, log every overbook with a reason code, and require a human owner's sign-off on the ratio. Then audit it: if your configured ratio is 0.03 and your observed oversell rate is 11%, the model has been improvising capacity policy, and you will find out from a waiting room, not a dashboard.
Step 5: The Monday audit (five checks, 15 minutes)
| # | Check | Method | Healthy threshold |
|---|---|---|---|
| 1 | Bundle completeness | Bookings lacking a full resource set | 0 |
| 2 | Conflict backlog | Future appointments with a missing/double-assigned resource | 0 |
| 3 | Hold-less confirmations | Confirmations with no hold_id in log |
0 |
| 4 | Observed oversell vs. policy | Actual overbooks Γ· bookings vs. configured ratio | Within Β±1 pt |
| 5 | Turnover violations | Bookings inside buffer_after_min
|
< 1% |
Check 4 is the one that catches drift. Ratios don't fail loudly. They slide while the model gets subtly better at "finding a way."
The numbers, for the internal pitch
- Unconstrained single-resource checks: 3β6% of bookings land in physically impossible slots. The clinic was 5.2%. In a 6-bay auto shop I audited, it was 8.1%, because bays also need lifts and a lift is a 90-minute commitment.
- Cost per conflict: $120β$200 in front-desk repair time, comped service, and churn. The clinic's 61 conflicts cost roughly $8,500 a month, plus 4.5 hours of weekly manual reshuffling that nobody had counted as labor.
- Bundle booking adds 300β800 ms to the booking call. Customers do not notice; they notice being sent home.
- After the gate, conflicts went to zero in the first month and stayed there, because the agent became structurally incapable of booking a therapist without a room.
The clinic's fix was a service catalog with eight bundles, a hold-verify-confirm sequence around the existing API, and a daily conflict query. The chair in the waiting area is gone. The review is not.
If you want the working version of this β capacity-gate schemas, bundle catalogs, the conflict-backlog query, and the audit checklist I run on every deployment β it's here: https://whop.com/agentfast-04b6/
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.