The Guard Layer Is the Product: Building a Dental Scheduling Agent That Refuses Bad Bookings

The Question That Matters Before Any Write

Before a scheduling agent writes anything, one question decides whether it is safe to deploy: is this booking allowed to touch the schedule? Conversation quality does not answer that. A model can be polite, clear, and well-scoped in dialogue and still call book() with a slot id that does not exist, a slot that is blocked, or a 120-minute service that starts at 16:30 on a grid that closes at 17:00.

This post describes a small build that tests that question directly. I wrote a scheduling tool for a dental practice, ran a scripted set of 40 book() calls against a synthetic fixture, and added validation in four levels. The headline measurement:

  • Level 0 (write-through) accepted 28 of 28 adversarial calls as valid and produced 4 invariant breaks.
  • Level 3 accepted 0 invalid calls.
  • At every level, 0 legitimate calls were rejected.

Scope matters here. This is the tool layer behind book(), not the dialogue. The harness never called a model’s conversational output as a result, never measured satisfaction, and never touched a real calendar. Everything below is about what a write path should refuse.

What I Built: The Fixture and the Tool Surface

The fixture is one working week (Monday to Friday) of schedule for three providers: dr-anand, dr-okafor, and hy-lee. The grid runs in 30-minute increments from 09:00 to 17:00 — 16 slots per provider per day, and 3 providers × 16 slots × 5 days gives 240 slots in total. These are our fixture’s dimensions, chosen for this build; nothing in FHIR or Google Calendar prescribes them.

Each service has a chair time in minutes:

service chair time (min)
hygiene_cleaning 60
perio_maintenance 60
new_patient_exam 60
emergency_exam 30
crown_prep 120

Slot ids follow the pattern H1-20261109-1630: a provider prefix, a date, and a start time in HHMM. The prefix H1 belongs to hy-lee, and the other providers use the prefixes D1 and D2 in the same way. The id encodes provider, date, and start, but not duration. Duration has to be checked against the schedule.

The tool surface has one write path. book() is the only function that can create an appointment, and every call to it goes through whatever validation level is configured. There is no separate “reserve” call and no batch endpoint. That keeps the measurement honest: if a bad booking lands, it landed through book().

Shaping the Tool Layer on FHIR and Google Calendar

I borrowed the shape of the fixture from HL7 FHIR’s scheduling resources. The Appointment resource has a required status valueset of proposed | pending | booked | arrived | fulfilled | cancelled | noshow | entered-in-error | checked-in | waitlist. Its participant.status valueset is accepted | declined | tentative | needs-action. Appointment.minutesDuration is defined as a field that “can be less than the duration between the start and end times”, which is useful because it separates the booked length from the clock interval. requestedPeriod describes the window within which an appointment is preferred, and patientInstruction is separated from comment because the spec says comment “contains information for internal use.” created “should remain unchanged over the lifespan of the appointment.”

The Slot resource has a required status binding to busy | free | busy-unavailable | busy-tentative | entered-in-error, with start and end both at cardinality 1..1 as instants. overbooked is a boolean that marks a slot “already overbooked, appointments are unlikely to be accepted for this time.” The Schedule resource carries a planningHorizon, and the AppointmentResponse resource covers replies such as confirmations and rejections.

On the calendar side, the Google Calendar freebusy.query method takes timeMin and timeMax formatted as RFC3339. It caps calendarExpansionMax at 50. Its response lists busy[] ranges where busy[].start is the inclusive start and busy[].end is the exclusive end. That half-open interval convention matters for contiguity checks: a 30-minute slot ending at 16:30 does not conflict with a block starting at 16:30.

What I took and what I left

FHIR and Google define the data shape. They do not define the validation ordering. Nothing in either spec tells you to check existence before status, or status before contiguous duration. Those decisions are ours. I used the resource vocabulary so the fixture’s field names would map cleanly onto a real integration later, and I wrote the guard ordering myself, based on the failures I measured.

The tool-call side followed the OpenAI function calling guide. Strict mode makes arguments “adhere to the function schema, instead of being best effort,” and the schema examples set additionalProperties to false. Strict mode constrains the shape of the arguments. It does not check whether the slot exists, which is the harness’s job.

The Test Set: 40 Scripted book() Calls

The test set is 40 scripted calls, run identically at each validation level. Twelve are legitimate. Twenty-eight are adversarial, in four classes:

class count defeated first at why this level
Hallucinated or non-existent slot ids (8 hand-written, 10 from an off-grid sweep) 18 Level 1 (slot-exists) The slot id is not in the fixture at all
Slots that exist but are blocked (two busy-unavailable blocks and one busy-tentative hold) 3 Level 2 (status-free) The slot is real but its status is not free
Double-books (second patient pushed onto an already-booked slot) 4 Level 2 (status-free) The slot is real but already holds an active appointment
Duration overruns (60- or 120-minute service starting at 16:30 on a 30-minute grid) 3 Level 3 (duration+dedupe) The slot is free, but the service does not fit in the remaining contiguous time

Each class targets a different level on purpose. If a single check handled all four, the table would show one jump instead of a progression, and I would learn nothing about which residual each check removes.

The 12 legitimate calls are the control. A guard stack that refuses everything would score perfectly on adversarial calls and fail the legitimate ones, so the legitimate count is reported alongside every adversarial result.

Level 0: Write-Through, and What It Accepted

Level 0 is the version that “worked” in demos. It validates the request’s shape, then writes whatever book() receives. It does not look up the slot, check its status, or measure the duration.

At level 0, all 28 adversarial calls were accepted. Four of those acceptances produced invariant breaks: the four double-books, each of which left two active appointments holding the same slot. The harness counts those as “invariant_breaks.” Level 0 also accepted all 12 legitimate calls, which is the reason the naive version looks fine in a demo.

The point of the level-0 row is that nothing in the write path was testing the schedule. The model’s output was the only check, and the model is not a schedule.

Level 1: Slot-Exists

The loudest failure at level 0 was the hallucinated slot id. The 18 invented or off-grid ids were accepted as real bookings. Our level 1 adds one check: the slot id must exist in the fixture.

This removed all 18 hallucinated-id acceptances. The accepted-invalid count fell from 28 to 10. The remaining 10 are the 4 double-books, the 3 blocked slots, and the 3 duration overruns. Legitimate calls remained at 12 of 12 accepted.

The tradeoff is clear from the test set. An existence check catches invented ids. It does not catch unavailable ones. A slot can exist in the grid, have a real id, and still be blocked or already taken. Existence is a necessary check and a weak one.

Level 2: Status-Free

Level 1 still double-booked real slots. The four double-books were valid ids, so they passed the existence check, and the invariant breaks remained at 4. Our level 2 adds a status check: the slot must be free before the write.

Status-free removed the 3 blocked-slot acceptances and the 4 double-books. The invariant breaks dropped to 0. The accepted-invalid count fell to 3, and all three survivors were duration overruns. Legitimate calls were still 12 of 12 accepted.

Those three survivors were:

  • hygiene_cleaning at H1-20261109-1630 (60m)
  • new_patient_exam at D2-20261111-1630 (60m)
  • crown_prep at D1-20261113-1630 (120m)

Each starts at 16:30 on a grid that ends at 17:00. The slot itself is free and has a valid id, so existence and status both pass. The service simply does not fit. A 60-minute service from 16:30 needs slots through 17:30. A 120-minute service needs slots through 18:30. Neither exists in the fixture’s 09:00–17:00 grid.

Level 3: Duration + Dedupe → Zero

Level 3 adds a contiguous-time check and a dedupe check. The duration check walks the grid forward from the requested start, summing the chair time, and rejects the booking if the service would run past the available contiguous free time. The dedupe check refuses a second active appointment on a slot that is already held.

The results at all four levels:

level validation accepted_invalid rejected_valid invariant_breaks
0 write-through 28 0 4
1 +slot-exists 10 0 4
2 +status-free 3 0 0
3 +duration+dedupe 0 0 0

Twelve of 12 legitimate calls were accepted at every level, including level 0. The guard stack removed every adversarial acceptance without rejecting a legitimate call on this fixture.

Three log lines from the level-3 run show the three rejection codes in use:

{"case": "hallucinated-slot-0", "slot": "H1-20261109-0730", "service": "hygiene_cleaning", "verdict": "rejected", "code": "UNKNOWN_SLOT"}
{"case": "blocked-slot-2", "slot": "H1-20261109-1200", "service": "hygiene_cleaning", "verdict": "rejected", "code": "SLOT_NOT_FREE"}
{"case": "duration-overrun-1", "slot": "D1-20261113-1630", "service": "crown_prep", "verdict": "rejected", "code": "INSUFFICIENT_CONTIGUOUS_TIME"}

The result is zero, and it is zero on a 40-call fixture. I do not claim it generalizes. The next section explains what it does and does not say.

Why Conversation Quality Is the Wrong Metric

Scheduling agents are usually evaluated in dialogue: did the patient get a time, did the assistant sound helpful, did the booking confirm. Those are reasonable questions for a product team. They are the wrong questions for deciding whether the write path is safe, because a conversation can be pleasant while the tool call underneath it is wrong.

The no-show literature points the same way. The Clinekt summary of dental no-show research cites a PeerJ Computer Science 2022 study of 196,018 dental appointments, which reported a 42.68% no-show rate and a validation set of 49,007 appointments at 41%. The study found lead time between booking and visit to be the single strongest predictor, with the patient’s own prior no-show history second. A 2025 University at Buffalo School of Dental Medicine analysis of 7,379 pediatric dental visits reported 14.3% no-shows, with adolescents aged 12 to 17 at 24%. The same summary gives a published spread from about 14% in a well-managed academic pediatric clinic to more than 40% in a large general clinic. The ADA Health Policy Institute’s Q4 2025 data, as quoted in that summary, says “One third of dentists said they were not busy enough” in late 2025, up from one quarter a year earlier.

The 2026 Catalyst Index report from Henry Schein One gives different numbers for groups with 8 or more locations: a 3% industry average no-show rate, with the top 10% of performers under 1%, and a 5% average cancellation rate with the top 10% under 1%. The same report says “A good no-show rate for a DSO or multi-location dental group with 8+ locations is under 2%,” and attributes top performance to “standardizing confirmation workflows across locations, not by relying on individual front desks.”

The 3% figure and the 14–42% figures do not contradict each other. They measure different populations with different counting. The DSO benchmark covers multi-location groups running standardized confirmation workflows. The clinic studies cover individual clinics, some pediatric, with their own denominators. Read them as separate populations, not as one number that somebody got wrong.

None of this is a result from this build. I did not measure no-shows, and nothing in the guard stack is shown to reduce them. The point of the research here is narrower: the conversation is not where the scheduling risk sits, so the conversation is not the right thing to score.

The benchmark trap

A benchmark that rewards a clean conversation can reward a bad booking. The Dental Signal makes this point in a different context: a practice that dismisses every patient with two broken appointments “will post a beautiful no-show number and a worse new-patient-to-active-patient conversion rate.” The metric improves while the underlying operation gets worse. A booking agent that refuses to write invalid slots is a different kind of change: the failures it removes are the ones that corrupt the schedule itself.

Vendor numbers in this space are worth reading with the same caution. Clinekt states that “82% of patients try to book care outside a practice’s regular office hours.” That is a vendor claim. Its published case results are from orthopedics, not dentistry: Baldwin Bone & Joint reported “263 qualified surgical leads, 159 booked appointments, and a 60% booking rate in a single quarter.” I cite these only to show what vendor evidence looks like here. They are not independent evidence for a dental build.

A separate figure, about $196 per no-show, comes from a large multi-clinic medical dataset. It is not a dental dataset, and I do not use it as a dental cost figure.

What This Harness Does NOT Cover

The harness is a fixture, and a fixture leaves out most of what production needs. These are the gaps:

  • Auth and tenant scoping. The fixture has one calendar set and no notion of which practice or user may call book().
  • Idempotency keys across retries. A retried book() after a timeout is not tested, so duplicate writes under retry are unmeasured.
  • Timezone and DST-aware datetimes. Slot ids and times are fixed local values; daylight-saving transitions are not exercised.
  • PMS write reconciliation. The harness never wrote to a practice-management system, so drift between the agent’s view and the PMS is not measured.
  • Concurrency between two agents. Only one writer ran, so races between two agents booking the same slot are untested.
  • Audit trail. The log lines above are harness output, not a durable, queryable record of who booked what and when.
  • Cancellation and reschedule policy. Only book() was tested; cancellation and moves are out of scope.
  • HIPAA scope. I have not assessed whether any part of a production version falls under HIPAA. This is listed as an open gap, not an achievement.

FAQ

Why FHIR instead of a custom schema? Because the field names and value sets are already published, and they map onto real integrations later. FHIR gave me vocabulary for status, duration, and instructions. It did not give me a validation order, so the guard ordering is my own.

Why not just prompt the model harder? Prompting changes what the model tends to produce. It does not change what the write path accepts. The level-0 run shows a write path with no checks accepting every adversarial call, regardless of how the model was prompted. The checks have to live in the tool.

Does this reduce no-shows? Not established. The build measured acceptance of invalid bookings on a fixture. It did not measure no-show rates, patient behavior, or outcomes. The no-show figures above are published research and are not results from this work.

What breaks first in production? Based on the gap list, I would expect concurrency and reconciliation to break first, because a fixture with one writer and a local clock does not exercise either. That is an expectation, not a measured result.

Why 40 calls and not 400? Forty calls are enough to show each class separately and to see which level removes which class. The count is small on purpose: I wanted every case readable by hand. A larger set would be a useful next step, but it would not change the argument that the write path needs checks.

The Bottom Line

On a 40-call fixture, the validation stack behind book() determined whether bad bookings reached the schedule. Accepting all 28 adversarial calls at level 0 produced four invariant breaks. Each level removed a class of failure that the previous one missed, and level 3 reached zero accepted-invalid bookings while accepting all 12 legitimate calls. The conversation was never the thing being tested. The result holds for this synthetic fixture of one working week, three providers, and 240 slots, and it says nothing measured about production calendars, patients, or no-show rates.

How This Guide Was Built

  • (a) The harness was run locally against a synthetic fixture calendar. No real practice data, no patient records, and no live practice-management system were used, and no production calendar was touched.
  • (b) No model benchmark or per-model tool-calling score was measured or consulted. The Berkeley Function Calling Leaderboard V4 is referenced only for the existence of its evaluation axes.
  • (c) No patient outcome and no no-show reduction was measured. The no-show statistics cited are published research, not results from this build.
  • (d) The 40-call test set and the four-level numbers are my own fixture measurements.
  • (e) Vendor figures, including the Clinekt and Baldwin Bone & Joint numbers, are labeled as vendor claims.
  • CodeIntel Log — code quality, debugging, and software engineering benchmarks
  • ToolBrain — tool reviews, LLM comparisons, and AI workflow guides

Cross-links automatically generated from NiteAgent.

← Back to all posts