Case study · Leguas AI · 2026 Stack: n8n (self-hosted) · Google Gemini · WhatsApp Cloud API · Chatwoot · HubSpot · Zoho Books · MCP
Easy Gardens is a landscaping and garden supply business in Dubai: natural and artificial grass, palms, irrigation, hardscaping. Customers arrive almost entirely through WhatsApp, in English, Arabic, Urdu and Hindi, at all hours.
Full disclosure up front: this is not an arms-length client. I ran operations for this business before I became an engineer, and it is where I first learned what a missed lead actually costs. That history is why the system described here optimises for revenue behaviour rather than technical elegance. The numbers come from the company’s real systems and are shared with its consent.
Two things were leaking money.
First, pricing deflection. The original assistant answered every price question with “the team will call you” and eagerly offered free site visits. In the UAE a site visit is a real cost: crew time, a vehicle, half a day. Roughly 8 out of 10 visits produced no job. The business was paying to not answer the customer’s question.
Second, response capacity. WhatsApp is the shop counter, and the counter was staffed by humans with working hours and other jobs to do.
Baseline (measured before the migration to the Meta Cloud API and Chatwoot):
Mira: an LLM sales agent that answers the WhatsApp line end to end. It qualifies leads, analyses photos of the site, quotes real price ranges from a live catalog, drafts estimates in Zoho Books for human review, files tickets for the team, and escalates complaints and payment conversations to a person. It runs as one n8n master workflow (about 79 nodes) plus tool sub-workflows, self-hosted in Docker.
The behaviour is a deliberate sales design, not a chatbot default: understand the problem, ask for photos (photos replace most site visits), get location and size, then quote a range from the catalog. A formal quote is offered before a visit is ever considered, and the bot never confirms visit times; it files a request for the human team. Complaints and “let’s proceed” moments hard-pause the bot and hand the thread to a person.
WhatsApp Cloud API webhook → debounce and media handling (Gemini image/video/audio analysis) → Chatwoot contact and thread sync → a cheap Gemini gatekeeper classifying CUSTOMER / VENDOR / JOB_SEEKER / SPAM behind a deterministic parse gate → HubSpot contact sync → the main Gemini agent with memory and six tools → reply splitting → WhatsApp send and Chatwoot logging.
The six agent tools: live price catalog lookup, draft-estimate creation in Zoho Books (via an MCP client inside n8n), ticket creation, visit-request filing, availability check, and human escalation. Estimates are always drafts; a human reviews and sends every price document.
| Decision | Options | Choice and why |
|---|---|---|
| Where prices live | In the prompt/KB vs a queryable tool | A live catalog table the agent must call. Prompt-embedded prices went stale and produced wrong quotes; the KB was stripped of every AED figure. One source of truth per fact class |
| Catalog access | LLM writes search filters vs return everything | Return all ~280 active rows in one call. The platform’s filter operator was case-sensitive and silently returned zero rows; teaching an LLM undocumented operator semantics is fragile. Simplicity won |
| Pricing safety | Trust the model vs hard rules | If an item is not in the catalog, no figure may be quoted, not even a range. This rule exists because the model once invented a crane fee |
| Estimates | Bot sends prices vs human sends | Bot creates Zoho drafts only. This one decision made every pricing error recoverable and allowed live iteration on a system talking to real customers |
| Behaviour vs guarantees | Everything in the prompt vs gates in the graph | Voice and sales-ladder rules live in the prompt (cheap to iterate); spam mutes, bot-pause and floors live in the workflow (cannot be sweet-talked) |
| Classification failure mode | Fail-closed vs fail-open | Parse gate defaults to CUSTOMER on any failure. Losing a spam message to the agent is cheap; losing a lead is not |
The defects that reached customers came from infrastructure, data and concurrency, almost never from the model.
A silent platform upgrade hung the pipeline, twice. An unpinned Docker image upgraded n8n on restart, moving Code-node execution to an external task runner where cross-node data access can block indefinitely. Messages stalled for minutes. The fix was architectural: the offending node was rewritten from code to declarative expressions, which bypasses the entire hang class, and the image version was pinned. A 180-second execution timeout was added afterwards, and it is the reason the root cause was ever caught: cancelled runs on this platform save no trace, so the timeout converts “it hangs until someone kills it” into “it dies with a body you can autopsy.”
One misclassification silently muted a live lead. A test message mentioning a pending receipt was classified VENDOR, the conversation got labelled, and a gate then dropped every later message from that customer, including “I need a quotation for grass,” while reporting success. The gate now hard-mutes only spam, drop events leave a visible note for the team, and payment language can never classify as VENDOR.
A “hallucinated” price that mostly wasn’t. The agent quoted palm prices we believed did not exist. A full catalog dump showed the figure came from a stale legacy row; an earlier table read had been silently truncated at 60 of 342 rows. Only one fee was actually invented. Lesson: verify your own observability before blaming the model, and treat stale tool data as seriously as no data, because the model will faithfully quote whatever the tool returns.
Concurrency is the worst UX bug, and it is not the model. Rapid-fire texts each spawn their own execution, producing out-of-order and contradictory replies. A per-conversation queue is the top open item; everything else in this project was fixable with prompts, data or node swaps, but this one needs an architectural change.
There is no benchmark for “sound like a friendly Dubai landscaper and never invent a price,” so the evaluation is live and adversarial: full replayed customer scenarios graded against execution traces (every WhatsApp bubble mapped to its run, gatekeeper verdict, tool calls and node timings), component isolation tests before anything touches the live agent, and a scored regression harness with a golden dataset built from real conversations that runs before any change ships. Current harness scores: price given without deflection 100%, correct escalation 100%, warm tone 100%, no premature visit offer 87% and under active tuning. The harness stops before the send step, so evaluation never messages a real customer.
The eval harness and its golden dataset are public: github.com/xaphor/mira-whatsapp-agent-eval
Measured from the full Chatwoot conversation log, November 2025 to July 2026.
Pin infrastructure versions from day one instead of learning it from a production hang. Build the per-conversation message queue before launch rather than after meeting the burst problem live. And wire the catalog sync as a scheduled diff from the start; manual reconciliation of 370 items is a one-time job that should never have needed doing twice.
“We were losing hours every week creating estimates and invoices for each customer by hand and answering every inquiry one by one. We had a CRM from day one, but recording every ticket and following the trail cost us a lot of useful hours. After the AI Brain went live, it lifted the business in ways we could feel. Customers get answers on time, and we are not typing CRM entries all day anymore. It has become a genuine companion in our business. Zaffar has been supportive all along: we have regular review sessions where he monitors quality and traffic and keeps improving the system. A genuinely beautiful system, and our business is on steroids.”