Cover bild 2

KI-Telefonassistent selbst hosten: OpenAI Realtime API mit Fritz!Box, Asterisk & n8n auf der Synology NAS

Was du in diesem Tutorial baust, ist kein Anrufbeantworter. Es ist ein KI-Telefonassistent, der Anrufe vollautomatisch entgegennimmt, ein strukturiertes Gespräch führt – nach Name, Anliegen und Dringlichkeit fragt – und dir danach eine fertige E-Mail-Zusammenfassung schickt. Alles in natürlicher Sprache, auf Deutsch.

Das Ganze läuft auf deiner Synology NAS zuhause, ohne Cloud-Abhängigkeiten für die Infrastruktur. Als Telefonanlage dient die Fritz!Box 7590, als Telefonie-Server Asterisk im Docker-Container, als KI-Gehirn die OpenAI Realtime API. Die Automatisierung übernimmt n8n.

Klingt komplex? An einigen Stellen ist es das auch, diese Anleitung führt dich aber Schritt für Schritt durch. Mit SSH-Grundkenntnissen und etwas Geduld ist das Ergebnis nachbaubar.

Um diese Anleitung verständlicher zu machen, begleiten dich drei Personen:
Die typischen Büro-Charaktere: Die kompetente IT-Kollegin, der selbsternannte Experte und der ehrliche Anfänger. Diese drei Perspektiven helfen dir, typische Stolperfallen zu erkennen.

Tanja ist die IT-Expertin. Sie weiß, wie es funktioniert, erklärt geduldig und strukturiert und lässt sich von schlechten Ratschlägen nicht aus der Ruhe bringen. Wenn du eine Frage hast, hat Tanja die Antwort.
Bernd ist der selbsternannte „Experte“, der alles besser weiß – und meistens falsch liegt. Seine Abkürzungen und sein Halbwissen führen regelmäßig zu Problemen. Er steht für alle gefährlichen Mythen und schlechten Praktiken, die du vermeiden solltest.
Ulf ist der Lernende, genau wie du. Er stellt die Fragen, die dir im Kopf herumschwirren, und braucht manchmal einen Vergleich aus dem Alltag, um IT zu verstehen. Wenn Ulf etwas nicht versteht, ist das völlig in Ordnung, dafür ist Tanja da.

„Und… Action!“

Donnerstagabend. Büro. Ulf starrt auf sein Handy.
Ulf: „Schon wieder eine verpasste Anruf-Benachrichtigung. Keine Nachricht. Keine Nummer. Nix.“
Bernd ohne aufzuschauen: „Ruf zurück“,
Ulf: „Ich kenn die Nummer nicht. Und wenn ich zurückruf, ist besetzt. Oder niemand geht ran. Oder — — –„
Bernd: „Ich hab das gelöst, Anrufbeantworter. Ganz klassisch. ‚Hinterlassen Sie eine Nachricht nach dem Piepton.‘ Fertig.“
Tanja legt ihren Stift hin. „Und wie viele Leute hinterlassen heutzutage noch Nachrichten auf einem Anrufbeantworter?“
Bernd überlegt. „Na… Oma.“
Tanja: „Genau.“

1 Technische Beschreibung

1.1 Was macht dieses Projekt?

Ulf fragt: „Also die KI geht ans Telefon und redet mit dem Anrufer?“,
Tanja: „Genau, sie begrüßt ihn, fragt nach seinem Namen, hört sich das Anliegen an, klärt wie dringend es ist und verabschiedet sich. Alles auf Deutsch, alles in natürlicher Sprache.“
Ulf: „Und dann?“
Tanja: „Und dann bekommst du eine E-Mail mit dem kompletten Gesprächsverlauf. Wer hat angerufen, was wollte die Person, wie dringend war es.“
Bernd lehnt sich vor: „Ich hätte da einfach einen billigen Anrufbeantworter hingestellt.“
Tanja: „Hattest du. Der hat nichts aufgenommen, weil der Piepton zu leise war.“
Bernd sagt nichts mehr.

Der KI-Assistent übernimmt Anrufe auf deiner Festnetznummer vollautomatisch. Er begrüßt den Anrufer freundlich, fragt nach Name und Anliegen, klärt die Dringlichkeit und verabschiedet sich in natürlichem Deutsch, ohne roboterhafte Pausen. Wenige Sekunden nach dem Auflegen landet eine vollständige Zusammenfassung in deinem Posteingang.

1.2 Warum diese Architektur?

Ulf: „Moment, kann die Fritz!Box nicht einfach direkt mit OpenAI reden? Warum brauchen wir drei Systeme dazwischen?“
Tanja: „Gute Frage. Die Fritz!Box spricht SIP – das ist das klassische Telefonie-Protokoll. OpenAI spricht WebSocket – das ist ein modernes Streaming-Protokoll. Die beiden verstehen sich nicht direkt. Wir brauchen einen Übersetzer.“
Ulf: „Asterisk?“
Tanja: „Asterisk ist der Übersetzer auf der Telefonie-Seite. Und das Node.js-Relay ist die Brücke zu OpenAI. Zusammen bilden sie den Verbindungskanal.“
Ulf: „Das ist wie beim Fußball, die Fritz!Box ist der Torwart, der den Ball abschlägt. Asterisk ist der defensive Mittelfeldspieler, der ihn annimmt. Und das Relay ist der Spielmacher, der ihn zu OpenAI weiterleitet.“
Kurze Stille.
Tanja: „Das ist, überraschend präzise.“

Die Fritz!Box ist ein reines Telefon-Gateway – sie versteht kein WebSocket, das OpenAI für die Realtime API benötigt. Asterisk übernimmt daher die Rolle des Vermittlers: Er nimmt das klassische SIP/RTP-Telefonsignal entgegen und reicht es an das Node.js-Relay weiter. Das Relay öffnet seinerseits eine WebSocket-Verbindung zu OpenAI und streamt das Audio bidirektional.
Der vollständige Signalweg:

Telefon → FritzBox (SIP) → Asterisk (Docker) → [ARI/Node.js Relay] → WebSocket → api.openai.com:443

OpenAI übernimmt dabei die komplette Gesprächsintelligenz: Spracherkennung, KI-Antworten und Text-to-Speech in einem einzigen Modell mit sehr niedriger Latenz. Nach dem Gespräch sendet das Relay das Transkript per Webhook an n8n, das daraus eine formatierte E-Mail erstellt.

1.3 Technologie-Stack

KomponenteRolle
Fritz!Box 7590Telefon-Gateway: nimmt Festnetzanruf entgegen, leitet per SIP weiter
Asterisk 20 (Docker)Telefonie-Server: SIP-Registrierung, Anrufsteuerung via ARI
Node.js Relay (Docker)Brücke: wandelt RTP-Audio in WebSocket-Stream für OpenAI um
OpenAI Realtime APIKI-Gehirn: Spracherkennung, Dialog, Text-to-Speech in Echtzeit
n8n (Docker)Automatisierung: empfängt Webhook, erstellt und versendet E-Mail
Synology NASHost: läuft alle Docker-Container, immer eingeschaltet

1.4 Voraussetzungen

Bevor du loslegst – ein ehrlicher Check. Du brauchst:
Hardware:

  • Synology NAS mit Intel-CPU (z. B. DS1621xs+, DS1621+, DS923+, DS720+ oder vergleichbar) – ARM-Prozessoren sind für Docker-Workloads wie diesen zu schwach
  • Mindestens 4 GB RAM auf der NAS (8 GB empfohlen, wenn n8n parallel läuft)
  • DSM 7.0 oder neuer
  • Fritz!Box mit aktiver Festnetznummer (getestet mit Fritz!Box 7590)
    Software & Dienste:
  • Docker bzw. Container Manager auf der Synology bereits eingerichtet
  • n8n läuft bereits als Docker-Container
  • OpenAI-Account mit hinterlegter Zahlungsmethode und API-Key
  • Optional: Cloudflare-Tunnel für externen Zugriff auf n8n-Webhooks
    Kenntnisse:
  • SSH-Grundkenntnisse – also: Befehle in die Konsole tippen können
  • Grundlegendes Verständnis von Docker und compose.yaml
  • Keine Programmierkenntnisse nötig – du bearbeitest vorhandene Konfigurationsdateien

1.5 Kosten

Ulf: „Was kostet das Ganze?
Bernd: „Ich hab schon ein ChatGPT-Plus-Abo – zählt das?“
Tanja: „Leider nein, das Plus-Abo ist für die Chat-Oberfläche. Die Realtime API läuft über die Entwicklerplattform. Die hat eine eigene Abrechnung. Kein Abo, kein Monatspreis. Du zahlst minutengenau, genau das was du verbrauchst.“

ModellKosten pro Minute (ca.)
gpt-4o-realtime-preview~$0,12–0,24

Tanja: „Ein typisches Gespräch dauert ein bis drei Minuten. Das sind $0,12 bis $0,75 pro Anruf. Mit $10 Startguthaben kommst du für viele Wochen aus, wenn du das für gelegentliche Heimanrufe nutzt.“
Ulf: „Und wenn das Guthaben aufgebraucht ist?“.
Tanja: „Dann läuft nichts mehr. Deswegen setzt du in der OpenAI-Konsole einen Spending Alert und ein monatliches Kostenlimit – dann wirst du rechtzeitig benachrichtigt, bevor das Konto leer ist.“
Bernd: „Ich würde das Limit einfach weglassen. Kostet ja nicht viel.“
Tanja: „Wenn jemand deinen Webhook automatisiert aufruft, absichtlich oder aus Versehen, hast du am Monatsende eine unschöne Überraschung. Das Limit ist keine Bremse, es ist eine Sicherung.“

1.6 Ablauf im Überblick

Bevor wir bauen, die Vogelperspektive: So läuft ein kompletter Anruf durch das System.

  1. Anruf kommt auf der Fritz!Box-Festnetznummer an
  2. Fritz!Box leitet den Anruf per SIP an Asterisk weiter
  3. Asterisk nimmt den Anruf an und übergibt ihn an das Node.js-Relay (via ARI)
  4. Das Relay öffnet eine WebSocket-Verbindung zu wss://api.openai.com/v1/realtime und streamt Audio bidirektional
  5. OpenAI führt das komplette Gespräch mit dem Anrufer
  6. Nach Gesprächsende sendet das Relay das Transkript per HTTP POST an n8n
  7. n8n erstellt eine formatierte E-Mail mit Anrufernummer, Name, Anliegen, Dringlichkeit und vollständigem Gesprächsverlauf

2 Implementierung

2.1 Vorbereitung: OpenAI API-Key erzeugen

Ulf: „Wo bekomme ich diesen API-Key her? Ist das irgendwo in meinen Account-Einstellungen?“
Tanja: „Fast. Du brauchst die Entwicklerplattform. Das ist eine separate Seite von OpenAI. platform.openai.com, nicht chat.openai.com.“ Tanja tippt die Adresse. „Dort erstellst du den Key und lädst Guthaben auf.“
Ulf: „Und das ist dann mit meinem normalen Account verknüpft?“
Tanja: „Selbe E-Mail, aber separate Abrechnung. Was du dort verbrauchst, wird unabhängig von allem anderen abgerechnet.“

Die OpenAI Realtime API ist das KI-Gehirn deines Telefonassistenten. Sie ermöglicht Echtzeit-Sprachkonversationen: Spracherkennung, Antwortgenerierung und Text-to-Speech laufen in einem einzigen Modell mit sehr niedriger Latenz. Ohne API-Key läuft gar nichts.

Den Key bekommst du unter platform.openai.com/account/api-keys. Wichtig: Das ist die Entwicklerplattform – ein vorhandenes ChatGPT-Abo hilft hier nicht weiter.

  1. In deinem Projekt: API Keys → Create new secret key
  2. Benenne den Key sinnvoll, z. B. KI-Telefonassistent
  3. Kopiere den Key sofort und speichere ihn im Passwortmanager – Keys werden nur einmal vollständig angezeigt
  4. Gehe zu Billing → Add payment method
  5. Lade ein Startguthaben auf (z. B. $10 für erste Tests)

2.2 Vorbereitung: IP-Telefon in der Fritz!Box einrichten

Damit Asterisk Anrufe von der Fritz!Box empfangen kann, muss er sich dort wie ein normales Telefon anmelden. Wir richten dafür ein virtuelles IP-Telefon ein – die Fritz!Box behandelt es genauso wie ein echtes Schnurlostelefon, leitet Anrufe dorthin weiter und übergibt sie per SIP-Protokoll.

Ulf: „Warum ein virtuelles Telefon?“ Kann Asterisk sich nicht einfach direkt einklinken?“
Tanja: „Stell dir vor, du willst in ein Bürogebäude rein. Du brauchst einen Ausweis. Das virtuelle IP-Telefon ist der Ausweis von Asterisk bei der Fritz!Box.“

  1. Melde dich an der Benutzeroberfläche deiner Fritz!Box 7590 an (meisthttp://fritz.box)
  2. Navigiere zu Telefonie -> Telefoniegeräte
  3. Klicke auf Neues Gerät einrichten
  4. Wähle Telefon (mit oder ohne Anrufbeantworter) aus und klicke auf „Weiter“
  5. Wähle LAN/WLAN (IP-Telefon) aus und gib dem Kind einen Namen (z. B. Asterisk_KI)
  1. WICHTIG (Zugangsdaten): Im nächsten Schritt vergibst du einen Benutzernamen und ein Passwort. Notiere dir diese Daten gut!
    • Benutzername: KI_ASSISTANT_USER (muss mindestens 8 Zeichen haben)
    • Passwort: `DEIN_SICHERES_PASSWORT
    • Registrar (SIP-Server): fritz.box oder deine Fritz!Box-IP
  1. Wähle die Rufnummer aus, auf die der KI-Assistent reagieren soll – deine Festnetznummer
  2. Bestätige die Einrichtung (ggf. musst du dies per Telefon-Code oder Tastendruck an der Fritz!Box verifizieren).

2.3 Vorbereitung: Synology NAS

Tanja: „Bevor wir Container bauen, legen wir die Ordnerstruktur an. Das ist wie der Werkzeugschrank, bevor man anfängt zu schrauben. Wer den überspringt, sucht hinterher alles.“
Bernd hebt die Hand: „Ich fange immer direkt an. Hab keine Zeit für Vorbereitung.“
Tanja:„Du hast letzte Woche zwei Stunden gesucht, wo du die compose.yaml hingelegt hast.“
Bernd schweigt.

Wir legen jetzt die Ordnerstruktur und die .env-Datei an, die später alle API-Keys und Zugangsdaten sicher aufbewahrt.

  1. Erstelle folgende Verzeichnisse auf der NAS:
    • /volume1/docker/asterisk/
      • config/ – hier kommen später alle SIP- und Dialplan-Dateien rein
      • logs/ – damit wir sehen, was beim Telefonieren passiert
  2. Erstelle darin die Datei /volume1/docker/asterisk/.env
  3. Öffne auf deinem PC einen Texteditor (TextEdit, VS Code oder Notepad++) und füge folgenden Inhalt ein:
# OpenAI
OPENAI_API_KEY=DEIN_OPENAI_API_KEY

# Wird später gebraucht:
OPENAI_REALTIME_MODEL=gpt-realtime
N8N_WEBHOOK_BASE_URL=https://n8n.DEINE_DOMAIN.de
  1. Berechtigungen setzen: Rechtsklick auf den asterisk-Ordner -> Eigenschaften -> Berechtigungen -> Nur dein Admin-User soll lesenden Zugriff haben.

Warum .env? Weil du damit in der compose.yaml sauber auf die Zugangsdaten referenzieren kannst, ohne sie direkt in YAML-Dateien oder Screenshots sichtbar zu machen. Das ist Best Practice – nicht Paranoia.

2.4 Das richtige Modell wählen – Qualität vs. Kosten

Aktuell stehen drei Realtime-Modelle zur Verfügung:

ModellStatusAudio Input / 1M TokenAudio Output / 1M Tokenca. Kosten pro Anruf (3 min)
gpt-realtimeGA – produktionsreif$32$64~$0,12
gpt-realtime-miniGA – kostengünstig$10$20~$0,04
gpt-4o-realtime-previewPreview – Vorgänger$40$80~$0,15

Die Kosten pro Anruf sind Schätzwerte bei ca. 2 Minuten Nutzer-Audio und 1 Minute KI-Antwort. Längere Gespräche oder ausführliche System-Prompts erhöhen den Preis entsprechend.

Für dieses Projekt empfehle ich gpt-realtime. Es ist das erste allgemein verfügbare Realtime-Modell, wurde speziell für produktive Sprachagenten optimiert und bietet gegenüber gpt-4o-realtime-preview spürbar bessere Sprachqualität, zuverlässigeres Instruction Following – und liegt dabei 20 % günstiger. Besonders praktisch: gpt-realtime unterstützt neben WebRTC und WebSocket auch native SIP-Verbindungen – also genau das Protokoll, das Asterisk und die Fritz!Box sprechen.
Wer Kosten sparen möchte und keine hochkomplexen Gespräche erwartet, kann gpt-realtime-mini ausprobieren. Bei gelegentlichen Heimanrufen ist der Qualitätsunterschied im Alltag kaum spürbar – und die Kosten pro Anruf sinken auf einen Bruchteil.

3 Asterisk als Docker-Container einrichten und mit der Fritz!Box verbinden

Ulf: „Also jetzt bauen wir Asterisk?“
Tanja: „Jetzt bauen wir Asterisk. Aber wir machen es in zwei Schritten. Erst konfigurieren, dann starten, dann testen. Erst wenn der Test grünes Licht gibt, machen wir weiter.“
Bernd: „Ich würde alles auf einmal starten. Spart Zeit.“
Tanja: „Und wenn dann was nicht funktioniert, weißt du nicht wo.“
Bernd: „Stimmt, ist mir schon passiert.“

Asterisk läuft als Docker-Container auf deiner Synology. Er übernimmt eine zentrale Rolle: Er registriert sich bei der Fritz!Box wie ein IP-Telefon, nimmt eingehende Anrufe entgegen und reicht sie an die OpenAI-Brücke weiter. Alles Folgende baut auf dieser Verbindung auf – deshalb testen wir sie explizit, bevor wir weitermachen.

3.1 Docker-Setup: Die compose.yaml

Die compose.yaml ist dein Bauplan. Anstatt kryptische Befehle in die Konsole zu tippen, definierst du hier einmalig, welche Dateien in den Container gemountet werden und wie er sich verhalten soll.

  1. Öffne einen Texteditor (z.B. TextEDIT, VS Code oder Notepad++) und erstelle eine neue Datei namens compose.yaml
  2. Kopiere den folgenden Block hinein:

Hinweis: In meinem Fall läuft n8n bereits, daher fügen wir hier nur den Asterisk-Teil hinzu. Wie man n8n installiert siehe n8n selber hosten – n8n Installation auf Synology NAS (Diskstation DS1621+) optional mit PostgreSQL

version: "3.8"

services:
  asterisk:
    image: andrius/asterisk:20
    container_name: asterisk_ki
    restart: unless-stopped
    network_mode: "host"
    healthcheck:
      disable: true
    volumes:
      # === Config-Dateien (read-only) ===
      - /volume1/docker/asterisk/config/pjsip.conf:/etc/asterisk/pjsip.conf:ro
      - /volume1/docker/asterisk/config/extensions.conf:/etc/asterisk/extensions.conf:ro
      - /volume1/docker/asterisk/config/modules.conf:/etc/asterisk/modules.conf:ro
      - /volume1/docker/asterisk/config/logger.conf:/etc/asterisk/logger.conf:ro
      - /volume1/docker/asterisk/config/rtp.conf:/etc/asterisk/rtp.conf:ro
      - /volume1/docker/asterisk/config/ari.conf:/etc/asterisk/ari.conf:ro
      - /volume1/docker/asterisk/config/http.conf:/etc/asterisk/http.conf:ro
      # === Logs (read-write) ===
      - /volume1/docker/asterisk/logs:/var/log/asterisk:rw
    environment:
      - TZ=Europe/Berlin
      - ASTERISK_UID=0
      - ASTERISK_GID=0
  1. Kopiere die fertige Datei nach /volume1/docker/asterisk/

Warum network_mode: "host"?
Ulf zeigt auf die compose.yaml :„Moment, was bedeutet das hier network_mode: host? Klingt irgendwie gefährlich.“
Tanja: „Ist es nicht, aber die Erklärung braucht eine Minute.“
Bernd: „Ich kenn das, NAT. Hab ich mal bei uns im Büro eingerichtet. Hat nicht funktioniert.“
Tanja: „Genau das ist das Problem. Stell dir vor, der Docker-Container ist ein Mitarbeiter, der in einem abgeschlossenen Glaskasten sitzt. Er kann nach draußen telefonieren, aber wenn jemand zurückruft, landet der Anruf an der Glaskasten-Tür, und der Container hört nichts. Das nennt sich One-Way-Audio: du hörst die andere Seite, aber sie hört dich nicht. Oder umgekehrt.“
Ulf: „Das klingt wie beim Fußball wenn das Headset des Torwarts kaputt ist. Der Trainer brüllt, aber der Keeper hört nix.“
Tanja: „Perfekter Vergleich. Mit network_mode: host reißen wir den Glaskasten weg. Der Container verhält sich dann so, als wäre er direkt die DiskStation – gleiche IP, gleiche Ports, kein NAT dazwischen. Asterisk und die Fritz!Box können sich problemlos gegenseitig hören.“
Ulf: „Und das ist sicher?“
Tanja: „Für diesen Anwendungsfall: ja. Asterisk lauscht nur auf den Ports, die wir ihm erlauben. Der Kompromiss ist bewusst eingegangen.“
Bernd nickt langsam. „Ich hätte einfach alle Ports geöffnet.“
Tanja: „Das, wäre nicht sicher.“

Wichtig: Port 5060 muss frei sein. Mit network_mode: "host" kann Asterisk nur dann sauber SIP sprechen, wenn auf der DiskStation niemand sonst bereits auf Port 5060 (UDP/TCP) lauscht. Wenn der Port belegt ist, startet Asterisk zwar – aber die SIP-Registrierung schlägt still fehl, oder Calls brechen unerwartet ab.

Den RTP-Portbereich definieren wir nicht in der compose.yaml, sondern in der rtp.conf – das ist der richtige Ort dafür.

Nachtrag vom 23.08.2026: Der Container läuft als root

In der compose.yaml oben stehen zwei Zeilen, die dieser Beitrag bisher nicht erklärt hat: ASTERISK_UID=0 und ASTERISK_GID=0. Die Null ist kein Platzhalter – sie bedeutet root. Zusammen mit network_mode: "host" heißt das: Asterisk läuft mit den höchsten Rechten und direkt im Netz der DiskStation. Wer den Dienst übernimmt, übernimmt damit mehr als nur die Telefonanlage.

Das Host-Netz und die root-Rechte sind zwei verschiedene Dinge. Das Host-Netz ist oben erklärt und bleibt richtig: Ohne es kommen SIP und RTP nicht sauber durch, und Asterisk lauscht dabei nur auf den Ports, die wir ihm erlauben. Die root-Rechte dagegen sind Bequemlichkeit – mit UID 0 muss man sich um Schreibrechte auf /volume1/docker/asterisk/logs nicht kümmern. Das ist ein Kompromiss, und er war in dieser Anleitung bis heute nicht benannt. Einen Kompromiss, den der Leser nicht sieht, kann er auch nicht ablehnen.

Wie es zwei Wochen später aussieht. In der Anleitung zum KI-Wächter mit Home Assistant ist derselbe Container-Typ deutlich enger gefahren: user: "1000:1000" statt root, dazu no-new-privileges: true, cap_drop: ALL, read_only: true und eine Firewall-Kette an INPUT mit einem Test, der belegt, dass die Sperre wirklich greift. Dort sagt Bernd den Satz, der auch hierher gehört: „Bei mir läuft immer alles als root mit allen Rechten. Nie Probleme gehabt.“ – und Tanja antwortet: „Du hattest nur noch keine Probleme, die du bemerkt hast. Das ist ein Unterschied.“

Wenn du es enger haben willst: Setze ASTERISK_UID und ASTERISK_GID auf einen eigenen, unprivilegierten Benutzer und gib diesem Schreibrecht auf das Log-Verzeichnis. Danach unbedingt einmal wirklich anrufen und prüfen, ob Registrierung und Ton funktionieren – SIP verzeiht wenig, und ein Fehler zeigt sich erst im Gespräch, nicht beim Start des Containers.

3.2 Docker-Setup: Asterisk-Container-Konfiguration

Ulf: „Sechs Dateien? Für einen Telefonserver?“
Tanja: „Jede macht genau eine Sache. Stell dir vor, du richtest eine neue Werkstatt ein. Du hast einen Schrank für Werkzeuge, einen für Schrauben, einen für die Bedienungsanleitungen. Du könntest alles in eine einzige Schublade werfen.“
Bernd: „Mach ich so“
Ulf: „… aber dann weißt du hinterher nicht mehr, wo was ist, wenn etwas nicht funktioniert.“ Tanja zählt an den Fingern ab: „pjsip.conf: wie Asterisk mit der Fritz!Box redet. rtp.conf: welche Ports für Audio reserviert sind. extensions.conf: was bei einem eingehenden Anruf passiert. modules.conf: welche Asterisk-Funktionen geladen werden. logger.conf: was ins Log geschrieben wird. asterisk.conf: globale Grundeinstellungen.“
Ulf: „Also sechs Schubladen, jede beschriftet“,
Tanja: „Genau. Wenn später Audio nicht funktioniert, schaust du in rtp.conf. Wenn Asterisk sich nicht bei der Fritz!Box registriert, schaust du in pjsip.conf. Du musst nicht eine riesige Datei durchsuchen, sondern weißt sofort wo das Problem liegt.“
Bernd: „Ich hätte alles in eine Datei gepackt“.
Tanja: „Das weiß ich. Fang einfach oben an. Eine Datei nach der anderen.“

Wir erstellen im Verzeichnis /volume1/docker/asterisk/config/ diese sechs Dateien:

  1. pjsip.conf → SIP-Registrierung bei der Fritz!Box
  2. rtp.conf → RTP-Portbereich (Real-time Transport Protocol für Audiodaten)
  3. extensions.conf → Minimaler Dialplan (was passiert, wenn ein Anruf reinkommt)
  4. modules.conf → welche Asterisk-Module geladen werden
  5. logger.conf → welche Log-Nachrichten wo landen
  6. asterisk.conf → globale Asterisk-Einstellungen

3.2.1 pjsip.conf – SIP zur Fritz!Box

PJSIP ist der moderne SIP-Stack von Asterisk und wird von der Fritz!Box sauber unterstützt. Diese Datei definiert, wie Asterisk sich bei der Fritz!Box anmeldet und Anrufe entgegennimmt.
Datei: /volume1/docker/asterisk/config/pjsip.conf

[global]
type=global
user_agent=Asterisk PBX

; ===== UDP TRANSPORT =====
[transport-udp]
type=transport
protocol=udp
bind=0.0.0.0

; ===== FRITZBOX AUTHENTICATION =====
[fritzbox-auth]
type=auth
auth_type=userpass
username=KI_FRITZBOX_USER
password=DEIN_SICHERES_FRITZBOX_PASSWORT

; ===== OUTBOUND REGISTRATION =====
[fritzbox-reg]
type=registration
transport=transport-udp
outbound_auth=fritzbox-auth
server_uri=sip:fritz.box
client_uri=sip:KI_FRITZBOX_USER@fritz.box
retry_interval=60
expiration=3600

; ===== AOR (Address of Record) =====
[fritzbox-aor]
type=aor
max_contacts=1
remove_existing=yes

; ===== IDENTIFY - Erkennt FritzBox =====
[fritzbox-identify]
type=identify
endpoint=fritzbox
match=DEINE_FRITZBOX_IP

; ===== ENDPOINT (für ein- UND ausgehende Anrufe) =====
[fritzbox]
type=endpoint
transport=transport-udp
context=from-fritzbox
aors=fritzbox-aor
auth=fritzbox-auth
outbound_auth=fritzbox-auth
disallow=all
allow=alaw
allow=ulaw
direct_media=no
force_rport=yes
rewrite_contact=yes
rtp_symmetric=yes
from_user=KI_FRITZBOX_USER
from_domain=fritz.box

Diese Platzhalter musst du ersetzen:

  • KI_FRITZBOX_USER → Benutzername aus der Fritz!Box (Schritt 2.2)
  • DEIN_SICHERES_FRITZBOX_PASSWORT → SIP-Passwort aus der Fritz!Box
  • DEINE_FRITZBOX_IP → z. B. 192.168.0.1

3.2.2 rtp.conf – Audio-Ports

Wir legen den RTP-Portbereich explizit fest, damit Audio vorhersagbar läuft. RTP ist das Protokoll, das den eigentlichen Audiostrom überträgt – ohne definierten Portbereich könnte Asterisk beliebige Ports verwenden, was Firewalls und NAT noch chaotischer macht.
Datei: /volume1/docker/asterisk/config/rtp.conf

[general]
rtpstart=20000
rtpend=20255

Firewall-Hinweis: Falls die Firewall auf der Diskstation aktiv ist, lege Regeln an für Port 5060 (UDP) und den Bereich 20000–20255 (UDP).

3.2.3 extensions.conf – Minimal-Dialplan

Ulf: „Was macht der Dialplan?“
Tanja: „Stell dir vor, Asterisk ist ein Empfangstresen. Der Dialplan ist die Arbeitsanweisung: Wenn jemand anruft, was passiert dann? Wohin wird der Anruf weitergeleitet?“ Sie tippt kurz. „Im Moment sagen wir nur: Annehmen, kurz warten, an die OpenAI-Brücke übergeben.“
Datei: /volume1/docker/asterisk/config/extensions.conf

[general]
static=yes
writeprotect=no

[from-fritzbox]
; Eingehender Anruf von FritzBox → an OpenAI Relay weiterleiten
exten => s,1,NoOp(Eingehender Anruf von FritzBox - CID=${CALLERID(all)})
 same => n,Answer()
 same => n,Wait(0.5)
 same => n,NoOp(Uebergabe an OpenAI Realtime via Stasis...)
 same => n,Stasis(asterisk_to_openai_rt)
 same => n,Hangup()

; Catch-all für alle Nummern, die die FritzBox sendet
exten => _X.,1,Goto(from-fritzbox,s,1)

Ein paar Erläuterungen für den Fall, dass du später debuggen musst:

  • exten => _X.,1 fängt jede gerufene Nummer ab – die Fritz!Box übergibt oft konkrete MSN-Nummern, und so landest du sicher im Dialplan
  • NoOp(...) schreibt ins Log, welche Nummer Asterisk tatsächlich sieht – hilft enorm beim Fehlersuchen
  • Wait(0.5) entschärft bei manchen Setups Race-Conditions beim Audiostart

3.2.4 modules.conf, logger.conf und asterisk.conf

Diese drei Dateien sind der Maschinenkeller. Unspektakulär, aber ohne sie startet Asterisk nicht sauber.
Datei: /volume1/docker/asterisk/config/modules.conf

[modules]
autoload=yes

; Sicherheitshalber kritische ARI-Module explizit laden
load = res_http_websocket.so
load = res_ari.so
load = res_ari_channels.so
load = res_ari_bridges.so
load = res_ari_events.so
load = res_stasis.so
load = res_stasis_answer.so
load = res_stasis_playback.so
load = res_stasis_recording.so
load = res_stasis_snoop.so
load = chan_rtp.so

Asterisk hat hunderte Module. autoload=yes lädt alle automatisch – die kritischen ARI-Module werden zusätzlich explizit genannt, damit Asterisk sie auf jeden Fall findet.

Datei: /volume1/docker/asterisk/config/logger.conf

[general]
dateformat=%F %T

[logfiles]
console => notice,warning,error,verbose(5)
messages => notice,warning,error,verbose(5)

Datei: /volume1/docker/asterisk/config/asterisk.conf

[directories]
astetcdir => /etc/asterisk
astvarlibdir => /var/lib/asterisk
astdbdir => /var/lib/asterisk
astmoddir => /usr/lib/asterisk/modules
astspooldir => /var/spool/asterisk
astrundir => /var/run/asterisk
astlogdir => /var/log/asterisk

[options]
verbose = 3
debug = 0

Warum so minimal? Damit Asterisk sicher startet und wir die wichtigen Meldungen direkt auf der Konsole sehen. Datei-Logs können wir später gezielt ergänzen.

3.3 Asterisk im Docker starten

Alle Dateien liegen an ihrem Platz. Jetzt starten wir den Container – über den Container Manager auf der Diskstation.

  1. Container Manager öffnen
    • DSM -> Container Manager
  2. Neues Projekt erstellen
    • Links: Projekt
    • Erstellen
    • Projektname: asterisk_ki
  3. compose.yml auswählen
    • Von bestehender compose.yml
    • Pfad auswählen: /volume1/docker/asterisk/compose.yml
    • Weiter
  4. Projekt starten
    • Erstellen & starten (oder „Fertigstellen“ -> „Starten“)

Firewall-Erinnerung: Falls die Firewall auf der Synology aktiv ist, jetzt Regeln anlegen: Port 5060 (UDP) und Port 20000–20255 (UDP).

3.4 Prüfen, ob Asterisk bei der Fritz!Box registriert ist

Ulf: „Und jetzt einfach anrufen und schauen ob’s geht?“
Tanja: „Nein, erst verifizieren wir, dass Asterisk sich registriert hat. Erst dann testen wir den Anruf. Wer diesen Schritt überspringt, verbringt anschließend eine Stunde damit, im Dunkeln zu tappen.“
Bernd: „Ich hätte einfach angerufen“
Tanja: „Das weiß ich.“

Wir schauen jetzt direkt in die Asterisk-Konsole. Dafür gibt es zwei Wege:
Weg 1 – über den Container Manager:

  • Container Manager öffnen → Container asterisk_ki auswählen
  • Tab Terminal → Erstellen → bash wählen
  • Befehl eingeben: asterisk -rvvv
  • Sobald der Prompt asterisk*CLI> erscheint, eingeben: pjsip show registrations
    Weg 2 – über SSH:
  • SSH auf der Diskstation aktivieren: Systemsteuerung → Terminal & SNMP → SSH-Dienst aktivieren
  • Terminal öffnen und einloggen: ssh DEIN_DSM_USER@DEINE_DISKSTATION_IP
  • In die Asterisk-CLI: docker exec -it asterisk_ki asterisk -rvvv
  • Registrierung anzeigen: pjsip show registrations
    Das Ergebnis sollte so aussehen:
<Registration/ServerURI..............................>  <Auth..........>  <Status.......>
==========================================================================================
 fb-trunk/sip:192.168.178.1                              fb-auth           Registered

Das Wort Registered ist dein grünes Licht. Alles andere – FailedRejected, gar keine Ausgabe – bedeutet: Zugangsdaten prüfen, Fritz!Box-Konfiguration nochmal kontrollieren, erst dann weitermachen.

4 Die OpenAI-Brücke: WebSocket-Relay einrichten und KI-Gespräche aktivieren

Ulf: „Asterisk läuft, Fritz!Box meldet ‚Registered‘, was kommt jetzt?“.
Tanja: „Jetzt, bauen wir den spannendsten Teil. Das Relay. Der zweite Docker-Container, der Asterisk und OpenAI miteinander verbindet.“
Bernd:„Ich würde das alles in einem Container zusammenstecken“.
Tanja: „Dann hast du einen Container, der alles macht und bei dem du nichts mehr einzeln debuggen kannst. Zwei Container, zwei Verantwortlichkeiten – das ist sauberer.“
Bernd: „Klingt nach mehr Arbeit.“
Tanja: „Klingt nach weniger Kopfschmerzen.“

Das Relay übersetzt zwei grundlegend verschiedene Welten: auf der einen Seite das klassische RTP-Audioprotokoll von Asterisk, auf der anderen den WebSocket-Stream, den OpenAI erwartet. Sobald diese Brücke steht, übernimmt OpenAI das komplette Gespräch – Spracherkennung, Antwortgenerierung und Text-to-Speech in Echtzeit.

Wichtiger Hinweis vom 30.08.2026, bevor du dieses Kapitel baust: Das Dockerfile in Abschnitt 4.2 klont die Community Edition aus dem öffentlichen Repository, und dieser Code spricht noch die Realtime-Beta-Schnittstelle von OpenAI. Die ist abgeschaltet. Ein frisch gebautes Relay nimmt den Anruf zwar an, bleibt danach aber stumm. Was zu ändern ist und warum, steht in Abschnitt 4.7.1 – arbeite dieses Kapitel bis dorthin durch und mach dann die Umstellung, bevor du zum ersten Mal anrufst.

4.1 Asterisk-to-OpenAI-Realtime (WebSocket Bridge)

Wir erweitern die Ordnerstruktur um den openai-relay-Container:

/volume1/docker/
├── asterisk/
│   ├── compose.yaml
│   ├── config/
│   │   ├── pjsip.conf
│   │   ├── extensions.conf
│   │   ├── ari.conf
│   │   ├── http.conf
│   │   ├── modules.conf
│   │   ├── logger.conf
│   │   └── rtp.conf
│   └── logs/
└── openai-relay/
    ├── compose.yaml
    ├── Dockerfile
    └── config.conf

Drei neue Dateien im Verzeichnis openai-relay: das Dockerfile, die compose.yaml und die config.conf. Schauen wir uns alle drei an.

4.2 Das Dockerfile

Das Dockerfile baut das Docker-Image direkt aus einem öffentlichen Repository – du musst keinen Code manuell klonen oder pflegen.
Erstelle die Datei /volume1/docker/openai-relay/Dockerfile:

FROM node:20-slim

RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Community Edition klonen
RUN git clone https://github.com/infinitocloud/asterisk_to_openai_rt_community.git .

RUN npm install

ENV NODE_ENV=production

CMD ["node", "index.js"]

4.3 Die compose.yaml für openai-relay

Da Asterisk im network_mode: host läuft, muss das Relay dasselbe tun – sonst erreicht es die RTP-Audio-Ports nicht.
Erstelle die Datei /volume1/docker/openai-relay/compose.yaml:

version: "3.8"

services:
  openai-relay:
    build: .
    container_name: openai-relay
    restart: unless-stopped
    network_mode: "host"
    volumes:
      # config.conf wird in den Container gemountet
      - /volume1/docker/openai-relay/config.conf:/app/config.conf:ro
    environment:
      - TZ=Europe/Berlin
      - NODE_ENV=production
    depends_on: []
    # Hinweis: asterisk_ki muss ZUERST gestartet werden,
    # da openai-relay sich per ARI verbindet.
    # Da die Container in separaten compose-Dateien sind,
    # manuell sicherstellen: erst asterisk_ki, dann openai-relay.

4.4 Asterisk Core-Konfiguration: ari.conf und http.conf

Ulf: „Was ist ARI?“.
Tanja: „ARI steht für Asterisk REST Interface. Es ist die Schnittstelle, über die das Node.js-Relay mit Asterisk kommuniziert. Ohne ARI kann das Relay keinen Anruf übernehmen.“
Ulf: „Das klingt nach dem Trainertelefon zur Bank. Der Coach kann dem Ersatzspieler Anweisungen geben, ohne auf das Feld zu müssen.“
Tanja: „Exakt“.

Diese beiden Dateien sind das Bindeglied zwischen Asterisk und dem Relay. Besonders wichtig: type=user in der ari.conf – das ist häufig der Grund, warum ARI-Verbindungen still scheitern.
Datei: /volume1/docker/asterisk/config/ari.conf

[general]
enabled=yes
pretty=yes
allowed_origins=*

[ari_user]
type=user
read_only=no
password=dein_ari_passwort

Diese Platzhalter musst du ersetzen:

  • ARI_USERNAME=ari_user -> frei wählbarer Benutzername
  • ARI_PASSWORD=dein_ari_passwort -> frei wählbares Passwort (merken, kommt gleich wieder)

Datei: /volume1/docker/asterisk/config/http.conf

[general]
enabled=yes
bindaddr=127.0.0.1
bindport=8088

127.0.0.1 statt 0.0.0.0: Da beide Container auf dem Host-Netz der Synology laufen, braucht der HTTP-Port nicht nach außen offen zu sein. Das ist eine einfache, effektive Sicherheitsmaßnahme.

4.5 Das config.conf – Schaltzentrale des Relays

Diese Datei ist das Herzstück des openai-relay-Containers. Hier trägst du ein, mit welchem OpenAI-Key das Relay spricht, wie es sich bei Asterisk anmeldet und was die KI beim Gespräch sagen soll.
Erstelle die Datei /volume1/docker/openai-relay/config.conf:

# OpenAI Realtime API Key
OPENAI_API_KEY=OpenAI_Projekt_API_Key

# ARI Credentials (muessen mit ari.conf uebereinstimmen)
ARI_URL=http://127.0.0.1:8088
ARI_USERNAME=ari_user
ARI_PASSWORD=dein_ari_passwort

# Anruf Limit von 5 Minuten
CALL_DURATION_LIMIT_SECONDS=300

# System-Prompt fuer den Assistenten
SYSTEM_PROMPT=Du bist ein professioneller deutschsprachiger Telefonassistent. Fuehre das Gespraech in dieser Reihenfolge: 1) Begruesse den Anrufer freundlich und sage dass gerade niemand erreichbar ist und du die KI Telefon-Assistentin bist. 2) Frage nach dem Namen des Anrufers falls nicht bereits genannt. 3) Frage worum es geht und hoere aufmerksam zu. 4) Frage wie dringend das Anliegen ist. 5) Fasse das Anliegen kurz zusammen und bestaetige dass du die Nachricht weiterleiten wirst. 6) Verabschiede dich freundlich. Antworte immer auf Deutsch, kurz und praezise. Vermeide lange Monologe.

# Logging
LOG_LEVEL=info

Diese Platzhalter ersetzen:

  • OpenAI_Projekt_API_Key -> dein OpenAI API-Key aus Schritt 2.1
  • ARI_USERNAME=ari_user -> vdie Werte aus ari.conf
  • ARI_PASSWORD=dein_ari_passwort -> die Werte aus ari.conf

Den SYSTEM_PROMPT kannst du frei anpassen. Das ist die Persönlichkeit deines KI-Assistenten. Halte ihn konkret und strukturiert: Je klarer die Reihenfolge, desto zuverlässiger arbeitet das Modell.

4.6 Stimme der KI ändern

OpenAI bietet verschiedene Stimmen für die Realtime API an. Standard ist alloy – du kannst aber auf andere Stimmen wie echofableonyxnova oder shimmer wechseln.
Um die Stimme zu ändern, kopierst du zunächst die Konfigurationsdatei aus dem laufenden Container auf die NAS:

sudo docker cp openai-relay:/app/config.js /volume1/docker/openai-relay/config.js

Füge in dieser config.js nach dem zweiten Eintrag folgende Zeile ein:

OPENAI_VOICE: process.env.OPENAI_VOICE || 'alloy',

Damit der Container diese Datei auch findet, erweitere die compose.yaml um den zweiten Volume-Eintrag:

volumes:
      - /volume1/docker/openai-relay/config.conf:/app/config.conf:ro
      - /volume1/docker/openai-relay/config.js:/app/config.js:ro

Und trage in der config.conf ein:

OPENAI_VOICE=alloy

Danach den Container neu starten . Die neue Stimme ist aktiv.

4.7 Webhook für n8n Workflow vorbereiten

Aktuell werden Transkripte nur in die Konsole geloggt und gehen nach dem Auflegen verloren. Jetzt erweitern wir das System so, dass nach jedem Gespräch automatisch ein Transkript an n8n geschickt wird.

Ulf: „Moment, wir müssen Code in fertigen Dateien ändern?“
Tanja: „Wir kopieren die Dateien erst raus, ändern sie, und mounten sie dann als Volume in den Container rein. Der Container bleibt unberührt. Wir steuern sein Verhalten von außen.“
Ulf: „Das ist wie beim Fußball“. Der Spieler ist unveränderlich, aber der Trainer gibt ihm von außen neue Anweisungen.“
Tanja: „Genau.“

Schritt 1: Es müssen folgende JavaScript-Dateien aus dem Container kopiert werden, damit wir sie anpassen und als Volume mounten können. Hinweis: Hast du Abschnitt 4.6 bereits durchlaufen, liegt config.js schon mit deiner Stimmen-Änderung auf der NAS – dann lass die erste Zeile weg, sonst überschreibst du sie.

sudo docker cp openai-relay:/app/config.js /volume1/docker/openai-relay/config.js
sudo docker cp openai-relay:/app/asterisk.js /volume1/docker/openai-relay/asterisk.js
sudo docker cp openai-relay:/app/openai.js /volume1/docker/openai-relay/openai.js

Schritt 2: In /volume1/docker/openai-relay/config.js suche nach INITIAL_MESSAGE: und füge direkt darüber ein:

OPENAI_VOICE: process.env.OPENAI_VOICE || 'alloy',
N8N_WEBHOOK_URL: process.env.N8N_WEBHOOK_URL || '',
INITIAL_MESSAGE: process.env.INITIAL_MESSAGE || 'Hi',

Schritt 3: config.conf ergänzen
Füge am Ende der /volume1/docker/openai-relay/config.conf hinzu:

OPENAI_VOICE=alloy 
N8N_WEBHOOK_URL=https://n8n.deine-domain.de/webhook/NEUE_WEBHOOK_ID

Hinweis: Die NEUE_WEBHOOK_ID wird erst im nächsten Schritt beim Aufbau des n8n-Workflows definiert. Trag hier zunächst einen Platzhalter ein und ersetze ihn danach.

Schritt 4: compose.yaml aktualisieren
Erweitere die Volumes in /volume1/docker/openai-relay/compose.yaml:

volumes:
      - /volume1/docker/openai-relay/config.conf:/app/config.conf:ro
      - /volume1/docker/openai-relay/config.js:/app/config.js:ro
      - /volume1/docker/openai-relay/asterisk.js:/app/asterisk.js:ro
      - /volume1/docker/openai-relay/openai.js:/app/openai.js:ro

Aktuell werden die Transkripte nur in die Konsole geloggt und gehen nach dem Anruf verloren. Wir müssen openai.js so erweitern, dass die Transkripte während des Gesprächs gesammelt und nach Gesprächsende per HTTP POST an n8n geschickt werden.

Schritt 5: openai.js erweitern – Transkript sammeln
Suche in /volume1/docker/openai-relay/openai.js die Zeile (ca. Zeile 88):

  let lastUserItemId = null;

Füge direkt darunter ein:

  let transcriptLog = [];

Dann suche diesen Block (ca. Zeile 164-168):

if (role === 'User') {
              logOpenAI(`User command transcription for ${channelId}: ${response.transcript}`, 'info');
            } else {
              logOpenAI(`Assistant transcription for ${channelId}: ${response.transcript}`, 'info');
            }

Ersetze ihn durch:

            if (role === 'User') {
              logOpenAI(`User command transcription for ${channelId}: ${response.transcript}`, 'info');
              transcriptLog.push({ role: 'user', text: response.transcript, time: new Date().toISOString() });
            } else {
              logOpenAI(`Assistant transcription for ${channelId}: ${response.transcript}`, 'info');
              transcriptLog.push({ role: 'assistant', text: response.transcript, time: new Date().toISOString() });
            }

Dann suche (ca. Zeile 167)

            logOpenAI(`User command transcription for ${channelId}: ${response.transcript}`, 'info');
          }
          break;
        case 'response.audio.done':

Ersetze die eine Zeile:

            logOpenAI(`User command transcription for ${channelId}: ${response.transcript}`, 'info');

durch:

            logOpenAI(`User command transcription for ${channelId}: ${response.transcript}`, 'info');
            transcriptLog.push({ role: 'user', text: response.transcript, time: new Date().toISOString() });

Zum Schluss: Suche sipMap.set(channelId, channelData); innerhalb von connectWebSocket (ca. Zeile 246) und füge direkt davor ein:

        channelData.transcriptLog = transcriptLog;

Suche diesen Block (ca. Zeile 180):

        case 'conversation.item.input_audio_transcription.completed':
          if (response.transcript) {
            logger.debug(`User transcript completed - Full message: ${JSON.stringify(response, null, 2)}`);
            logOpenAI(`User command transcription for ${channelId}: ${response.transcript}`, 'info');
          }
          break;

Ersetze durch:

        case 'conversation.item.input_audio_transcription.completed':
          if (response.transcript) {
            logger.debug(`User transcript completed - Full message: ${JSON.stringify(response, null, 2)}`);
            logOpenAI(`User command transcription for ${channelId}: ${response.transcript}`, 'info');
            transcriptLog.push({ role: 'user', text: response.transcript, time: new Date().toISOString() });
          }
          break;

Suche Zeile (ca. Zeile 224):

            input_audio_transcription: {
              model: 'whisper-1',
              language: 'en'
            },

Suche language: en und ersetze durch de für Deutsche Sprache

              language: 'de'

Schritt 6: asterisk.js erweitern – Transkript an n8n senden
Jetzt /volume1/docker/openai-relay/asterisk.js erweitern — nach Gesprächsende die gesammelten Transkripte an n8n senden.

Änderung 1: Ganz oben nach der letzten require-Zeile einfügen:

const http = require('http');
const https = require('https');

Änderung 2: Suche in der cleanupChannel-Funktion diese Zeile:

    } catch (e) { 
      logger.error(`Cleanup error for ${channelId}: ${e.message}`);
    } finally {
      cleanupPromises.delete(channelId);
      cleanupPromises.delete(`ws_${channelId}`);
    }

Füge direkt davor (also vor } finally {) ein:

      // Send transcript to n8n webhook
      if (config.N8N_WEBHOOK_URL && channelData.transcriptLog && channelData.transcriptLog.length > 0) {
        try {
          const payload = JSON.stringify({
            channelId: channelId,
            callerName: channelData.channel?.caller?.name || 'Unbekannt',
            callerNumber: channelData.channel?.caller?.number || 'Unbekannt',
            callStart: channelData.transcriptLog[0]?.time || new Date().toISOString(),
            callEnd: new Date().toISOString(),
            transcript: channelData.transcriptLog
          });
          const url = new URL(config.N8N_WEBHOOK_URL);
          const client = url.protocol === 'https:' ? https : http;
          const req = client.request(url, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
          }, (res) => {
            logger.info(`n8n webhook response for ${channelId}: ${res.statusCode}`);
          });
          req.on('error', (e) => logger.error(`n8n webhook error for ${channelId}: ${e.message}`));
          req.write(payload);
          req.end();
          logger.info(`Transcript sent to n8n for ${channelId} (${channelData.transcriptLog.length} entries)`);
        } catch (e) {
          logger.error(`Failed to send transcript to n8n for ${channelId}: ${e.message}`);
        }
      }

4.7.1 Nachtrag vom 30.08.2026: Die Realtime-Beta-Schnittstelle ist abgeschaltet

Ulf: „Bei mir klingelt es, der Anruf wird angenommen – und dann ist Stille. Bis ich auflege.“
Bernd: „Neu starten. Hilft immer.“
Tanja: „Hat es bei dir jemals geholfen?“
Bernd: „Einmal.“
Tanja: „Dann schau ins Protokoll des Containers. Wenn das System stumm bleibt, steht dort fast immer ein Satz, der genau sagt, warum.“

OpenAI hat die Beta-Fassung der Realtime API abgeschaltet. Der Code der Community Edition, den das Dockerfile in Abschnitt 4.2 klont, meldet sich noch über diese Beta-Schnittstelle an. Das Ergebnis ist ein Fehlerbild, das leicht in die Irre führt: Die Fritz!Box reicht den Anruf durch, Asterisk nimmt ab, die Brücke und der Audiokanal stehen, die WebSocket-Verbindung kommt sogar zustande – und bricht Millisekunden später ab. Der Anrufer hört nichts.

Im Protokoll des Containers openai-relay steht dann dieser Satz:

[ERROR] OpenAI error for 1234567890.0:
The Realtime Beta API is no longer supported. Please use /v1/realtime for the GA API.

Das sicherste Erkennungsmerkmal steht am Ende jedes Anrufs in derselben Zeile: total packets sent: 0. Null gesendete Pakete heißt, dass nie Ton in Richtung Anrufer geflossen ist. Läuft es richtig, stehen dort mehrere tausend.

Ulf: „Und die Adresse? Da steht doch /v1/realtime schon drin.“
Tanja: „Richtig, die Adresse war nie das Problem. Auf die Beta-Schiene zwingen drei andere Dinge: eine Kopfzeile in der Verbindung, das alte Vorschaumodell und die Form der ersten beiden Nachrichten. Die drei stellen wir jetzt um.“

Vorher sichern

Bevor du eine Zeile änderst, leg eine Kopie an. Sie kostet nichts und ist der ganze Rückweg:

sudo cp /volume1/docker/openai-relay/openai.js /volume1/docker/openai-relay/openai.js.vor-ga
sudo cp /volume1/docker/openai-relay/config.conf /volume1/docker/openai-relay/config.conf.vor-ga

Geht etwas schief, kopierst du die beiden Dateien zurück und startest den Container neu – mehr ist nicht nötig. Ein Neubau des Image ist für diese Umstellung nicht erforderlich, weil openai.js und config.conf laut Abschnitt 4.7 als Volume in den Container gemountet sind. Der Container liest sie bei jedem Start neu.

Änderung 1: Die Beta-Kopfzeile entfernen

Suche in /volume1/docker/openai-relay/openai.js die Stelle, an der die WebSocket-Verbindung aufgebaut wird:

ws = new WebSocket(config.REALTIME_URL, {
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'OpenAI-Beta': 'realtime=v1'
}
});

Ersetze sie durch:

ws = new WebSocket(config.REALTIME_URL, {
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`
}
});

Achte auf das Komma: Nach der Authorization-Zeile darf keines mehr stehen, sonst startet das Relay nicht.

Änderung 2: session.update auf die GA-Form bringen

In der GA-Fassung liegen Ein- und Ausgabe unter audio, das Audioformat ist kein Text mehr, sondern ein Objekt mit type, und modalities heißt output_modalities. Aus g711_ulaw wird audio/pcmu – dieselbe Kodierung, neuer Name.

Suche in openai.js diesen Block (er beginnt direkt nach ws.on('open', ...)):

ws.send(JSON.stringify({
type: 'session.update',
session: {
modalities: ['audio', 'text'],
voice: config.OPENAI_VOICE || 'alloy',
instructions: config.SYSTEM_PROMPT,
input_audio_format: 'g711_ulaw',
output_audio_format: 'g711_ulaw',
input_audio_transcription: {
model: 'whisper-1',
language: 'de'
},
turn_detection: {
type: 'server_vad',
threshold: config.VAD_THRESHOLD || 0.6,
prefix_padding_ms: config.VAD_PREFIX_PADDING_MS || 200,
silence_duration_ms: config.VAD_SILENCE_DURATION_MS || 600
}
}
}));

Ersetze ihn vollständig durch:

ws.send(JSON.stringify({
type: 'session.update',
session: {
type: 'realtime',
output_modalities: ['audio'],
instructions: config.SYSTEM_PROMPT,
audio: {
input: {
format: { type: 'audio/pcmu' },
transcription: {
model: 'whisper-1',
language: 'de'
},
turn_detection: {
type: 'server_vad',
threshold: config.VAD_THRESHOLD || 0.6,
prefix_padding_ms: config.VAD_PREFIX_PADDING_MS || 200,
silence_duration_ms: config.VAD_SILENCE_DURATION_MS || 600
}
},
output: {
format: { type: 'audio/pcmu' },
voice: config.OPENAI_VOICE || 'alloy'
}
}
}
}));

Alle Stellschrauben bleiben dieselben: Stimme, Schwellwert, Vorlauf, Stillepause und dein SYSTEM_PROMPT stehen weiterhin in der config.conf. Es ist eine Übersetzung, keine Neueinstellung. Steht bei dir noch language: 'en', weil du Schritt 5 aus Abschnitt 4.7 übersprungen hast: In diesem neuen Block ist 'de' bereits enthalten – damit ist der Punkt erledigt.

Das Modell gehört ausdrücklich nicht in diesen Block. Es wird über die Adresse gewählt (Änderung 4); die Schnittstelle lässt es in session.update nicht ändern.

Änderung 3: response.create entschlacken und die Ereignisnamen ergänzen

Das Ausgabeformat gehört jetzt zur Sitzung, nicht mehr zur einzelnen Antwort. Suche etwas weiter unten:

ws.send(JSON.stringify({
type: 'response.create',
response: {
modalities: ['audio', 'text'],
instructions: config.SYSTEM_PROMPT,
output_audio_format: 'g711_ulaw'
}
}));

Ersetze durch:

ws.send(JSON.stringify({
type: 'response.create',
response: {
instructions: config.SYSTEM_PROMPT,
output_modalities: ['audio']
}
}));

Zuletzt heißen vier Ereignisse, die OpenAI zurückschickt, in der GA-Fassung anders. Wir ergänzen die neuen Namen, statt die alten zu ersetzen – dann laufen beide Schreibweisen in denselben Zweig, und wenn ein Name doch abweicht, verschwindet er nicht still, sondern landet im default-Zweig und damit im Protokoll.

Setze dazu in der grossen switch-Anweisung jeweils direkt über die vorhandene Zeile die neue:

case 'response.output_audio.delta':
case 'response.audio.delta':

case 'response.output_audio_transcript.delta':
case 'response.audio_transcript.delta':

case 'response.output_audio_transcript.done':
case 'response.audio_transcript.done':

case 'response.output_audio.done':
case 'response.audio.done':

Gemeint ist: Vor jeder der vier vorhandenen case-Zeilen kommt eine zusätzliche Zeile mit dem neuen Namen. Der Code darunter bleibt unverändert. conversation.item.input_audio_transcription.completed heißt weiterhin genau so und wird nicht angefasst – auch die Ergänzungen aus Abschnitt 4.7 und 4.10 bleiben damit gültig.

Änderung 4: Das Modell in der config.conf festlegen

Ohne eigene Angabe greift im Relay ein fest eingebauter Vorgabewert – und das ist ein Vorschaumodell der abgeschalteten Beta. Trage deshalb in /volume1/docker/openai-relay/config.conf ein:

# Modell der GA-Schnittstelle
REALTIME_MODEL=gpt-realtime

Achte auf den Namen der Variablen. Das Relay liest REALTIME_MODEL aus der config.conf – nicht OPENAI_REALTIME_MODEL aus der .env in Abschnitt 2.3. Die Zeile dort ist ein Platzhalter für spätere Erweiterungen und hat auf das Relay keine Wirkung.

Wer sparen möchte, trägt gpt-realtime-mini ein (siehe Abschnitt 2.4). Bring es aber erst mit gpt-realtime zum Laufen und wechsle danach – sonst weißt du bei einem Fehler nicht, woran es lag.

Neu starten und mit einem echten Anruf belegen

Container Manager → Container openai-relayStopp, dann Start. Im Protokoll müssen kurz darauf diese Zeilen stehen:

[dotenv] injecting env (10) from config.conf
SYSTEM_PROMPT loaded from config.conf
Loading openai.js module
Connected to ARI at http://127.0.0.1:8088
ARI application "asterisk_to_openai_rt" started
Application started successfully

Die Zahl in Klammern hinter injecting env ist die Anzahl der Einträge in deiner config.conf. Ist sie um eins gestiegen, wird die neue Datei gelesen – und damit sind auch die übrigen gemounteten Dateien frisch eingebunden.

Ulf: „Container läuft wieder. Fertig?“
Tanja: „Nein. Ein Neustart ist kein Beleg. Ruf an.“
Bernd: „Bei mir läuft’s, wenn der Container grün ist.“
Tanja: „Der Container war beim Fehler auch grün. Grün heißt nur, dass das Programm läuft – nicht, dass jemand etwas hört.“

Ruf deine Festnetznummer an und sprich ein paar Sätze. Danach ins Protokoll schauen. Diese Zeilen sind der Beweis:

RTP stream ended for ..., total packets sent: 4629, total bytes: 740640
Transcript sent to n8n for ... (29 entries)
n8n webhook response for ...: 200

Entscheidend ist total packets sent. Steht dort eine vierstellige Zahl, fließt Ton. Steht dort 0, ist die Umstellung noch nicht vollständig – dann suche im Protokoll nach [ERROR]. Die Fehlermeldung nennt in der Regel genau das Feld, das der Schnittstelle nicht passt.

Bleibt es still, obwohl kein Fehler im Protokoll steht, treffen die Audio-Ereignisse unter einem Namen ein, den Änderung 3 noch nicht abfängt. Setze dann in der config.conf vorübergehend LOG_LEVEL=debug, ruf noch einmal an und suche nach Unhandled event type – dort steht der fehlende Name, den du als weitere case-Zeile ergänzt.

4.8 Cloudfare öffenen für Webhooks

Ulf: „Ich nutze Cloudflare mit Zero Trust für n8n. Muss ich da was anpassen?“
Tanja: „Ja, Zero Trust blockiert standardmäßig alle eingehenden Requests, die nicht authentifiziert sind – also auch Webhooks von OpenAI. Wir müssen einen Bypass für den Webhook-Pfad einrichten.“

Wenn du Cloudflare mit Zero Trust für n8n nutzt, werden Webhooks grundsätzlich geblockt. Wir richten eine Ausnahme speziell für den /webhook/-Pfad ein. Zur EInrichtung von Cloudflare siehe https://www.foundic.org/n8n-cloudflare-tunnel-synology-nas.

Teil 1 – Neue Access Application anlegen:

  1. Cloudflare Zero Trust Dashboard öffnen:
    1. Gehe zu: https://one.dash.cloudflare.com/
    2. Melde dich an
    3. Wähle dein Team/Account
  2. Access Application finden:
    1. Navigiere zu: Protect & Connect -> Zero Trust -> Access controls -> Applications
    2. Finde deine n8n-Application in der Liste
    3. Wähle „+ Add an application“
    4. Select: Self-hosted
    5. Applciation name: n8n-webhook
    6. Session Duration: 24hours
    7. „+ Add public hostname“
      • Subdomain: die bisher benutzte
      • Domain: deine Domain
      • Path: webhook/*
    8. Wähle links im Menü „Policies“ aus
    9. Klicke auf „Add a policy“
      • Policy konfigurieren:
        Policy Name: n8n Webhook Bypass
        Action: „Bypass“
        Session duration: Same as application session timeout
        Selection: Everyone
        Klicke auf „Save“ 
BILD 10 - Cloudfare - Web app exploits einrichten - Manage custom rules
BILD 10 – Cloudfare – Web app exploits einrichten – Manage custom rules

Teil 2 – WAF-Regel erstellen:
Gehe im Cloudflare Dashboard zur obersten Kontoebene -> Domains -> deine Domain -> (links im Menü) Security -> Overview -> in der Mitte im Kasten „Web app exploits“ -> Manage custom rules -> + Create rule -> Custom rules

  • Rule name: Allow n8n Webhook
  • Field: URI Path …, Operator: starts with, Value: .. /webhook/
  • Choose action: Skip
  • Aktiviere alle Sicherheitsoptionen (WAF, Super Bot Fight Mode etc.), damit OpenAI ungehindert durchkommt
  • Klicke auf Deploy

4.9 n8n Workflow bauen

Ulf: „Jetzt kommt der Teil, den ich kenne, n8n.“
Tanja: „Fast, erst brauchen wir noch ein App-Passwort für Gmail. Das normale Google-Passwort geht nicht.“
Ulf: „Warum nicht?“
Tanja: „Weil Google beim normalen Passwort verlangt, dass du dich per Browser verifizierst. Eine Maschine kann das nicht. Ein App-Passwort ist ein separater 16-stelliger Code, der nur für automatisierte Zugänge gedacht ist.“
Ulf: „Und wenn ich keins hab?“
Tanja: „Dann bleibt die E-Mail aus.“

Schritt 1: Gmail App-Passwort erstellen
Falls der n8n Workflow eine Zusammenfassung an Gmail bzw. Googel Mail senden soll brauchst Du ein App-Passwort (nicht Dein normales Gmail-Passwort). So richtest Du es ein:
Gmail App-Passwort erstellen:

Schritt 2: SMTP Credentials in n8n anlegen
SMTP Credentials anlegen in n8n anlegen

  • n8n -> Settings -> Credentials -> Add Credential -> Typ: SMTP
  • Name: SMTP Gmail account
  • User: deine-adresse@gmail.com
  • Password: dein 16-stelliges App-Passwort
  • Host: smtp.gmail.com
  • Port: 465
  • SSL/TLS: Ein
  • Speichern

Schritt 3: Die drei Workflow-Nodes
Erstelle einen neuen n8n-Workflow mit drei Nodes hintereinander.
Node 1: Webhook

  • Method: POST
  • Path: dein-webhook-pfad
  • Authentication: Header Auth – nicht None. Die Zugangsdaten dazu legst du gleich in Abschnitt 4.9.1 an; dort steht auch, warum der Pfad allein kein Schutz ist.

Node 2: Code in JavaScript

  • Mode: Run Once for All Items
  • Language: JavaScript
// Transkript formatieren
const body = $input.first().json.body;
const transcript = body.transcript || [];
const callerName = body.callerName || 'Unbekannt';
const callerNumber = body.callerNumber || 'Unbekannt';
const callStart = body.callStart ? new Date(body.callStart).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : 'Unbekannt';
const callEnd = body.callEnd ? new Date(body.callEnd).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : 'Unbekannt';

// Gespraechsverlauf als Text
let gespraech = '';
for (const entry of transcript) {
  const rolle = entry.role === 'assistant' ? 'Anna (KI)' : 'Anrufer';
  gespraech += `${rolle}: ${entry.text}\n\n`;
}

// Zusammenfassung erstellen
let zusammenfassung = `ANRUF-ZUSAMMENFASSUNG\n`;
zusammenfassung += `========================\n\n`;
zusammenfassung += `Anrufer: ${callerName}\n`;
zusammenfassung += `Telefonnummer: ${callerNumber}\n`;
zusammenfassung += `Anruf Beginn: ${callStart}\n`;
zusammenfassung += `Anruf Ende: ${callEnd}\n\n`;
zusammenfassung += `GESPRAECHSVERLAUF:\n`;
zusammenfassung += `------------------------\n\n`;
zusammenfassung += gespraech;

// HTML Version fuer E-Mail
let html = `<h2>Anruf-Zusammenfassung</h2>`;
html += `<table style="border-collapse:collapse; margin-bottom:20px;">`;
html += `<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">Anrufer:</td><td>${callerName}</td></tr>`;
html += `<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">Telefonnummer:</td><td>${callerNumber}</td></tr>`;
html += `<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">Beginn:</td><td>${callStart}</td></tr>`;
html += `<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">Ende:</td><td>${callEnd}</td></tr>`;
html += `</table>`;
html += `<h3>Gespr\u00e4chsverlauf</h3>`;
for (const entry of transcript) {
  const rolle = entry.role === 'assistant' ? 'Anna (KI)' : 'Anrufer';
  const color = entry.role === 'assistant' ? '#2563eb' : '#059669';
  html += `<p><strong style="color:${color};">${rolle}:</strong> ${entry.text}</p>`;
}

const subject = `Anruf von ${callerName} (${callerNumber}) - ${callStart}`;

return [{ json: { subject, text: zusammenfassung, html, callerName, callerNumber } }];

Als letzten Node 3: Send Email erstellen

  • Credential to connect with: SMTP Gmail account
  • Operations: Send
  • From Email: deine E-Mail-Adresse
  • To Email: deine E-Mail-Adresse
  • Subject: {{ $json.subject }}
  • Email Format: HTML
  • HTML: {{ $json.html }}

Schritt 4: Workflow aktivieren
Aktiviere den Workflow über Publish und kopiere die Webhook-URL aus dem Webhook-Node. Sie sieht etwa so aus:

https://n8n.deine-domain.de/webhook/dein-webhook-pfad

Schritt 5: URL in config.conf eintragen
Öffne /volume1/docker/openai-relay/config.conf und trage die URL ein:

N8N_WEBHOOK_URL=https://n8n.deine-domain.de/webhook/dein-webhook-pfad

Starte den openai-relay-Container neu. Jetzt sollte es funktionieren.

4.9.1 Nachtrag vom 30.08.2026: Den Webhook absichern

Bernd: „Den Pfad kennt doch keiner. dein-webhook-pfad – da kommt niemand drauf.“
Tanja: „Der Pfad steht in der Adresszeile, im Cloudflare-Protokoll, im Container-Log und in jeder Anleitung, die du im Netz veröffentlichst. Ein Pfad ist ein Türschild, kein Schloss.“
Ulf: „Und was passiert, wenn jemand ihn doch kennt?“
Tanja: „Er löst deinen Workflow aus, so oft er will. Jedes Mal eine E-Mail. Und wenn du später einmal etwas Teureres hinter den Webhook hängst, zahlst du dafür.“

Mit Authentication: None ist der Webhook für jeden auslösbar, der die Adresse kennt. Wir hängen deshalb eine Kennung davor: eine Kopfzeile, die das Relay mitschickt und die n8n bei jedem Aufruf prüft. Das ist eine Zeile Konfiguration auf jeder Seite – und der Unterschied zwischen „wer den Pfad kennt, kommt rein“ und „wer den Schlüssel nicht hat, bekommt 403″.

Schritt 1: Den Schlüssel erzeugen

Eine lange Zufallszeichenkette, im Terminal:

openssl rand -hex 32

Das Ergebnis brauchst du zweimal, identisch: einmal in n8n, einmal in der config.conf. Kopiere es einmal und füge es an beiden Stellen ein, statt den Befehl zweimal laufen zu lassen – sonst hast du zwei verschiedene Werte und suchst später den Fehler an der falschen Stelle.

Schritt 2: Zugangsdaten in n8n anlegen

In n8n: Credentials → Create credential → im Suchfeld Header Auth eingeben und auswählen. Dann:

  • Name der Zugangsdaten (oben, frei wählbar): z. B. Webhook Anruf Header
  • Feld NameX-Webhook-Token
  • Feld Value: deine Zeichenkette aus Schritt 1
  • Save

Danach im Webhook-Node selbst Authentication auf Header Auth stellen und die eben angelegten Zugangsdaten auswählen. Pfad und Methode bleiben unverändert.

Schritt 3: Das Relay schickt die Kopfzeile mit

In /volume1/docker/openai-relay/config.js direkt nach der Zeile mit N8N_WEBHOOK_URL einfügen:

N8N_WEBHOOK_HEADER: process.env.N8N_WEBHOOK_HEADER || 'X-Webhook-Token',
N8N_WEBHOOK_TOKEN: process.env.N8N_WEBHOOK_TOKEN || '',

In /volume1/docker/openai-relay/asterisk.js den Aufruf aus Abschnitt 4.7 suchen:

const req = client.request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
}, (res) => {

und ersetzen durch:

const kopfzeilen = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) };
if (config.N8N_WEBHOOK_TOKEN) {
kopfzeilen[config.N8N_WEBHOOK_HEADER || 'X-Webhook-Token'] = config.N8N_WEBHOOK_TOKEN;
}
const req = client.request(url, {
method: 'POST',
headers: kopfzeilen
}, (res) => {

Die Bedingung if (config.N8N_WEBHOOK_TOKEN) ist Absicht. Solange der Wert leer ist, verhält sich das Relay wie vorher. Du kannst den Code also gefahrlos einspielen, bevor der Schlüssel gesetzt ist – die Änderung am Code und die Änderung am Verhalten bleiben getrennt, und damit auch die Fehlersuche.

Zuletzt in /volume1/docker/openai-relay/config.conf:

# Webhook-Authentifizierung (muss mit den Header-Auth-Zugangsdaten in n8n uebereinstimmen)
N8N_WEBHOOK_HEADER=X-Webhook-Token
N8N_WEBHOOK_TOKEN=hier_deine_zeichenkette_aus_schritt_1

Ohne Anführungszeichen, ohne Leerzeichen vor oder hinter dem Gleichheitszeichen. Danach den Container openai-relay stoppen und starten. Im Protokoll meldet dotenv jetzt zwei Einträge mehr als vorher – das ist die einfachste Kontrolle, dass die neue config.conf wirklich gelesen wird.

Schritt 4: Veröffentlichen – die Falle, die uns erwischt hat

Ulf: „Gespeichert. Fertig?“
Tanja: „Nein. Schau oben rechts. Steht da ein Knopf Publish?“
Ulf: „Ja.“
Tanja: „Dann läuft draußen noch die zuletzt veröffentlichte Fassung. Deine Änderung liegt im Entwurf.“

Neuere n8n-Fassungen trennen Entwurf und veröffentlichte Fassung. Wer die Authentifizierung setzt und speichert, aber nicht veröffentlicht, hat eine Sperre eingebaut, die nicht wirkt – und merkt es nicht, weil in der Oberfläche alles richtig aussieht. Also: nach jeder Änderung am Webhook-Node auf Publish klicken.

Schritt 5: Beide Richtungen prüfen

Eine Sperre ist erst geprüft, wenn du beide Richtungen gemessen hast. Ein erfolgreicher Anruf zeigt nur, dass der Berechtigte durchkommt – nicht, dass der Unberechtigte draußen bleibt.

Vorwärts: Ruf deine Festnetznummer an. Im Protokoll des Containers muss stehen:

n8n webhook response for ...: 200

Steht dort 403, sind die beiden Werte nicht identisch. Dann noch einmal denselben Wert an beide Stellen einfügen.

Rückwärts: Ein Aufruf ohne die Kopfzeile. Das muss abgewiesen werden:

curl -i -X POST -H "Content-Type: application/json" -d '{}' \
  https://n8n.deine-domain.de/webhook/dein-webhook-pfad

HTTP/2 403
Authorization data is wrong!

Achtung, ein GET taugt als Test nicht. n8n prüft zuerst die Methode und erst danach die Kennung – ein GET auf den Pfad liefert deshalb immer 404 This webhook is not registered for GET requests, ganz gleich ob die Sperre steht oder nicht. Nur ein POST beweist etwas.

Und rechne damit, dass dieser Test beim ersten Mal durchgeht: Wenn statt 403 ein 200 mit {"message":"Workflow was started"} kommt, hast du vermutlich Schritt 4 übersprungen. Dann löst der Test den Workflow wirklich aus und du bekommst eine leere Zusammenfassungs-E-Mail – ärgerlich, aber genau der Hinweis, den du brauchst.

4.10 Optional: KI legt nach der Verabschiedung selbst auf

Ulf: „Legt die KI eigentlich selbst auf?“
Tanja: „Standardmäßig nicht. Sie verabschiedet sich und wartet dann. Der Anrufer muss auflegen.“
Ulf: „Das ist unhöflich. Ich würde das ändern.“
Tanja: „Du kannst. Es ist optional, aber es macht das Gespräch deutlich runder.“

Die Logik ist einfach: Wenn die KI sich verabschiedet – erkannt an Schlüsselwörtern im Transkript – startet ein Timer. Sagt der Anrufer innerhalb von 8 Sekunden nichts mehr, legt das System auf.
Alle Änderungen in /volume1/docker/openai-relay/openai.js:
Änderung 1: Nach der Zeile let transcriptLog = []; (ca. Zeile 95) einfügen:

  let goodbyeTimer = null;
  let goodbyeDetected = false;
  const GOODBYE_TIMEOUT_MS = 8000;
  const goodbyePattern = /wiedersehen|wiederh.ren|tsch.ss|sch.nen tag|guten tag noch/i;

Änderung 2: Im response.audio_transcript.done-Block, nach dem transcriptLog.push-Aufruf im else-Zweig (KI-Antwort), einfügen:

// Check if assistant said goodbye
              if (goodbyePattern.test(response.transcript)) {
                logger.info(`Goodbye detected for ${channelId}, starting ${GOODBYE_TIMEOUT_MS}ms hangup timer`);
                if (goodbyeTimer) clearTimeout(goodbyeTimer);
                goodbyeTimer = setTimeout(async () => {
                  logger.info(`Goodbye timeout reached for ${channelId}, hanging up`);
                  if (ws && ws.readyState === WebSocket.OPEN) {
                    ws.close();
                  }
                  try {
                    const cd = sipMap.get(channelId);
                    if (cd && cd.channel) {
                      await cd.channel.hangup();
                      logger.info(`Channel ${channelId} hung up after goodbye`);
                    }
                  } catch (e) {
                    logger.error(`Error hanging up channel ${channelId} after goodbye: ${e.message}`);
                  }
                }, GOODBYE_TIMEOUT_MS);
              }

Änderung 3: Im conversation.item.created-Block, nach if (response.item.role === 'user') { und vor lastUserItemId = ..., einfügen:

              if (goodbyeTimer) {
                clearTimeout(goodbyeTimer);
                goodbyeTimer = null;
                logger.info(`Goodbye timer cancelled for ${channelId}, user is speaking`);
              }

Dieser Teil ist wichtig: Wenn der Anrufer nach der Verabschiedung doch nochmal etwas sagt, wird der Timer abgebrochen. Die KI antwortet dann nochmals – und startet den Timer bei der nächsten Verabschiedung neu.
Danach Container neu starten.

5 Fazit

Einige Wochen später.
Ulf öffnet sein Handy. Eine E-Mail. Betreff: „Anruf von Klaus Müller (0171-…) – heute, 14:32 Uhr.“
Ulf öffnet sie. Vollständiger Gesprächsverlauf. Name, Anliegen, Dringlichkeit, alles da. Drei Minuten Gespräch, in einer lesbaren E-Mail zusammengefasst.
Ulf: „Hat funktioniert“.
Tanja: „Das weiß ich“.
Bernd schaut kurz rüber: „Ich habe auch eine Lösung gebaut. Ich habe meiner Frau gesagt, sie soll Anrufe entgegennehmen.“
Ulf: „Und?“
Bernd „Sie nimmt das Telefon nicht mehr ab.“

Was als Heimserver-Bastelprojekt begann, ist am Ende ein erstaunlich leistungsfähiges System. Ein Anrufer merkt kaum einen Unterschied zu einem echten Gespräch – die KI begrüßt freundlich, hört zu, fragt nach und verabschiedet sich. Wenige Sekunden später landet die vollständige Zusammenfassung im Postfach.

Was gut funktioniert: Der größte Vorteil dieser Architektur ist die vollständige Kontrolle über die eigene Infrastruktur. Asterisk, n8n und das Relay laufen lokal auf der Synology – nur die KI-Verarbeitung findet bei OpenAI statt. Keine monatlichen Abokosten für Telefonie-Dienste, keine Abhängigkeit von externen SIP-Anbietern, volle Transparenz über die Gesprächsdaten.

Wo es knifflig werden kann: Der schwierigste Teil ist erfahrungsgemäß die initiale Netzwerkkonfiguration – insbesondere network_mode: host, die Firewall-Regeln auf der Synology und die Cloudflare-Webhook-Freigabe. Wer hier sorgfältig vorgeht und jeden Schritt einzeln testet, spart sich viel Fehlersuche. Das Motto gilt auch hier: erst testen, dann weitermachen.

Mögliche nächste Schritte:

  • Unterschiedliche System-Prompts je nach Rufnummer oder Tageszeit
  • Mehrsprachige Unterstützung
  • Integration in einen Kalender, damit die KI direkt Rückruftermine vereinbaren kann
  • Erweiterung des n8n-Workflows mit einer KI-basierten Zusammenfassung via Claude oder ChatGPT

Bei Fragen oder Verbesserungsvorschlägen: Kommentar hinterlassen.

FOUNDIC.org ist werbefrei und ohne Bezahlschranke. Wenn dir diese Anleitung geholfen hat: Ko-fi donationsLade uns auf einen Kaffee ein

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Nach oben scrollen