All4Beauty: ein autonomer Buchungsagent für Beauty-Salons über Telefon, WhatsApp und Chat
Projekt-Übersicht
All4Beauty ist ein Agentensystem, das für Friseur- und Beauty-Salons Termine vereinbart. Kundinnen und Kunden rufen an, schreiben per WhatsApp oder nutzen einen Web-Chat; auf der anderen Seite spricht in allen drei Fällen derselbe Agent. Er erkennt, ob jemand buchen oder nur etwas wissen will, klärt Leistung, Mitarbeiter, Datum und Uhrzeit, fragt Name und Telefonnummer ab, fasst alles zusammen und bucht erst nach ausdrücklicher Zustimmung. Die Verfügbarkeit kommt je nach Salon aus einem eigenen Kalender oder aus den Salon-Systemen Shore und Belbo. Technisch ist das Ganze ein NestJS-Backend mit einem eigenen, OpenAI-kompatiblen chat/completions-Endpunkt, hinter dem mehrere Agent-Versionen liegen – von einer LangChain-Tool-Calling-Schleife bis zu einer LangGraph-Zustandsmaschine. Dazu kommen eine Nachbereitung jeder Sitzung und ein Testsystem, in dem ein zweites LLM den Kunden spielt.
Ausgangslage
Terminvergabe in einem Salon ist kleinteilige Arbeit, die mitten in den eigentlichen Betrieb fällt. Das Telefon klingelt, während jemand gerade Haare schneidet; WhatsApp-Nachrichten bleiben bis zum Feierabend liegen. Jede Anfrage folgt dabei fast immer demselben Muster: Welche Leistung? Bei wem? Wann ist etwas frei? Auf welchen Namen?
Ein klassischer Online-Buchungskalender löst nur einen Teil davon. Er setzt voraus, dass der Kunde selbst durch Formulare klickt, und er hilft nicht am Telefon. Ein einfacher Chatbot mit festen Dialogpfaden scheitert wiederum an der Art, wie Menschen tatsächlich fragen: „Habt ihr nächste Woche abends noch was für Strähnchen bei der Kollegin von letztem Mal?“ enthält Leistung, Zeitraum und Mitarbeiterwunsch in einem Satz.
Dazu kam die Systemlandschaft: Salons arbeiten mit unterschiedlicher Software. Ein Agent, der nur gegen einen eigenen Kalender bucht, wäre für Salons mit bestehendem Kassensystem wertlos. Gesucht war also ein Agent, der frei formulierte Anfragen versteht, aber gegen echte, verbindliche Kalenderdaten arbeitet – über mehrere Kanäle und mehrere Backend-Systeme hinweg.
Architektur
Alle Kanäle münden in denselben Kern. Der Telefonkanal läuft über Vapi.ai: Vapi übernimmt Spracherkennung und Sprachsynthese und ruft für die eigentliche Antwort meinen Endpunkt als „Custom LLM“ auf. WhatsApp-Nachrichten kommen über Twilio oder die Meta-Schnittstelle herein und werden intern an dieselbe Agent-Instanz weitergegeben.
flowchart LR
V["Telefon über Vapi.ai"] --> E["OpenAI-kompatibler Endpoint chat/completions"]
C["Web-Chat und Test-Agenten"] --> E
W["WhatsApp über Twilio oder Meta"] --> WS["WhatsApp-AI-Service"]
E --> R["ModelRegistry: Instanz je Modell und API-Key"]
WS --> R
R --> A["Agent hairdresser-v5: Tool-Calling-Schleife mit GPT-4o"]
A --> T["LangChain-Tools mit Validierung"]
T --> D["Datenquelle je Salon"]
D --> D1["Eigener Kalender"]
D --> D2["Belbo"]
D --> D3["Shore"]
A --> S["Session-Persistenz"]
S --> P["Nachbereitung: Zusammenfassung und Mail an den Salon"]
Jeder Salon ist ein Projekt mit eigenem API-Key. Über diesen Schlüssel findet die ModelRegistry die passende Konfiguration – Sprache, Öffnungszeiten, Basis-Anweisungen, Integrations-Provider – und hält pro Kombination aus Modell und API-Key eine eigene Agent-Instanz vor. Die Mandantentrennung beginnt also nicht in der Datenbank, sondern schon bei der Frage, welche Agent-Instanz eine Anfrage überhaupt zu sehen bekommt.
Technische Umsetzung
Ein Endpunkt, mehrere Agent-Versionen
Der Endpunkt /llms/chat/completions spricht das OpenAI-Format, inklusive Server-Sent Events, wenn stream: true gesetzt ist. Dadurch kann Vapi ihn wie ein normales Sprachmodell ansprechen. Welcher Agent antwortet, steuert der Modellname. Die Registry kennt hairdresser-v1 bis hairdresser-v5 sowie hairdresser-v4-langgraph; v1 bis v3 fallen inzwischen auf die v4-Implementierung zurück. Zusätzlich kann der Modellname einen Kanal-Suffix tragen:
// Kanal steckt im Modellnamen, z. B. "hairdresser-v5:whatsapp"
const modelParts = request.model.split(':');
metadata.modelTags = modelParts.slice(1);
const cleanModel = modelParts[0];
for (const identifier of ['whatsapp', 'chat', 'voice']) {
if (metadata.modelTags.includes(identifier)) {
metadata.agentType = identifier as AgentTypesEnum;
break;
}
}Der Kanal entscheidet über den Ton. Am Telefon gilt: keine Aufzählungen, Jahreszahlen nicht aussprechen, Zahlen und Preise ausgeschrieben, bei unplausiblen Eingaben nachfragen statt raten – das sind typische Fehlerbilder einer Spracherkennung. Im Chat sind kurze, formatierte Antworten erlaubt. Diese Anweisungen liegen in einer eigenen Klasse BaseInstructions, getrennt nach Kanal und Sprache (Deutsch und Englisch), und werden mit den salonspezifischen Anweisungen aus der Datenbank kombiniert.
Die Tool-Calling-Schleife
Der Standardpfad ist ein klassischer Agent-Loop auf Basis von LangChain.js und GPT-4o. Das Modell bekommt die Tools gebunden, und die Schleife läuft so lange, bis eine Antwort ohne Tool-Aufruf kommt. Jeder Tool-Aufruf und jedes Ergebnis landet im Sitzungsverlauf, damit die nächste Runde – und die spätere Nachbereitung – den vollständigen Kontext hat. Fehler eines Tools brechen den Dialog nicht ab, sondern gehen als ToolMessage an das Modell zurück.
Der Agent arbeitet mit fünf Tools: GetAllServices, GetEmployeesForServices, GetAvailability, CreateBooking und GetServiceInfos. Für die Verfügbarkeit liefert GetAvailability die freien Zeiten kompakt und lesbar zurück („Fr., 24.10.2026 14:30“) und Termine an anderen Tagen als „Alternativdatum“ markiert. So kann der Agent von sich aus Ausweichtermine anbieten, wenn der Wunschtag voll ist.
Validierung im Tool, nicht im Prompt
Der Systemprompt beschreibt den Buchungsprozess als Checkliste mit neun Schritten: Leistung klären, Mitarbeiter ermitteln, Datum erfragen, Verfügbarkeit prüfen, Zeit wählen lassen, Name und Telefonnummer erfassen, zusammenfassen, explizit bestätigen lassen, buchen. Ein Prompt ist aber keine Garantie. Deshalb prüft CreateBooking alles noch einmal selbst und gibt bei Verstößen eine Fehlermeldung zurück, die das Modell versteht und mit der es beim Kunden nachfragen kann:
if (!date || !date.includes('T')) {
return { success: false, message: 'FEHLER: Das Datum muss eine Uhrzeit enthalten! ...' };
}
if (!customerPhone || customerPhone.trim() === '' || customerPhone.trim() === 'undefined') {
return { success: false, message: 'Die Telefonnummer wurde nicht angegeben. ...' };
}
if (!this.isPhoneNumberValid(customerPhone)) {
return { success: false, message: 'Die Telefonnummer ist nicht gültig. ...' };
}
// Mitarbeiter muss existieren und ALLE gewählten Leistungen anbieten
if (!employee.serviceIds.includes(serviceId)) { /* ... */ }
if (!this.isDateValid(dateObj, true)) {
return { success: false, message: 'Buchungen für vergangene Termine sind nicht möglich. ...' };
}Dass ein Wert wie "undefined" als Zeichenkette abgefangen wird, ist kein Zufall: Genau so etwas schreibt ein Modell gelegentlich in ein Pflichtfeld, wenn es die Nummer noch gar nicht hat. Ebenso wandeln die Zod-Schemas der Tools IDs grundsätzlich in Strings um, weil das Modell sie mal als Zahl, mal als Text übergibt.
Kontext: Was das Modell vorab weiß
Damit der Agent nicht für jede Kleinigkeit ein Tool aufrufen muss, steht das Stammwissen des Salons direkt im Systemprompt: alle Leistungen mit Dauer, Preis (oder „Preis auf Anfrage“) und ID, dazu, wer welche Leistung anbietet, sowie die Öffnungszeiten. Datum und Uhrzeit werden bei jeder Anfrage frisch eingesetzt, damit relative Angaben wie „nächsten Dienstag“ korrekt aufgelöst werden. Im Telefonkanal kommt die Nummer des Anrufers hinzu, damit der Agent fragen kann, ob er diese übernehmen soll.
Diese Stammdaten sind pro API-Key im Speicher gecacht. Ein Hintergrundjob prüft alle 15 Sekunden, ob sich in der Projekttabelle last_data_change geändert hat, und verwirft dann die Instanzen des betroffenen Salons. Ändert ein Salon eine Leistung im Admin-Bereich, arbeitet der Agent kurz danach mit den neuen Daten.
Datenquellen: eigener Kalender, Belbo, Shore
Hinter dem Agenten steht eine einheitliche Schnittstelle IHairdresserDataSource mit vier Methoden: Leistungen, Mitarbeiter, freie Zeitfenster und Buchung anlegen. Es gibt drei Implementierungen:
- Eigener Kalender (Supabase): Freie Slots werden in einem 30-Minuten-Raster erzeugt und gegen bestehende Buchungen des Mitarbeiters auf Überschneidung geprüft, unter Berücksichtigung der Gesamtdauer aller gewählten Leistungen.
- Belbo: Verfügbarkeiten kommen aus der Belbo-API. Für das Buchen gibt es pro Salon ein konfigurierbares Ziel: direkt im Partnersystem, als Buchungsanfrage per E-Mail an den Salon oder im eigenen Kalender. Optional arbeitet die Quelle mit synchronisierten Stammdaten und übersetzt interne IDs in die externen IDs von Belbo.
- Shore: Verfügbarkeiten werden über die Shore-API gelesen. Die Buchung geht als Buchungsanfrage per E-Mail an den Salon; die direkte Buchung im Shore-System ist als nächster Ausbauschritt vorbereitet.
Telefonie-Details: Auflegen und Weiterverbinden
Ein Sprachmodell kann nicht auflegen. Deshalb arbeitet der Agent mit Steuer-Markern: Ist das Gespräch beendet, hängt er <ENDCALL> an, soll an einen Menschen weiterverbunden werden, <TRANSFERCALL>. Der Controller entfernt die Marker aus dem Text, bevor er an Vapi geht, und löst die Aktion über die Control-URL des Anrufs aus. Beim Auflegen muss die Verabschiedung erst fertig gesprochen sein:
// Wartezeit aus der Länge der letzten Nachricht (ca. 150 Wörter pro Minute)
const baseDelay = Math.ceil((wordCount / 150) * 60 * 1000);
const calculatedDelay = Math.min(Math.max(baseDelay + 2000, 8000), 20000);
setTimeout(async () => {
const ended = await this.vapiService.endCall(controlUrl);
}, calculatedDelay);Ob weiterverbunden wird, legt jeder Salon selbst fest – etwa bei Wunsch nach Umbuchung oder Stornierung oder wenn der Anrufer ausdrücklich einen Menschen sprechen will. Die Regeln dafür werden nur dann in den Prompt geschrieben, wenn der Salon sie aktiviert hat. Nach Gesprächsende liefert Vapi einen Bericht, aus dem Dauer und Kosten des Anrufs gespeichert werden.
Für WhatsApp legt das Backend pro Projekt ein Twilio-Subkonto und einen WhatsApp-Sender an; alternativ ist der Embedded-Signup-Flow von Meta angebunden. Eingehende Webhooks von Twilio werden über die Signatur geprüft. Die Sitzungs-ID setzt sich aus Projekt und Absendernummer zusammen, sodass ein Kunde, der am nächsten Tag weiterschreibt, im selben Verlauf landet.
Sitzungen und Nachbereitung
Der Verlauf einer Sitzung lebt zunächst im Speicher und wird nach jeder Antwort asynchron in die Datenbank geschrieben – über setImmediate, damit die Persistenz die Antwortzeit nicht verlängert. Gespeichert werden auch Tool-Aufrufe, Tool-Ergebnisse und deren Laufzeit sowie strukturierte Metadaten wie gewählte Leistungen, Mitarbeiter und abgeschlossene Buchungen.
Ein separater Post-Processor erstellt aus einer abgeschlossenen Sitzung mit GPT-4o (Temperatur 0,3) eine Zusammenfassung in genau fünf Sätzen: Anliegen, besprochene Leistungen, ausgetauschte Informationen, durchgeführte Aktionen, Ergebnis oder offene Punkte. Hat der Salon es eingestellt, geht diese Zusammenfassung per E-Mail an die hinterlegte Adresse. Scheitert die Generierung, wird ein fester Hinweistext gespeichert statt eines leeren Feldes.
Die LangGraph-Variante
Parallel zur Tool-Schleife habe ich eine Variante als LangGraph-Zustandsmaschine entworfen: neun Knoten von intent_recognition über service_selection, employee_selection, date_selection, time_selection, customer_data_collection und booking_confirmation bis create_booking, dazu provide_information für reine Auskünfte. Bedingte Kanten führen nur weiter, wenn der jeweilige Zustand gefüllt ist. Diese Variante habe ich als Alternative prototypisch umgesetzt; der Standardpfad ist die flexiblere Tool-Schleife.
Qualitätssicherung
Einen Buchungsagenten von Hand durchzutesten heißt, dieselben Gespräche immer wieder zu führen. Deshalb gibt es im Admin-Bereich LLM-Test-Agenten: simulierte Kunden mit Briefing („Du willst einen Herrenhaarschnitt am Freitagnachmittag …“), Startnachricht und einem überprüfbaren Ziel. Ein Testlauf spielt den Dialog zwischen zwei Modellen durch:
// Perspektivwechsel: Für den simulierten Kunden ist der Salon-Agent der "user"
messages.push({ role: 'system', content: this.document.briefing });
for (const m of this.messages) {
if (m.role === 'assistant') messages.push({ role: 'user', content: m.content });
if (m.role === 'user') messages.push({ role: 'assistant', content: m.content });
}Der Runner ruft abwechselnd den echten Endpunkt mit dem gewählten Modell und Kanal und den Test-Agenten (GPT-4o-mini) auf, standardmäßig bis zu zehn Runden, mit Stopp-Knopf und gemessener Antwortzeit. Am Ende kann ein Bewerter-Aufruf (GPT-4o-mini, Temperatur 0, JSON-Modus) entscheiden, ob das Ziel erreicht wurde, und begründet das. Weil die Testläufe über denselben Endpunkt gehen, entstehen dabei ganz normale Sitzungen, die sich im Verlauf inklusive aller Tool-Aufrufe nachvollziehen lassen. So lassen sich Prompt- oder Modelländerungen gegen dieselben Szenarien laufen lassen, statt sich auf ein Gefühl aus drei Testanrufen zu verlassen.
Ergebnis
Entstanden ist ein mandantenfähiges Agentensystem, das über einen einzigen, OpenAI-kompatiblen Endpunkt drei Kanäle bedient und gegen drei verschiedene Kalender-Backends arbeitet. Der Agent führt den kompletten Buchungsdialog bis zur verbindlichen Buchung oder Buchungsanfrage, beantwortet Fragen zu Leistungen und Preisen, legt am Telefon selbst auf oder verbindet weiter und hinterlässt für jede Sitzung einen nachvollziehbaren Verlauf mit Zusammenfassung. Er übernimmt damit Arbeitsschritte, die vorher manuell am Telefon und im Terminbuch erledigt wurden. Neue Agent-Versionen lassen sich über den Modellnamen parallel betreiben und mit den Test-Agenten gegeneinander prüfen.
Was ich dabei gelernt habe
- Die harten Regeln gehören ins Tool. Die Checkliste im Prompt sorgt für einen guten Gesprächsablauf, aber erst die Prüfungen in
CreateBookingverhindern Buchungen ohne Uhrzeit, ohne Telefonnummer oder in der Vergangenheit. Eine verständliche Fehlermeldung an das Modell ist dabei mehr wert als eine Exception. - Eine starre Zustandsmaschine passt schlecht zu freien Gesprächen. Die LangGraph-Variante ist übersichtlich, verlangt aber, dass jeder Zustand aus der Nutzereingabe sauber befüllt wird. Kunden liefern Leistung, Wunschtermin und Mitarbeiter jedoch in beliebiger Reihenfolge und oft in einem Satz. Die Tool-Schleife mit einer Checkliste im Prompt, die bereits bekannte Schritte überspringen darf, bildet das direkter ab. Deshalb habe ich den LangGraph-Pfad nicht weiter ausgebaut.
- Prompt und Tool-Liste gehören zusammen. Sobald der Prompt Tools beim Namen nennt, ist er Teil der Tool-Schnittstelle. Jede Änderung an den Tools muss deshalb auch im Prompt nachgezogen werden – und regelmäßige Gesprächstests sind der Weg, Abweichungen zwischen beiden früh zu bemerken.
- Telefonie hat eigene Regeln. Jahreszahlen, Preise und Uhrzeiten müssen sprechbar formuliert sein, Transkriptionsfehler sind normal, und selbst das Auflegen braucht eine Wartezeit, die sich an der Länge des letzten Satzes orientiert.
- Test-Agenten lohnen sich früh. Ein zweites LLM als simulierter Kunde ist schnell gebaut und deckt Fehler auf, die beim eigenen Testen nicht auftreten, weil man selbst zu „brav“ fragt.
Technologien
- TypeScript, NestJS (Backend), Angular (Admin-Oberfläche), Tailwind CSS
- LangChain.js mit Tool Calling, LangGraph (Prototyp), Zod für Tool-Schemas
- OpenAI GPT-4o (Agent, Nachbereitung), GPT-4o-mini (Test-Agenten und Bewertung)
- Vapi.ai als Telefonie- und Sprachschicht mit eigenem Custom-LLM-Endpunkt
- Twilio und Meta-Schnittstelle für WhatsApp
- Supabase (PostgreSQL) für Projekte, Kalender, Sitzungen und Testfälle
- REST-Anbindungen an Shore und Belbo
- Winston-Logging für Tool-Aufrufe und Sitzungsverläufe