Skip to content
Muhammad Siraj
All work

AI front desk for HVAC

hvacX

A chat and email front desk that books HVAC visits with zero double bookings and answers questions with 99% correctness on its evaluation set.

The hvacX demo page, “Try the front desk”, with the MD Cold Services chat open beside a website enquiry form.

Problem

An HVAC contractor in South Florida fields the same requests all day. Customers ask questions, book technician visits, reschedule and cancel them, by chat and by email. The office needs to follow every booking, thank customers when a job is done and remind them the day before. An AI can carry that load, but a front desk that books real appointments cannot be allowed to guess.

Constraints

What made it hard.

  • Bookings, emails and Slack posts have real consequences, so none of them can depend on free-form model output.
  • Two customers can ask for the same slot at the same moment, and both cannot get it.
  • Answers must come only from the company’s own information, and the system must refuse when it does not know.
  • Customers write whatever they like, including prompt-injection attempts and genuine emergencies such as a gas smell.
  • It had to run the same on a laptop as on a server, with no public URL, Slack app or Gmail push setup.

Solution

hvacX is four independent LangGraph runs that share one booking core and one knowledge base. A customer can ask a question or book a visit in the website chat or by email. The office follows every booking in Slack. Finished jobs reported in Slack get a thank-you email, and every visit gets a reminder the day before. The runs never call each other. They share state only through PostgreSQL.

The design rule is “LLM classifies, code acts.” The model returns a structured analysis of each message. Everything that changes the world is ordinary, tested code, and it reports success only after the database confirms it.

How hvacX handles a message
  • Model output
  • Deterministic code
  • External system or data

Run A and B: chat and email

  1. System: Chat message, or email polled every 90 sGmail MCP server
  2. Code: Emergency guardKeywords, before any model call
  3. Model output: Classify the turnIntent and details as a Pydantic object
  4. Code: Collect details, offer up to 3 open times
  5. Code: Book after a clear yesOne transaction, row locks
  6. System: Confirmation email and Slack postTemplates, after the write

Questions

  1. Model output: Rewrite into standalone queriesUp to 3 for multi-part questions
  2. Code: Search ChromaDBScore ≥ 0.20, max 2 chunks per source
  3. Model output: Answer only from passages, or say so

Run C: finished jobs, every 12 h

  1. System: “case 123 status done” in SlackStaff write in their own words
  2. Code: Thank-you email, once
  3. Code: Case closed

Run D: reminders, daily at 6 PM

  1. Code: Visits tomorrow, not yet reminded
  2. System: Reminder emailFailed send alerts Slack

Every status change goes through one transition table with an audit row, and the model never sets a status. Simplified diagram.

hvacX office view of a booked case: a visit on Friday, October 16 from 2:00 to 4:00 PM for AC repair, with confirmation emailed and the office told in Slack at 1:42 PM, above a timeline of the booking and a later email asking to move it. The customer's phone, email and address are blacked out.(open full size)
The office view of one case. The booking, the confirmation email and the Slack post are recorded by code, then the email thread continues on the same case. Contact details redacted.
An email conversation on one case: the customer asks what services are offered, the AI desk answers from the knowledge base, the customer asks to book an inspection and its price, and the AI desk asks for a phone number and address.(open full size)
A real email thread. Answers come from the knowledge base, and booking details are collected one step at a time.

Email has its own rules, enforced by a code-only poller before the graph sees anything. Changes are accepted only from the booking’s own address. A change within two hours of the visit goes to a person. An unclear email gets a fixed acknowledgement, a Slack alert, and silence from the AI on that case. Every email is stored by its Gmail message ID, so it is handled once, and staff opening the inbox cannot hide one from the agent.

The office schedule for the next seven days: five two-hour slots per day from 8:00 AM to 4:00 PM, past slots marked as passed and the rest marked open, in Miami time.(open full size)
The schedule the AI books into. Slots in the past are never offered.
The office activity feed, newest first: a customer wrote through the website form and got an answer by email, twice, on case 4.(open full size)
Every event across chat, email and the website form, newest first.

Phase 1 is complete. Next are an embeddable chat widget for the company’s website and a WhatsApp channel that reuses the same agent and tools.

Technology and why

LangGraph
Each of the four runs is an explicit graph with a Postgres checkpointer, so a visitor or an email thread can pick up where it stopped days later.
Pydantic
The classifier returns a typed analysis (intent, details, chosen option) that code validates before anything happens.
PostgreSQL
The system of record. Row locks and partial unique indexes inside one transaction make a double booking impossible.
ChromaDB
Stores the 118 knowledge-base chunks, searched by cosine similarity with a tuned minimum score.
OpenAI
gpt-4.1-mini classifies and words replies, gpt-4.1 chunks and reviews the knowledge base, and text-embedding-3-large embeds it.
MCP
Gmail and Slack are reached through Klavis MCP servers, and only the seven tools the system needs are loaded.
APScheduler
Runs the inbox poll every 90 seconds, the Slack status check every 12 hours and reminders daily at 6 PM, inside the app.
pytest
65 offline tests with a fake model, database and MCP cover routing, booking, guardrails and every run.

Hard problems

Keeping the model away from critical actions

The model only classifies a message and words the answer. Code performs every booking, status change, email and Slack post. Confirmation texts are templates filled in after the database write succeeds, so the model can never confirm a booking that does not exist.

Identity comes from code, never from the model

Tools read the case, the customer and the recipient address from graph state that code sets. A fooled model can only send a bad reply. It cannot act on another customer’s case or email an address it chose.

Guaranteeing zero double bookings

Chat and email call the same booking core. Each booking runs in one transaction with row locks and partial unique indexes, one active booking per slot and one per case.

Retrieval worth trusting

An LLM writes self-contained chunks, then code checks that every price, phone number, URL, percentage and licence number in the source survives unchanged and that every line is covered. Retrieval keeps at most two chunks per source, and nothing above the threshold means no answer.

Hostile and urgent messages

A code-side guard catches emergency words in English and Roman Urdu before any model call and sends the approved safety message plus an urgent Slack alert. Customer text is treated as data, and more than five AI replies per thread per hour hands the case to a person.

Results

Answer correctness (LLM judge)
99%
Faithfulness to retrieved passages
98%
Mean reciprocal rank (MRR)
0.846
Expected source retrieved in top-k
100%
Correct refusals on unanswerable questions
100%
Invented facts on unanswerable questions
0%
Median (p50) latency, 4.49s at p95
2.96s
Double bookings, guaranteed by row locks
0

Model and evaluation

Model
OpenAI gpt-4.1-mini for classification and replies (Groq supported), gpt-4.1 for chunking and review, text-embedding-3-large for embeddings.
Evaluation
51 labelled questions, 40 answerable and 11 that must be refused, run end to end through the same path as the chat. Routing 98%, must-include facts in passages 98%, 0% false “not sure” answers.
Latency
2.96 s at p50 and 4.49 s at p95 on the evaluation set.