TL;DR Un'azienda di manutenzione domestica di Milano riceve le richieste su WhatsApp. Un agente AI legge la conversazione e le foto, consulta il listino e prepara la risposta; nessun messaggio parte senza che un operatore lo approvi. In due mesi di produzione: 50 bozze approvate, il 46% inviate esattamente come le aveva scritte il modello, il 42% corrette a mano prima dell'invio. Proprio quel 42% è la ragione per cui il passaggio umano esiste.
Un agente AI prepara i preventivi su WhatsApp. Nessuno parte senza che un umano lo legga.
Un'azienda di manutenzione domestica che opera a Milano riceve tutte le richieste di intervento su WhatsApp. Ho costruito il sistema che le gestisce: un agente AI legge la conversazione e le foto, consulta il listino e prepara la risposta, che un operatore approva o corregge prima dell'invio. È in produzione dal 1° luglio 2026.
Un agente AI human-in-the-loop è un agente il cui output è una proposta, non un'azione: il modello scrive la bozza, una persona approva o riscrive, e niente raggiunge il cliente finale senza passare da quel punto. Sacrifica velocità in cambio di controllo, e ha senso solo dove sbagliare costa più che andare piano.
Questa pagina spiega come è fatto, decisione per decisione, con i numeri dei primi due mesi di produzione a conferma di ogni scelta, comprese quelle che sono costate qualcosa. Cosa sia cambiato per l'azienda (tempi, costi, cosa continua a fare una persona) lo racconto invece nell'articolo che accompagna questo progetto.
Il problema
I clienti scrivono come si scrive su WhatsApp: «il rubinetto della cucina perde», una foto storta di un sifone, un vocale di quaranta secondi, e poi altri tre messaggi prima che qualcuno abbia risposto al primo. Nei due mesi di produzione la media è stata di 7,9 messaggi ricevuti per conversazione, contro 4,5 inviati.
Prima, il founder rispondeva a mano. Quando la richiesta si faceva complicata copiava la conversazione dentro l'interfaccia browser di un modello (a volte l'uno, a volte l'altro), si faceva aiutare a formulare la stima, e ricopiava indietro la risposta. Dal primo messaggio al preventivo passava circa mezz'ora: è la sua stima, non una misura, ma il punto non è il numero.
Il punto è che l'AI era già nel processo, solo che stava fuori dal canale. Ogni passaggio costava un copia-incolla, il listino viveva nella testa di chi rispondeva, e niente di tutto ciò lasciava una traccia consultabile.
Perché ogni risposta dell'AI è una bozza, non un invio
La prima decisione è anche quella che ha determinato tutte le altre: il modello non invia nulla. Produce testo che finisce in una coda di revisione, e un operatore decide se inviarlo, riscriverlo o buttarlo.
La ragione è che qui l'output contiene prezzi. Una risposta sbagliata su un tono non è un problema; una cifra sbagliata mandata a un cliente è un impegno commerciale preso per errore, e recuperarlo costa più di qualsiasi tempo risparmiato. La regola operativa che ne è uscita è volutamente grossolana invece che selettiva: ogni risposta diventa una bozza, punto. Decidere quali sono "abbastanza sicure" da saltare la revisione sarebbe a sua volta un giudizio probabilistico, esattamente il tipo di decisione che questo sistema tiene lontana dal telefono del cliente.
Le bozze vivono in una tabella separata dai messaggi, e non diventano un messaggio finché non vengono approvate. Ogni bozza ha più versioni, e ogni versione porta la sua origine: generata dal modello, oppure scritta dall'operatore. Quel campo non serve alla UI: serve a rendere misurabile la qualità di ciò che il modello produce, e infatti è la fonte dei numeri di questa pagina.
Su 50 bozze approvate:
- 23 (46%) sono partite esattamente come le aveva scritte il modello al primo tentativo;
- 29 (58%) non sono mai state riscritte a mano, contando anche quelle per cui l'operatore ha chiesto una rigenerazione;
- 21 (42%) sono state corrette a mano prima di partire.
Quel 42% è il numero più importante della pagina, e non è un difetto da spiegare. È la giustificazione dell'intera architettura: se fosse stato il 2%, avrei costruito un passaggio di revisione cerimoniale, un bottone che si preme a occhi chiusi. Al 42% quel passaggio ha impedito a ventuno messaggi imprecisi di raggiungere un cliente.
C'è un'ultima conseguenza, meno ovvia. La dashboard non è una coda di approvazione: è un client WhatsApp completo, con stati di consegna e lettura, foto, video e vocali riascoltabili, e un campo per scrivere. L'operatore può ignorare del tutto il flusso di revisione e rispondere di suo pugno, e lo fa: dei 429 messaggi inviati nel periodo, le bozze approvate sono 50. Il resto sono messaggi automatici di consenso privacy e, soprattutto, conversazioni che l'operatore ha scelto di condurre a mano, tipicamente con i clienti più esigenti.
Non lo considero un buco di copertura. Uno strumento che pretende di stare in mezzo a ogni messaggio si fa disattivare la prima volta che serve andare veloci.
Chi scrive quattro messaggi di fila deve ricevere una risposta sola
Con 7,9 messaggi ricevuti ogni 4,5 inviati, un agente che risponde a ogni messaggio in arrivo produce conversazioni assurde: risponde al «ciao», poi alla foto, poi al «scusa, intendevo il bagno», ognuna ignorando quella dopo.
La soluzione è un debounce a livello di coda, con chiave sulla conversazione, usando Inngest. Un debounce è un timer che riparte a ogni nuovo evento, così una raffica si riduce a una sola esecuzione invece di una per evento. Ogni messaggio in arrivo registra una bozza in stato «in attesa» e rilancia quel timer; quando scade, la generazione parte una volta sola con l'intera sequenza come contesto.
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)
);
La finestra è configurabile per ambiente, non fissa nel codice: 15 secondi in sviluppo, abbastanza pochi da non rendere impossibile il test manuale, e 120 secondi in produzione, quanto serve a coprire un burst reale. È un parametro che si tara guardando i dati, non una costante da decidere a tavolino.
Il costo di questa scelta è latenza deliberata: la bozza non è pronta appena arriva il messaggio. L'ho reso visibile invece che nasconderlo: la dashboard mostra un conto alla rovescia con i secondi che mancano alla generazione, così chi guarda lo schermo capisce che il sistema sta aspettando apposta, e non che si è bloccato.
Il consenso privacy non passa dall'agente
L'azienda deve raccogliere l'accettazione della privacy policy prima di trattare la richiesta. La strada breve sarebbe dare all'agente un tool e un'istruzione nel prompt: «se il cliente non ha accettato, chiediglielo».
Non l'ho fatto, e la ragione è che un adempimento non può dipendere da una decisione probabilistica. Il gating è un pre-check deterministico che gira prima di invocare l'agente, in codice ordinario: se non c'è consenso, l'agente non viene nemmeno chiamato.
const consent = await getConsent(contactId);
if (consent === null || consent.status === 'rejected') {
await sendConsentRequest(contactId); // messaggio a testo fisso
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);
L'unico pezzo probabilistico è capire se «va bene», «ok ci sto» o «sì certo» significhino accettazione. Quello lo fa un classificatore dedicato: una chiamata sola, sul singolo messaggio, senza storia della conversazione e senza tool, che restituisce una di tre etichette. È un compito piccolo, quindi gira su Claude Haiku, e se risponde «non chiaro», il sistema rimanda la richiesta invece di tirare a indovinare.
Questi messaggi di consenso sono gli unici che il sistema invia in autonomia in tutta la sua superficie, e possono esserlo perché il loro testo è fisso e deciso in anticipo dall'azienda: non c'è nulla da generare, solo da spedire al momento giusto.
Il modello è un parametro, non una dipendenza
Nessun agent importa un modello. Lo riceve:
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 }
});
}
Sembra un dettaglio di stile. Ha invece due conseguenze concrete.
La prima è che l'aggancio al fornitore vive in un unico file di quattro righe, quindi cambiarlo è un'operazione circoscritta e non una riscrittura della logica.
La seconda, e la più importante, è che ogni azione può girare sul modello che merita. Scrivere un preventivo a partire da una conversazione, un listino e delle foto è un compito difficile, e va a Claude Sonnet. Decidere se un messaggio significa sì o no è un compito facile, e va a Claude Haiku. Il conto lo dimostra: dei 30 $ di consumo API in due mesi, 26 sono di Claude Sonnet e 4 di Claude Haiku, che pure viene invocato molto più spesso. Se avessi usato lo stesso modello ovunque, quei 4 $ sarebbero stati moltiplicati per il divario di prezzo tra i due, e la latenza del gating, che sta davanti a ogni conversazione, sarebbe stata quella di Sonnet.
Chi cambia i prezzi non deve chiamare uno sviluppatore
Il listino non è nel codice. Non è nemmeno una tabella con colonne rigide: è un documento markdown versionato, che l'azienda modifica da sola dal pannello di amministrazione, e che l'agente recupera con un tool al momento di preparare il preventivo. Stesso trattamento per gli interventi che l'azienda non esegue per policy, per il testo della privacy policy e per il system prompt dell'agente.
Ogni salvataggio crea una nuova versione. Una sola è attiva; le precedenti restano e possono essere riattivate; la versione attiva non è cancellabile. Se una modifica al listino peggiora le risposte, si torna indietro in un click invece che con un deploy.
Markdown libero invece di uno schema è una scelta con un rovescio: nessuno valida che il documento sia sensato, e un listino scritto male produce preventivi scritti male. L'ho accettata perché il destinatario di quel testo è un modello linguistico, non un parser, e perché il vincolo vero era che il documento restasse modificabile da chi conosce i prezzi, non da chi conosce il codice.
L'effetto complessivo è che la qualità delle risposte è governata da quattro documenti che il cliente possiede e modifica, senza passare da me.
Le foto vanno al modello come link, non come byte
Metà delle richieste arriva con un'immagine: 175 media in due mesi, quasi due per conversazione. Un rubinetto che perde si capisce guardandolo.
Ogni file passa da una pipeline in tre fasi: scaricamento dal canale, verifica dell'hash, caricamento su Supabase Storage. Lo stato di ciascuna fase viene salvato, con il relativo errore. È una decisione presa pensando al giorno in cui qualcosa si rompe in produzione: senza quegli stati, un media mancante è un mistero; con quegli stati, è una riga che dice a quale passo si è fermato.
Al modello le immagini non arrivano come base64, ma come URL firmati a scadenza di Supabase Storage. La ragione è il costo: un'immagine in base64 dentro il contesto si paga a token, ogni volta che la conversazione viene rigenerata. La comunicazione è da server a server, quindi un link a scadenza lunga non espone nulla al cliente finale, e il contesto resta leggero anche quando la conversazione si allunga.
Il limite dichiarato: in questa versione il modello analizza testo e immagini. Vocali e documenti vengono ricevuti, salvati e riascoltabili dall'operatore, ma non entrano nel contesto dell'agente.
Cosa ho accettato di perdere
L'aggiornamento in tempo reale. La dashboard non riceve push: interroga il server a intervalli fissi. Su un'infrastruttura serverless una connessione persistente non regge senza tirarsi dietro infrastruttura dedicata, e per un pannello usato da una persona alla volta il polling costa meno di quanto costerebbe quella infrastruttura. Il prezzo è qualche secondo di ritardo e query che partono anche quando non serve.
Il ciclo di vita della conversazione. In questa versione non esiste la distinzione tra conversazione aperta e chiusa, né la conversione in commessa strutturata: quando il lavoro è concordato, l'operatore chiude a mano. Sono stati che aggiungono valore solo quando c'è abbastanza storico da analizzare, e a luglio non c'era.
Il 42% di bozze corrette. Non l'ho risolto, l'ho reso visibile. Ridurlo è lavoro sul prompt e sui documenti che lo alimentano. Ora c'è il dato per capire se una modifica lo migliora o lo peggiora, cosa che prima di questi due mesi non era possibile sapere.
I numeri, al 1° settembre 2026
| Dato | Valore |
|---|---|
| In produzione da | 1 luglio 2026 |
| Conversazioni | 95 |
| Messaggi ricevuti / inviati | 755 / 429 |
| Media ricevuti | 175 |
| Bozze approvate | 50 |
| Inviate senza modifiche al primo tentativo | 23 (46%) |
| Corrette a mano prima dell'invio | 21 (42%) |
| Rigenerate almeno una volta | 7 (14%) |
| Consumo API AI (1 lug - 1 set) | 30 $ (26 $ generazione, 4 $ classificazione) |
Le righe non sommano al 100% per costruzione: "rigenerate" si sovrappone alle due righe sopra, perché una bozza rigenerata finisce comunque tra quelle inviate senza modifiche o corrette a mano, una volta completata.
Circa 0,32 $ per conversazione: il totale comprende anche il traffico di sviluppo e collaudo, quindi il costo a regime è più basso di così.
Lo stack
Next.js e React per dashboard e API, tRPC e Drizzle su PostgreSQL, Inngest per tutto ciò che non deve far attendere il webhook, Supabase Storage per i media, WhatsApp Business API come canale. Claude (Sonnet per la generazione, Haiku per la classificazione) è agganciato tramite un livello di astrazione che ne rende la sostituzione un'operazione locale.
Il filo che tiene insieme le decisioni di questa pagina è sempre lo stesso: dove sbagliare costa poco, decide il modello; dove costa un impegno preso con un cliente, decide sempre una persona. È un principio che regge per qualunque automazione tocchi soldi o promesse, non solo per un preventivo su WhatsApp.