Case Study · Independent Build

Keepford: an AI phone receptionist where code, not the LLM, decides

Putting an LLM on a real phone line isn't the hard part; deciding what it's allowed to decide is. Keepford is my independent build of an AI phone receptionist for home-service businesses, running on a live demo line that answers as a fictional plumbing company. The model holds the conversation. Application code makes the decisions that matter and carries out the actions.

20 of 23 real-call acceptance scenarios passed; 3 could not be run (one tester)
2 life-safety scenarios validated on real test calls: gas and carbon monoxide
About 1,200 automated tests in CI (a point-in-time count)

Hear it and read it: the two-call recording below · the full case study (PDF) · keepford.com

Two real calls to the demo line: a blocked-drain booking, then a burst-pipe transfer. Pauses are shortened; nothing is re-voiced or reordered. Caller details are fictional, and the transfer is answered by the demo's automated test line.

Context

Keepford is an AI phone receptionist for home-service businesses, and an independent project of mine: not a client engagement, and not a product with customers. Phase 1 built the voice receptionist: a Retell voice agent on a Twilio number, backed by an application built with Python, FastAPI and PostgreSQL. Built with Python (Keepford); primary stack Ruby on Rails.

The most important result is a design rule that came out of real test calls rather than being assumed up front: each time a real call exposed a model failure or a vendor limitation, the fix moved a decision or action out of the model and into deterministic application code.

Challenge

A chatbot that gets something wrong produces a message the user can reread or retry. A voice agent acts in real time on a caller who cannot see what the system is doing, and some of its mistakes cannot be taken back.

  • Safety: a caller who smells gas needs exact instructions and then needs to hang up and leave. A helpful follow-up question is the wrong answer.
  • Booking correctness: "you're booked" is a commitment. If the appointment doesn't exist, nobody arrives.
  • Transfers: these happen on the telephony leg, which the voice platform may not control.
  • Provider failures: a calendar write can time out after it has succeeded.
  • Speech recognition: the model reads a transcript, not the caller, and transcripts drop words and garble addresses.
  • Opaque identifiers: LLMs are unreliable at copying long strings back exactly.
  • Truthful confirmation: every "done" the caller hears must correspond to something that happened.

The central question is: what should the model be allowed to decide? My answer: the model owns the conversation, and the application owns anything with consequences.

Approach: where the LLM talks and where code decides

Call control architecture: the Retell model handles conversation; the application owns safety, service, availability, booking, transfer and call-outcome decisions, and acts through Twilio redirects for safety scripts, transfers and platform-failure fallback.
Call control at Phase 1: the model handles conversation; the application decides and acts through Twilio. View full size.

Twilio sends every call to the application first. The application registers the call with Retell and connects the audio, so it stays in the call path and can later redirect the live call itself.

  • The model handles the conversation: understanding speech, keeping context, asking questions, speaking to the caller, and deciding when to call a tool.
  • The application decides and acts: safety classification, service-area and supported-service decisions, availability, slot references, booking validation and persistence, transfer mechanics, failure handling, and classifying and storing every call's outcome. The model reaches it through seven tools behind a signed endpoint, and each tool result tells the model what happened and what it may say.

Business details are configuration, not code; a second, deliberately different fictional business (test-only, not live) runs its own scenarios through the same test kit with no changes to the core.

This is not a claim that the model cannot make mistakes. It still decides whether and when to call a tool, and a voice platform cannot force a tool call. Some behaviours, such as how it ends a spam call, still depend on the prompt. What the split guarantees is narrower: once a decision reaches the application, the decision itself is made by code, not by the model.

Key decisions

Life-safety calls are ended by the phone system, not the model

Safety classification was fixed-rule from the first build, and by the time of the real-call tests it covered gas, carbon monoxide and fire. The model passes the caller's words to an assess_situation tool, which classifies them with fixed rules. Given the same recognised words, the classification is deterministic, and no business configuration can switch it off. What the first design still left to the model was delivery: the tool returned the approved script with instructions to read it word for word and end the call, and trusted the model to do both.

The gas-leak scenario failed twice on real test calls. On the first attempt, speech recognition dropped the word "gas"; the fixed rules correctly did not treat an unnamed smell as a gas leak, so the model asked a clarifying question, and when the caller repeated themselves the script was read correctly but the model added speech of its own. On the second, the script was read verbatim, immediately, and then the model did not end the call.

Classification was not the problem, and the fix did not change it. The fix moved delivery and termination into the application. On a life-safety classification, the application saves the outcome, safety event, owner alert and exact script in one transaction. Only then does it ask Twilio to redirect the live call to a safety endpoint, which speaks the saved script with <Say> and ends the call with <Hangup/>. The model's call leg ends at the redirect, so it gets no further conversational turn, and the words come from the database, never from the model. If Twilio refuses the redirect, the model receives the script and the end-call instruction as before: a weaker guarantee, but the outcome and alert are already saved. The trade-off is deliberate: the caller hears the phone system's voice, not the assistant's, in exchange for exact words and a hang-up that doesn't depend on the model.

The third gas attempt passed, and so did a fresh re-verification call on a later build; the carbon-monoxide scenario passed first time on the same design. This is deterministic termination after classification, not a claim that every emergency will be detected. Two things upstream of the classifier remain limits: speech recognition can lose the trigger word, and the model has to pass the caller's words to the tool. Detection is also keyword-based, so unusual phrasing can be missed. Fire and smoke are implemented as a third category with automated tests, but fire was not tested on a real call. The scripts are approved defaults each business signs off; they are not safety advice, and Keepford is not an emergency service.

Don't make an LLM copy opaque IDs

Booking integrity sequence: before, the model had to copy a 62-character slot ID; after, it picks a per-call reference such as S1, which the application resolves, checks against the slots offered on that call, holds in PostgreSQL, re-checks and writes before confirming. Failure branches: unknown reference, slot unavailable, calendar write failure, concurrent booking.
Booking integrity: the model selects a per-call reference; the application resolves it, checks it was offered, holds the slot, and only then says "booked". View full size.

The first booking design exposed opaque slot IDs: 62-character strings encoding technician, service and start time, which the model had to copy back. On an early build, the model sent back a corrupted copy three times; the application couldn't decode it and refused, but at that build it wrongly told the caller the time was unavailable. That failure led to two changes: a truthful message for an ID that doesn't decode, and a new rule that now anchors booking integrity - book only an ID that appears, by exact string match, in the list of slots this call was offered. Never decode an ID to decide whether it is bookable, because a mis-copied ID can decode to a different, perfectly valid time.

A later test call produced exactly that, and was the first time the new check caught the model: the caller chose 7:30, and three times the model sent the ID for 7:00, one character different. The check refused each one and nothing wrong was booked, but the conversation never recovered.

The second fix removed the copying. check_availability now labels each offered time with a per-call reference (S1, S2, S3) that is never reassigned. The model sends back only the reference; the application resolves it to the ID it stored for that call, then still applies the exact offered-slot check. An unknown reference such as S99 fails closed as slot_not_offered. The model now selects from a small constrained set while the application keeps authority over what each selection means. Three real calls passed on this build, one of them booking in a single tool call.

Say "booked" only after the write

After the offered-slot check, a booking takes a hold in PostgreSQL (an exclusion constraint on each technician's time rejects an overlapping hold outright, and holds expire quickly), re-checks the calendar for that exact range, reserves an ExternalRef record for the write, writes the appointment, and only then, in one transaction, stores the job, confirms the hold and stores the confirmation.

The caller hears "You're all set", followed by "We'll text a confirmation shortly", only after the write succeeds. If the write fails or times out, the hold is released and the result is pending_confirmation: the caller is told the office will text them to confirm the time, and the pending ExternalRef plus a retained lead become a reconciliation record for the office. On the demo line, neither text is actually sent.

Concurrency protection is enforced by the database and verified by automated race tests: 2 and 10 simultaneous bookings for one slot, and exactly one succeeds. Retries don't create a second appointment, and the pending_confirmation path is tested. Real calls demonstrated the success path only; the real two-phone race could not be run, because no second phone was available.

Transfers the application controls

Calls arrive through Twilio and reach Retell over SIP, and Retell's built-in transfer could not act on that Twilio leg. So the application took over: transfer_call claims the transfer and saves the destination, Twilio redirects the live call, the phone system speaks the business's emergency guidance (if any) and then "Connecting you now", and it dials the on-call number with a ring timeout. If nobody answers, the caller is told plainly what happens next and can leave a voicemail; the call is classified and one urgent owner alert is generated.

A real call shaped the ordering: on the first burst-pipe attempt the model spoke the guidance and requested the transfer in the same turn, and the redirect cut the guidance off mid-sentence. The application now saves the guidance and the phone system speaks it before the announcement. On the demo line the on-call number is an automated test line, not a technician, and owner alerts are generated and stored, not sent.

What real calls exposed

The real calls were not a demonstration at the end; they were part of the engineering loop. Each failed attempt was recorded with the build it ran on, and the ones that mattered changed the implementation:

  • A dropped "gas" happens before the application sees the words; it is now a documented limit, not an unknown.
  • A model that didn't end a safety call led to application-owned termination.
  • Wrong slot IDs led first to the offered-slot check, then to per-call references.
  • Guidance cut off by a redirect moved the guidance into the phone system.

Evidence

  • Contract and scenario tests: every scenario passes the tool-contract layer for both fictional businesses (27 of 27 and 6 of 6), without telephony. About 1,200 automated tests run in CI, with linting, type checking and a business-data leak check; ten are deliberate expected failures that document known limits. No coverage percentage has been measured.
  • Simulation: one scoped simulated-conversation run on the voice platform passed 8 of 8, covering the scenarios a simulation without a phone leg can exercise.
  • Real-phone acceptance: 23 scenarios; 20 passed, 3 could not be run, none were failing at sign-off. The full per-scenario summary is in the appendix below.
  • Performance (point-in-time, from the voice platform's own measurements, one tester calling the US demo line internationally): median about 1.1 s from the caller stopping to the agent speaking, 90th percentile about 2.5 s; the slowest booking tool call took 931 ms.

Scope: live vs planned

Live in Phase 1Planned (not in Phase 1)
Live phone line and voice conversation (Twilio, Retell)Google Calendar and GoHighLevel scheduling
Service, service-area and price-range handlingCRM sync and follow-ups, including missed-call text-back
Fixed-rule life-safety handling, application-owned terminationInvoicing and payments (QuickBooks, Stripe)
Application-controlled transfers with voicemail fallbackAutomation flows (n8n, Zapier, Make.com)
Availability and booking with database holdsVapi as a second voice platform; an MCP owner assistant
Call outcomes and missed-call reasons for every callSending SMS (messages are generated and stored today)
Platform-failure fallback audio (automated tests only)

How it was built

I set the architecture and engineering rules, defined the acceptance criteria, made the design decisions and ran the real-call testing. The code was written by an AI coding agent working under those rules, with CI enforcing them.

What this does not show

  • One business: one fictional business is live; the demo is not a client deployment and has no customers.
  • Callers: one tester, calling the US demo number from a non-US mobile. The evidence does not establish behaviour across multiple callers, accents, background-noise conditions or US-originating callers.
  • Current build: only three real calls ran on the current booking build; other passes come from earlier Phase 1 builds, some re-verified and some not re-run.
  • Concurrency: no real concurrent-phone test.
  • SMS: none sent; confirmation texts and owner alerts are generated and stored, and sending them is planned, not implemented.
  • Fallback: the platform-failure fallback audio is built and covered by automated tests, but hasn't been exercised on a real call.
  • Speech recognition: it still changes words, including addresses and safety trigger words.
  • Infrastructure: a single shared server without backups or rate limiting, not a hardened production environment.

What this demonstrates for client work

The interesting part of a voice AI system isn't getting an LLM to talk. It's deciding where the LLM stops having authority. The same approach carries to any workflow where a model talks to customers but the business carries the consequences:

  • Don't let an LLM own decisions with irreversible consequences. Keep the side effects in code.
  • Give models constrained references, not opaque identifiers. Selecting is more reliable than copying, and the application keeps authority over what a selection means.
  • Treat vendor behaviour as an engineering constraint, not an assumption.
  • Test with real phone calls, which expose failures that simulations miss, such as dropped words and redirect timing.
  • Prefer a truthful "we'll confirm" to an optimistic "booked".

Appendix: real-call acceptance summary

20 PASS / 3 BLOCKED / 0 FAIL. 39 real calls from one tester, 19-22 September 2026.

What the evidence labels mean:

  • Early Phase 1 build: the first acceptance builds.
  • Re-verified: an earlier pass repeated with a fresh call on a later build.
  • Late Phase 1 build: after booking hardening, before slot references.
  • Current booking build: after slot references.
  • Judged unaffected: the slot-reference change does not touch that row's path.
#ScenarioExpected outcomeResultAttemptsEvidence generation
1Routine booking, new customerBookedPASS3 (1 FAIL)Current booking build
2Existing customer bookingBookedBLOCKED0Test setup would have overwritten a customer record from an earlier test call
3Price question onlyDetails capturedPASS2 (1 FAIL)Late Phase 1 build; judged unaffected
4Address in service area?Callback details capturedPASS1Late Phase 1 build; judged unaffected
5Outside service areaOut-of-area details capturedPASS3 (1 FAIL regression)Re-verified
6Service not offeredDeclined; details capturedPASS3 (2 FAIL)Early Phase 1 build; not re-verified
7Slot taken by a competing callerBooked after conflictBLOCKED0No second phone for a simultaneous call; covered by automated race tests
8After-hours booking by the AIBookedPASS4 (3 FAIL)Current booking build
9Burst pipe in hoursTransferred, answeredPASS3 (1 FAIL)Re-verified
10Burst pipe after hoursTransferred, answeredPASS1Early Phase 1 build; not re-run
11Suspected gas leakSafety script, call endsPASS4 (2 FAIL)Re-verified
12Carbon-monoxide symptomsSafety script, call endsPASS1Early Phase 1 build; not re-run
13Transfer unanswered, voicemail leftFailed transfer, details capturedPASS1Early Phase 1 build; not re-run
14Transfer unanswered, caller hangs upFailed transfer, missedPASS1Early Phase 1 build; not re-run
15Caller demands a person, in hoursTransferred, answeredPASS1Early Phase 1 build; not re-run
16Caller demands a person, after hoursCallback details capturedPASS2Re-verified
17Hang-up during greetingAbandoned early, missedPASS1Early Phase 1 build; not re-run
18Call drops mid-conversationDropped, missedPASS1Early Phase 1 build; not re-run
19Wrong numberLogged as wrong numberPASS2Re-verified
20Sales / spam callLogged as spamPASS2 (recorded deviations)Re-verified
21Withheld caller IDLogged as withheldBLOCKED0Carrier could not withhold caller ID
22Fast talker gives addressAddress read back; bookedPASS2Current booking build
23Caller goes silentDropped, missedPASS1Early Phase 1 build; not re-run. The platform did not end the call; the caller hung up

Facing a build like this?

← All case studies