For technical teamsFor business owners

Taking the model off the critical path

A multi-tenant WhatsApp booking agent whose confirmation step makes zero LLM calls — and answers in under a second instead of nine.

Kotlin · Spring Boot · Claude (tool calling) · WhatsApp Business Platform · Production, live tenants

Context

A hospitality SaaS platform needed guests to book appointments and tables by chatting on WhatsApp, in natural Spanish, against a real availability system — multiple businesses, one bot, each with its own services, staff and opening rules.

The interesting constraint was not “make the LLM book things”. It was that a booking is money and a promise. The guest reads a confirmation and turns up. Anything the agent says has to be true, and it has to be fast enough that a person doesn’t abandon the chat.

The problem

The first working version had the model do the whole job: gather the details, write a confirmation message, then call the booking tool on the next turn. Two failures came out of that shape.

It was slow where it mattered most. The guest taps “Yes” — the single moment they’ve decided — and waits 7.4 to 9.4 seconds for a round trip through a large model that had nothing left to decide.

It could lie. The confirmation text was prose the model wrote; the booking was arguments it passed a turn later. Nothing made the two agree — a guest could read 19:30 and be booked at 19:00. Worse, a model that ran no tool at all could still write ”✅ Booking confirmed” and reach a real customer. That happened in production.

What I did

I moved the commit out of the model’s turn entirely.

confirm_booking(args)   →  the bot renders the summary FROM those args,
                           sends it with two buttons, and ends the turn
guest taps "Yes"        →  committed by code before the model is consulted:
                           apiCalls = 0, receipt built from the same args

One set of arguments now renders the question and performs the commit, so the summary and the booking cannot disagree — not because the prompt asks them to match, but because there is only one of them. And there is no model call on the turn that confirms, so there is nothing to fabricate with.

Three design rules earned their keep:

  • Whole-message affirmatives, never substrings. “Sí” is a yes. “Sí, ¿pero puede ser a las ocho?” is not, and a contains() check would book the wrong slot. A fast happy path that also books when the guest said “yes, but…” is worse than the slow one it replaced.
  • The pending confirmation lives on the conversation, next to the session state, so it expires with it. That state authorises a commit; given its own timer it would outlive the chat that produced it, and a guest’s “yes” to some later question would book something they’d abandoned.
  • A claim guard on the wire. If a reply announces a confirmed booking and no tool ran that turn, the message is not sent — the guest gets an honest notice instead. Belt and braces for the case where the model routes around the design.

Result

beforeafter
the “Yes” turn1 model call, 7.4–9.4 s0 model calls, ~0.9 s
model calls per turn (avg)1.20
summary vs. booking mismatchpossiblestructurally impossible
fabricated confirmationsreached a real customerblocked at the wire

284 automated tests. Deployed with a versioned runbook, flag-level rollback, and a startup line that prints which feature flags are actually on — added after a release ran with the feature silently disabled and cost a full handset test to diagnose.

The transferable lesson

Prompt instructions are not controls. Across this project, every “never do X” written in prose was eventually violated by the model — four times out of four. Each one was only fixed when the behaviour was made impossible in code: a tool that ends the turn, state that expires with its conversation, a guard that inspects the outbound message.

Prompt edits are a same-day mitigation. They are never the fix. If a wrong answer in your system costs money, the correctness has to live somewhere the model cannot reach.

Stack

Kotlin · Spring Boot · Anthropic Claude with tool calling · WhatsApp Business Platform (Meta Cloud API, webhooks, interactive buttons, message templates) · MySQL · multi-tenant by phone number · per-tenant token and cost metering · systemd on Linux VPS

Client and tenant names withheld. Figures are measured from production logs.

← All case studies