Im Standard-Setup für AI-Voice-Agents passiert die LLM-Generierung live während des Calls. Das ist die offensichtliche Architektur, aber sie hat zwei Nachteile: Latenz pro Call (jedes Token wird im Moment gerechnet) und Kosten (Premium-Modelle rechnen für jeden Anruf, auch für unqualifizierte Leads).
Bei einem Outbound-Setup mit ein paar tausend Calls pro Woche habe ich auf ein anderes Pattern umgestellt. Skripte werden pro Lead vorab generiert, einmal, und liegen als JSON in der Datenbank. Beim Call greift der Agent darauf zu. Hier ist, wie das mit n8n und Supabase aussieht.
Warum Pre-Generation überhaupt#
Drei Effekte, die zusammen den Unterschied machen.
Erstens: Latenz beim Call-Start sinkt um 400 bis 600ms. Der Agent muss nicht warten, bis das LLM den ersten Satz formuliert hat. Er liest aus der DB.
Zweitens: Pro Call wird nicht mehr Sonnet oder GPT-4o gerufen, sondern Haiku oder Mini-Modelle. Die teuren Generierungen laufen einmal vorher und sind über alle Tries amortisiert.
Drittens: Skript-QA wird möglich. Der Operator kann generierte Skripte vor dem Send prüfen, anpassen, ablehnen. Live-Generierung erlaubt das nicht.
Es gibt einen Trade-off: bei Inbound-Calls (eingehende Anrufe von Leads) funktioniert Pre-Generation nicht, weil man nicht weiß, wer anruft. Da bleibt es beim Live-Setup. Aber bei Outbound, wo ich den Lead vorher kenne, ist Pre-Generation das bessere Pattern.
Der Workflow in n8n#
Trigger: ein Webhook, ausgelöst aus dem Dashboard wenn der Operator auf „Skripte generieren" klickt. Payload enthält Workspace-ID und eine Liste von Lead-IDs.
[Webhook]
↓
[Supabase: SELECT leads WHERE id IN (...)]
↓ (Schleife pro Lead)
[Supabase: SELECT enrichment_data, recent_activity, kampagnen_kontext]
↓
[Claude Sonnet: build research_brief]
↓
[Claude Haiku: generate voice_script JSON]
↓
[Supabase: UPDATE leads SET voice_script = ?]
[Webhook-Response: { generated: 47, failed: 3 }]
Pro Lead dauert das 4 bis 8 Sekunden, abhängig von Datenmenge und Modell-Latenz. Das ist okay für Batch-Jobs, weil der Operator den Klick ohnehin als asynchron behandeln kann.
Schema für leads.voice_script#
Das Skript muss strukturiert sein, nicht ein freier Text-Blob. Die Voice-Engine braucht klare Stages mit Fallbacks.
ALTER TABLE leads ADD COLUMN voice_script jsonb;
CREATE INDEX leads_voice_script_generated_at_idx
ON leads ((voice_script->>'generated_at'))
WHERE voice_script IS NOT NULL;Der Index hilft mir, Skripte zu finden, die älter als zwei Wochen sind und vor dem Call neu generiert werden sollten (weil sich der Lead-Status geändert haben kann).
Schema des JSON-Objekts:
{
"generated_at": "2026-05-09T08:30:00Z",
"model": "claude-haiku-4-5-20251001",
"opener": "Hi {{lead_first_name}}, hier ist {{sender_name}} von {{sender_company}}.",
"hook": "Wir haben gesehen, dass {{lead_company}} kürzlich {{recent_event}}. Da war eure Series-A-Runde dabei.",
"value_prop": "Wir helfen B2B-Sales-Teams wie eurem, ihre Outreach-Antwortraten in 4 Wochen zu verdoppeln.",
"discovery_questions": [
"Wie groß ist euer Sales-Team gerade?",
"Welches Tool nutzt ihr aktuell für die Outreach?"
],
"objection_handlers": {
"no_time": "Verstehe ich. Soll ich euch eine zweiminütige Email schicken statt jetzt zu reden?",
"not_interested": "Klar. Eine kurze Frage noch: was würde euch dazu bringen, das in 6 Monaten doch interessant zu finden?"
},
"close": "Möchtest du einen 15-min Call diese Woche, um konkret durchzugehen, was wir bei {{lead_company}} bewegen könnten?"
}Der Voice-Agent läuft im Live-Call durch diese Stages. Wenn der Lead etwas sagt, das nicht in den objection_handlers matcht, fällt der Agent auf einen kleinen Live-Call mit Haiku zurück, aber mit dem strukturierten Kontext aus dem Skript als System-Prompt.
Der n8n-Composer-Prompt#
Das ist die Stelle, an der die meiste Sorgfalt reingeht. Der Prompt muss:
- Konkrete Fakten aus der Enrichment-Daten extrahieren (nicht erfinden)
- Den Style-Guide der Persona einhalten
- Strikt JSON zurückgeben, das durch Zod-Schema-Parse geht
// In n8n Code Node, baut den Composer-Prompt
const lead = $('Supabase: lead row').first().json;
const enrich = $('Supabase: enrichment_data').first().json;
const persona = $('Supabase: persona row').first().json;
const composerPrompt = `
Du bist ein deutscher B2B-Sales-Skript-Schreiber.
Du erstellst ein Voice-Call-Skript für einen einzelnen Lead.
LEAD-DATEN:
- Name: ${lead.first_name} ${lead.last_name}
- Position: ${lead.role}
- Firma: ${lead.company}
- Kürzliche Aktivität: ${enrich.recent_signals?.join(', ') || 'keine spezifischen Signale'}
PERSONA (du sprichst als):
- Name: ${persona.name}
- Rolle: ${persona.role}
OUTPUT (NUR JSON, kein erklärender Text):
{
"opener": "...",
"hook": "<konkreter Bezug auf recent_signals oder Branche>",
"value_prop": "<1 Satz, ohne Buzzwords>",
"discovery_questions": ["...", "..."],
"objection_handlers": { "no_time": "...", "not_interested": "..." },
"close": "<konkreter Call-to-Action>"
}
REGELN:
- Du erfindest KEINE Fakten. Wenn keine recent_signals da sind, mache einen generischen Hook auf Branche oder Rolle.
- KEINE Em-Dashes als Satzklammer.
- KEINE generischen Floskeln. Konkrete Sprache.
- Sprache: Deutsch, du-Form, professionell aber nicht steif.
`;Validation pro Skript#
Bevor das Skript in der DB landet, geht es durch einen Zod-Parse:
const VoiceScriptSchema = z.object({
generated_at: z.string().datetime(),
model: z.string(),
opener: z.string().min(20).max(200),
hook: z.string().min(20).max(300),
value_prop: z.string().min(20).max(200),
discovery_questions: z.array(z.string()).min(1).max(5),
objection_handlers: z.record(z.string(), z.string()),
close: z.string().min(20).max(200),
});Wenn das Modell ein leeres hook-Feld liefert, wird der Lead als „failed" markiert und nicht in den Call-Pool aufgenommen. Manuelles Fixen kostet 30 Sekunden pro Lead, automatisches Failen verhindert peinliche Live-Calls mit halben Skripten.
Was ich nicht löse#
Skript-Aging. Ein Skript, das vor 14 Tagen generiert wurde, ist möglicherweise veraltet wenn der Lead in der Zwischenzeit den Job gewechselt hat. Aktuell regeneriere ich Skripte, die älter als 14 Tage sind, automatisch. Das ist eine grobe Heuristik, kein guter Algorithmus.
Multi-Language. Wenn der gleiche Workspace in DE und EN Calls macht, brauche ich pro Lead zwei Skript-Varianten. Aktuell ein einfaches Feld lead.language und der n8n-Workflow generiert die richtige Sprache. Eleganter wäre eine script_versions-Tabelle mit FK auf den Lead, dahin geht es bei mir als nächstes.
Wann Pre-Generation NICHT die richtige Wahl ist#
Wenn dein Setup wenige hochwertige Calls pro Woche macht (unter 50), ist Pre-Generation Overkill. Live-LLM-Calls reichen, der Operator-Fokus liegt eh auf Qualität pro Lead.
Pre-Generation lohnt sich ab dem Punkt, wo du nicht mehr jeden Lead manuell briefen kannst. Dann ist ein vorgeneriertes Skript mit Operator-Review-Toggle der Sweet Spot zwischen Skalierung und Kontrolle.