TL;DR A home-maintenance company in Milan takes every job request over WhatsApp. An AI agent reads the conversation and the photos, consults the price list and writes the reply; nothing is sent until an operator approves it. Two months in production: 50 approved drafts, 46% sent exactly as the model wrote them, 42% corrected by hand before sending. That 42% is precisely why the human step exists.

An AI agent drafts the quotes on WhatsApp. Nothing goes out until a human reads it.

Doinel Atanasiu
Doinel Atanasiu11 min read

A home-maintenance company in Milan takes every job request over WhatsApp. I built the system that handles them: an AI agent reads the conversation and the photos, consults the price list and writes the reply, which an operator approves or corrects before it is sent. It has been in production since 1 July 2026.

A human-in-the-loop AI agent is one whose output is a proposal, not an action: the model drafts, a person approves or rewrites, and nothing reaches the end customer without passing through that step. It trades speed for control, and only makes sense where being wrong costs more than being slow.

This page explains how it's built, decision by decision, with two months of production numbers behind each choice, including the choices that cost something. What changed for the business (time, cost, what a person still does) is in the article that accompanies this project instead.

The problem

Customers write the way people write on WhatsApp: "the kitchen tap is leaking", a crooked photo of a trap, a forty-second voice note, then three more messages before anyone has answered the first. Across two months in production the average was 7.9 inbound messages per conversation, against 4.5 outbound.

Before, the founder answered by hand. When a request got complicated he pasted the conversation into a model's browser interface (sometimes one, sometimes another), worked out an estimate with it, and pasted the answer back. From first message to quote took around half an hour. That is his estimate, not a measurement, but the number isn't the point.

The point is that the AI was already in the process, it was just outside the channel. Every step cost a copy-paste, the price list lived in the head of whoever was answering, and none of it left a trace anyone could look up.

Why every AI reply is a draft, not a send

The first decision shaped all the others: the model sends nothing. It produces text that lands in a review queue, and an operator decides whether to send it, rewrite it, or throw it away.

The reason is that the output here contains prices. A reply that misses the tone is not a problem; a wrong figure sent to a customer is a commercial commitment made by accident, and walking it back costs more than any time saved. The operating rule that came out of this is deliberately blanket rather than selective: every reply becomes a draft, full stop. Sorting out which ones are "safe enough" to skip review would itself be a judgment call, and that's exactly the kind of probabilistic decision this design keeps off the customer's phone.

Drafts live in a table separate from messages, and do not become a message until they are approved. Each draft carries multiple versions, and each version records its origin: generated by the model, or written by the operator. That field isn't there for the UI: it's there to make the model's output measurable, and it is the source of every number on this page.

Across 50 approved drafts:

  • 23 (46%) went out exactly as the model wrote them on the first attempt;
  • 29 (58%) were never rewritten by hand, counting those where the operator asked for a regeneration;
  • 21 (42%) were corrected by hand before being sent.

That 42% is the most important number here, and it is not a flaw to explain away. It is the justification for the whole architecture: at 2% I would have built a ceremonial review step, a button people press with their eyes closed. At 42%, that step kept twenty-one inaccurate messages away from a customer.

There is a less obvious consequence. The dashboard is not an approval queue: it is a full WhatsApp client, with delivery and read receipts, photos, video and replayable voice notes, and a box to type in. The operator can bypass the review flow entirely and answer directly, and does: of the 429 messages sent in the period, 50 were approved drafts. The rest are automatic consent messages and, mostly, conversations the operator chose to run by hand, typically with the more demanding customers.

I don't read that as missing coverage. A tool that insists on standing between the operator and every message gets switched off the first time someone needs to move fast.

Four messages in a row should get one reply

At 7.9 inbound messages for every 4.5 outbound, an agent that answers each incoming message produces absurd conversations: it answers the "hi", then the photo, then the "sorry, I meant the bathroom", each one blind to the next.

The fix is a debounce at the queue level, keyed by conversation, using Inngest. A debounce is a timer that restarts every time a new event arrives, so a burst collapses into one run instead of one run per event. Every inbound message registers a draft in a waiting state and restarts that timer; when it expires, generation runs once, with the whole sequence as context.

export const generateDraft = inngest.createFunction(
  {
    id: 'generate-draft',
    debounce: {
      key: 'event.data.conversationId',
      period: `${config.draftDebounceSeconds}s`
    }
  },
  { event: 'inbound.draft-generate' },
  async ({ event }) => generateFor(event.data.conversationId)
);

The window is configured per environment rather than fixed in code: 15 seconds in development, short enough that manual testing stays bearable, and 120 seconds in production, long enough to cover a real burst. It's a parameter you tune by looking at data, not one you settle in advance.

The cost of this choice is deliberate latency: the draft isn't ready the moment a message lands. I made that visible instead of hiding it: the dashboard shows a countdown of the seconds remaining before generation, so whoever is watching understands the system is waiting on purpose rather than stuck.

The company has to collect acceptance of its privacy policy before handling a request. The short path would be to hand the agent a tool and a line in the prompt: "if the customer hasn't accepted, ask them to".

I didn't, because a compliance obligation cannot rest on a probabilistic decision. The gate is a deterministic pre-check that runs before the agent is invoked, in ordinary code: with no consent on file, the agent is never called at all.

const consent = await getConsent(contactId);

if (consent === null || consent.status === 'rejected') {
  await sendConsentRequest(contactId); // fixed, pre-approved wording
  return;
}

if (consent.status === 'pending') {
  const intent = await classifyIntent(lastInboundMessage); // 'accept' | 'reject' | 'unclear'
  if (intent !== 'accept') {
    await handleNonAcceptance(contactId, intent);
    return;
  }
  await markAccepted(contactId);
}

return runDraftAgent(conversationId);

The only probabilistic part is deciding whether "fine", "ok go ahead" or "sure" means acceptance. A dedicated classifier does that: one call, on the single message, with no conversation history and no tools, returning one of three labels. It's a small job, so it runs on Claude Haiku, and when it answers "unclear", the system re-sends the request rather than guessing.

Those consent messages are the only ones the system sends autonomously anywhere in its surface, and they can be, because their wording is fixed and approved in advance: there is nothing to generate, only something to send at the right moment.

The model is a parameter, not a dependency

No agent imports a model. It receives one:

export async function draftAgent(model: LanguageModel, messages: Message[]) {
  return generateText({
    model,
    system: await loadActiveSystemPrompt(),
    messages,
    tools: { get_price_list, get_out_of_scope_jobs, get_todays_date }
  });
}

This looks like a style detail. It has two concrete consequences.

The first is that the provider binding lives in a single four-line file, so changing it is a contained operation rather than a rewrite of the logic.

The second, and the more important one, is that every action can run on the model it deserves. Writing a quote from a conversation, a price list and a set of photos is a hard job, and goes to Claude Sonnet. Deciding whether a message means yes or no is an easy job, and goes to Claude Haiku. The bill shows it: of $30 in API spend over two months, $26 belongs to Claude Sonnet and $4 to Claude Haiku, even though Haiku is invoked far more often. Using one model for everything would have multiplied those $4 by the price gap between the two, and the gating latency, which sits in front of every conversation, would have been Sonnet's.

Changing prices shouldn't require a developer

The price list isn't in the code. It isn't a table with rigid columns either: it's a versioned markdown document the company edits itself from the admin panel, which the agent fetches through a tool when it's time to quote. Same treatment for the jobs the company doesn't take on as a matter of policy, for the privacy policy text, and for the agent's system prompt.

Every save creates a new version. One version is active; earlier ones remain and can be reactivated; the active one cannot be deleted. If a price-list edit makes the replies worse, rolling back is a click rather than a deploy.

Free markdown instead of a schema has a downside: nothing validates that the document makes sense, and a badly written price list produces badly written quotes. I accepted it because the reader of that text is a language model, not a parser, and because the real constraint was that the document stay editable by someone who knows the prices, not by someone who knows the code.

The net effect is that reply quality is governed by four documents the client owns and edits, without going through me.

Half the requests arrive with an image: 175 media files in two months, nearly two per conversation. A leaking tap is something you understand by looking at it.

Every file goes through a three-stage pipeline: download from the channel, hash verification, upload to Supabase Storage. Each stage's status is persisted, along with its error. That decision was made with the day something breaks in production in mind: without those statuses a missing file is a mystery; with them, it's a row telling you which step it stopped at.

Images don't reach the model as base64, but as Supabase Storage's expiring signed URLs. The reason is cost: a base64 image inside the context is paid for in tokens, every time the conversation is regenerated. Communication is server to server, so a long-lived signed link exposes nothing to the end customer, and the context stays light as the conversation grows.

The stated limit: in this version the model analyses text and images. Voice notes and documents are received, stored and replayable by the operator, but do not enter the agent's context.

What I accepted losing

Real-time updates. The dashboard receives no push: it polls the server at a fixed interval. On serverless infrastructure a persistent connection doesn't hold up without dragging dedicated infrastructure along with it, and for a panel used by one person at a time, polling costs less than that infrastructure would. The price is a few seconds of lag and queries that fire when nothing has changed.

The conversation lifecycle. This version has no open/closed distinction and no structured conversion into a booked job: when work is agreed, the operator closes the conversation by hand. Those states only start paying for themselves once there's enough history to analyse, and in July there wasn't any.

The 42% edit rate. I didn't solve it, I made it visible. Bringing it down is work on the prompt and on the documents feeding it. Now there's a number to tell whether a change improves it or makes it worse, which is something the first two months made knowable for the first time.

The numbers, as of 1 September 2026

MetricValue
In production since1 July 2026
Conversations95
Messages received / sent755 / 429
Media received175
Approved drafts50
Sent unchanged on the first attempt23 (46%)
Corrected by hand before sending21 (42%)
Regenerated at least once7 (14%)
AI API spend (1 Jul to 1 Sep)$30 ($26 generation, $4 classification)

Rows don't sum to 100% by design: "regenerated" overlaps with the two rows above it, since a redone draft still ends up either sent unchanged or corrected by hand once it's done.

About $0.32 per conversation, and that total includes development and testing traffic, so the steady-state cost is lower.

The stack

Next.js and React for the dashboard and the API, tRPC and Drizzle over PostgreSQL, Inngest for everything that must not keep the webhook waiting, Supabase Storage for media, WhatsApp Business API as the channel. Claude (Sonnet for drafting, Haiku for classification) sits behind an abstraction layer that keeps replacing it a local operation.

The thread running through every decision on this page is the same one: where a mistake costs little, the model decides; where it costs a commitment made to a customer, a person always does. That principle holds for any automation that touches money or promises, not just a WhatsApp quote.

FAQ

Common questions and answers
Does the AI reply to customers on its own?
No. Every message the model produces starts as a draft and is only sent after an operator has read and approved it. The single exception is the privacy-consent request and its reminders, which go out automatically because their wording is fixed and decided in advance by the company.
What happens if the model gets a price wrong?
The message never leaves. Every reply becomes a draft awaiting approval, not just the ones with numbers in them, and across two months in production 42% of drafts were corrected by hand before being sent. Mistakes are still possible; they just stop on the operator's screen instead of the customer's phone.
Does the end customer have to install anything?
No. Customers message the company's WhatsApp Business number the way they would message any contact: no app, no account, no link to open. Everything I built lives on the company's side, as a dashboard used by whoever answers.
Can a different model be used?
The architecture is built for it, though this project has always run on Claude. Agents receive the model as a parameter instead of importing it, so the provider binding lives in a single four-line file: switching provider means rewriting that file, not the agent logic. Switching model for one specific action means passing a different one at the call site.
What does running an agent like this on WhatsApp cost?
On this project the model API spend was roughly $30 over two months, across 95 conversations and 755 inbound messages, about $0.32 per conversation. 87% of that goes to Claude Sonnet, which writes the drafts, 13% to Claude Haiku, which classifies privacy-consent replies.
Interested in a system like this?
Reach out on LinkedIn, or send me an email - I read both.