Axenity
All posts
AIBackendCase Study

We built an AI booking agent for a tyre shop. Here's the architecture, cost, and what almost broke it.

A production walkthrough of the LLM-backed booking agent we built for Tyre Express — how it ingests requests, checks technician availability, auto-assigns jobs, and the two design decisions that saved us from a very expensive month.

Muhammad Omar

Co-Founder & CEO

22 September 20269 min read

A tyre shop's phone is louder than most offices. Between walk-ins, return calls, insurance quotes, and the person who wants to know if you can “just check a tyre, it's making a weird noise,” the front-desk person's real job is orchestration — not customer service. They're the last human standing between a paying job and chaos, and they spend all day mentally recomputing which tyre fitter is free, which bay is empty, and which car is still on a hoist.

That's the problem Tyre Express came to us with. Not “we want AI.” They said: we're losing bookings because the person answering the phone doesn't have time to check the schedule. We shipped an AI booking agent that ingests every appointment request, checks live technician availability, and auto-assigns the next-free worker — so the front-desk person doesn't have to.

This post is the honest walkthrough: architecture, two design decisions that mattered more than the model choice, the cost breakdown, and the near-failure that changed how we build these.

The shape of the problem

Before touching an LLM, we sat with the shop for a day. Two patterns showed up in the raw booking log:

  1. Every booking touches three data sources. The customer's request (vehicle, service type, preferred window), the technician roster (who's on shift, who's certified for what), and the bay schedule (what's physically in the workshop right now). Getting any one wrong wastes 20 minutes.
  2. The person handling the phone is not the bottleneck. They're just the visible part. The real bottleneck is the mental model of “who's free right now.” When it lives only in one head, everything queues behind that head.

Those two observations shaped every architectural decision that followed.

Architecture, in one paragraph

The system is deliberately boring. An LLM agent (behind a /api/booking-agent route) takes a natural-language request — from the website form, phone-call transcript, or SMS — and classifies intent. It calls out to three deterministic tools: get_availability(technicianId, window), get_bay_status(), and reserve_slot(technicianId, bayId, window, customerId). Those tools hit Postgres directly; no other AI in the loop. The agent's only job is to translate messy customer input into a proposed booking, negotiate windows if the ideal slot is full, and confirm with the customer before writing to the database.

Design decision 1: auto-assign vs. queue-first

The first big fork was: when a booking comes in, do we auto-assign it to a specific technician, or do we drop it into a queue that any technician can pick up later?

Queue-first is what most scheduling SaaS does. It's simpler to build, it doesn't need real-time roster data, and it feels safe. But the pattern we saw at Tyre Express was that the moment a job was “in the queue,” it became invisible — nobody owned it, so nobody chased the customer if there was a problem.

Auto-assign is harder to build (you need live roster and bay data) but it maps directly to how the shop actually operates: one job, one owner, one accountable person. We went auto-assign. Every booking has a technician name attached the moment the customer confirms it.

Design decision 2: confirm-then-write, not write-then-confirm

Most agent frameworks default to “take an action, then tell the user.” That is a bad pattern for anything that touches money or a physical schedule. If the model gets the time wrong once, you have a technician standing around at 8am for a customer who thought they booked 3pm.

We invert it. The agent's only booking-side tool is propose_slot(), which writes to a temporary proposed_bookings table with a 10-minute TTL. The customer sees the proposal in a confirmation SMS and either replies “yes” or picks a different window. Only on explicit confirmation does the agent call reserve_slot(), which promotes the proposal into a real booking.

This adds one round-trip. It also single-handedly killed almost every hallucination-related booking error. If the model got the time wrong, the customer just said no.

What it actually costs

The economics matter for small businesses. A single booking conversation runs 6-12 LLM turns depending on how much the customer negotiates. Averaging across a month of production traffic:

  • Model: Claude Haiku for classification + tool use; Claude Sonnet only for edge cases the classifier flags as ambiguous. About 90% of bookings never touch the more expensive model.
  • Tokens per booking: ~3,500 input + ~800 output on average.
  • Cost per completed booking: under 2 US cents.
  • Cost per abandoned conversation: under 0.5 cents. These are frequent — someone starts booking, gets distracted, comes back later.

The whole agent bill is a rounding error against the labour it replaces. The real cost is engineering time: the tools, the override UI, the SMS integration, and the observability that lets the shop trust the thing.

What almost broke it

Two weeks in, we hit a race condition that could have cost a weekend of bookings. The propose_slot() table wasn't transactionally isolated from the live availability check. Two customers, one slot, the model happily proposed both. We caught it in staging before it hit production, but only because we'd wired a Sentry alert on any reserve_slot() call that failed with a unique-constraint violation.

The fix was mechanical (wrap the availability check +propose_slot() in a serialisable transaction, use a Postgres advisory lock for the technician-window key). The lesson was not: it was that observability has to ship on day one, not “when we have time.” If we hadn't been listening for constraint violations, that bug would have surfaced as a customer showing up to a locked shop.

What we'd do differently

  1. Model choice is the last decision, not the first. We spent an afternoon debating Claude vs. GPT vs. a fine-tuned smaller model. In hindsight that afternoon should have gone to the override UI. The model is fungible; the workflow around it isn't.
  2. Ship the human-visible dashboard first. The shop used the agent much more confidently once they could see every booking the agent had made in the last 24 hours, with the reasoning trace attached. That took us four days and paid for itself in the first week.
  3. Propose-then-confirm should be the default pattern for anything money-adjacent. We're using the same pattern on two newer builds now.

Who this pattern suits

Not every service business needs an LLM agent for scheduling. If your bookings are already going through a decent SaaS calendar and the front desk isn't underwater, adding an agent will burn engineer time for marginal gain. Where this pattern earns its keep is a business that meets all three of these:

  • Bookings arrive through multiple channels — phone, website form, SMS, walk-ins — and each channel currently needs its own manual handling.
  • Assignment is non-trivial: technicians have certifications, bays have capacity, or jobs have different time budgets. A calendar with 30-minute slots can't capture it.
  • The person handling requests is losing bookings — not just running late, but customers hanging up because nobody picked up.

If two of those three apply, an agent will pay for itself before the end of the month. If only one applies, buy a better calendar.