OpenClaw + FreePBX + OpenAI Realtime (HAL 9000)
Objetivo
Integrar OpenClaw con FreePBX para que una extensión interna (en este caso la 1001) sea atendida por un asistente de voz basado en OpenAI Realtime, usando Asterisk/ARI + External Media, sin depender de Twilio, Telnyx o Plivo.
Resumen de la arquitectura
En lugar de registrar OpenClaw como un teléfono SIP usando pjsua2 en Python (ruta que resultó inestable por fallos en callbacks SWIG), la solución funcional fue:
- FreePBX/Asterisk maneja la telefonía
- Asterisk enruta la extensión 1001 a una aplicación ARI
- La aplicación ARI responde la llamada y crea un bridge
- Asterisk crea un canal External Media
- OpenClaw recibe RTP por UDP, lo envía a OpenAI Realtime, y devuelve audio RTP al bridge
- El llamante escucha la respuesta generada por OpenAI en tiempo real
Esto resultó mucho más estable que intentar registrar un softphone SIP en Python.
Requisitos verificados
En el PBX se confirmó lo siguiente:
- Asterisk 18.20.2
- ARI cargado:
- res_ari.so
- res_ari_events.so
- res_ari_channels.so
- res_ari_bridges.so
- HTTP/ARI habilitado en:
- http://pbx.fratec.net:8088/ari/...
- WebSocket ARI disponible
Comandos útiles usados para verificar:
asterisk -rx "core show version" asterisk -rx "module show like ari" asterisk -rx "http show status"
Ruta fallida inicial: SIP directo con Python + pjsua2
Primero se intentó registrar la extensión 1001 como endpoint SIP desde Python usando pjsua2.
Resultados:
- El registro SIP sí funcionó
- FreePBX sí mostraba 1001 online por momentos
- Pero el proceso se caía con segmentation fault
Se depuró con strace y gdb.
Hallazgo clave:
- el crash ocurría dentro de callbacks SWIG de pjsua2
- especialmente alrededor de SwigDirector_Account::onRegState y SWIG_Python_NewPointerObj
Conclusión:
- la ruta Python pjsua2 + callbacks no era confiable para producción en este entorno
Decisión de arquitectura
Se descartó la integración SIP directa y se adoptó este enfoque:
- Asterisk/FreePBX como motor SIP y de llamadas
- ARI para control de llamadas
- External Media para enviar/recibir RTP
- OpenAI Realtime para inteligencia y voz
- OpenClaw como puente de control y medios
Configuración del dialplan en FreePBX
Se agregó una entrada en:
/etc/asterisk/extensions_custom.conf
Contenido:
[from-internal-custom] exten => 1001,1,NoOp(HAL 9000 via ARI) same => n,Answer() same => n,Stasis(hal9000) same => n,Hangup()
Luego se recargó:
fwconsole reload
Verificación:
asterisk -rx "dialplan show 1001@from-internal" asterisk -rx "dialplan show 1001@from-internal-custom"
Resultado esperado:
- la extensión 1001 debe entrar primero por from-internal-custom
- y luego ejecutar Stasis(hal9000)
Configuración de ARI
Crear el usuario ARI Una vez habilitada la interfaz en FreePBX, puede administrar cuentas de usuario específicas: Vaya a Configuración > Usuarios de la interfaz REST de Asterisk.
Haga clic en el botón Agregar usuario. Proporcione los siguientes detalles:
1 REST Interface User Name: Ingrese el nombre de usuario deseado.
2 REST Interface User Password: Ingrese una contraseña segura (evite caracteres especiales si encuentra problemas de conexión).
3 Read Only: Seleccione No. Eso es importante si el usuario necesita realizar acciones (como originar llamadas) o Sí para solo monitoreo.
Haga clic en Enviar y luego en Aplicar configuración.
Verificación:
curl -u hal9000:TU_PASSWORD_FUERTE http://pbx.fratec.net:8088/ari/asterisk/info
Si todo está bien, devuelve JSON con información de Asterisk.
Estructura creada en OpenClaw
Directorio de trabajo:
/root/.openclaw/workspace/ari-hal/
Archivos principales:
- hal_ari_mvp.py
- MVP inicial que respondía llamadas y reproducía una locución de prueba
- hal_ari_bridge_mvp.py
- versión que crea bridge + External Media
- rtp_listener_mvp.py
- listener RTP para confirmar recepción de audio desde Asterisk
- rtp_echo_mvp.py
- eco RTP para confirmar que el audio de retorno funcionaba
- realtime_rtp_bridge.py
- bridge final RTP ↔ OpenAI Realtime
Pruebas realizadas paso a paso
1. Prueba ARI mínima
Se desarrolló un app ARI mínimo que:
- se conecta por WebSocket a ARI
- escucha StasisStart
- responde la llamada
- reproduce un audio de ejemplo
Resultado:
- al llamar de 1003 a 1001, se escuchó el anuncio correctamente
Esto confirmó:
- ARI operativo
- ruta de llamada correcta
- control de llamadas funcional
2. Prueba de bridge + External Media
Luego se extendió el ARI app para:
- contestar la llamada
- crear un mixing bridge
- agregar el canal del llamante
- crear un canal External Media
- agregar ese canal al bridge
Hubo un detalle importante:
- si se intentaba agregar el canal External Media demasiado rápido, Asterisk devolvía:
- 422 Channel not in Stasis application
La solución fue:
- esperar a que el canal External Media emitiera su propio StasisStart
- y solo entonces agregarlo al bridge
Resultado:
- bridge funcional
- canal External Media correctamente unido
3. Confirmación de RTP entrante
Se creó un listener UDP en el puerto 40000.
Se observó RTP real desde el PBX, por ejemplo:
- payload type 0 (PCMU/uLaw)
- secuencia y timestamps correctos
- paquetes de audio constantes
Esto confirmó:
- Asterisk enviaba RTP correctamente hacia OpenClaw
4. Confirmación de RTP saliente
Se creó un pequeño echo RTP que devolvía el payload recibido al PBX.
Resultado:
- al llamar, se escuchaba la propia voz del usuario de regreso
Esto confirmó:
- RTP bidireccional funcionando
- audio de retorno correctamente inyectado en la llamada
5. Bridge con OpenAI Realtime
Se desarrolló realtime_rtp_bridge.py para:
- recibir RTP ulaw a 8 kHz
- decodificarlo a PCM
- remuestrear a 24 kHz
- enviarlo a OpenAI Realtime
- recibir audio generado
- remuestrear a 8 kHz
- codificarlo a ulaw
- encapsularlo en RTP
- devolverlo al PBX
Resultado:
- el usuario llamó y escuchó respuestas generadas por OpenAI en la llamada
Esto validó todo el pipeline:
- FreePBX → ARI → External Media → OpenAI Realtime → RTP de vuelta
Conversación natural: control de turnos
Al principio, HAL hablaba demasiado después del saludo.
Se corrigió implementando lógica básica de turn-taking en realtime_rtp_bridge.py:
- saludo inicial
- esperar al usuario
- detectar energía de voz (RMS)
- acumular audio del usuario mientras habla
- detectar silencio
- enviar a OpenAI solo cuando el usuario termina su turno
- esperar respuesta única
- volver a modo escucha
Estados conceptuales usados:
- WAITING_FOR_GREETING
- WAITING_FOR_USER
- USER_SPEAKING
- PROCESSING
- ASSISTANT_SPEAKING
Esto hizo que la interacción se sintiera mucho más natural.
Personalización para Jorge / extensión 1003
Se añadió detección del caller ID:
- 1003 → Jorge
El ARI controller escribe contexto de llamada por canal en:
/run/hal-call-context/
Y el bridge RTP/OpenAI lee ese contexto para ajustar el saludo.
Saludo fijo final para Jorge:
Buenos días, Jorge. Hal al habla. ¿En qué puedo ayudarte?
Además, se ajustó la personalidad para:
- hablar en español por defecto con Jorge
- sonar calmado, preciso y breve
- estilo HAL 9000 sin exagerar
Resultado:
- al llamar desde 1003, HAL saluda correctamente a Jorge y espera a que hable
Servicios systemd en OpenClaw
Se creó archivo de entorno:
/root/.config/hal-ari.env
Variables típicas:
ARI_BASE=http://pbx.fratec.net:8088 ARI_USER=hal9000 ARI_PASS=******** EXTERNAL_HOST=200.59.16.16:40000 EXTERNAL_FORMAT=ulaw OPENAI_API_KEY=******** OPENAI_REALTIME_MODEL=gpt-4o-realtime-preview OPENAI_VOICE=alloy OPENAI_INSTRUCTIONS=... RTP_HOST=0.0.0.0 RTP_PORT=40000 CALL_CONTEXT_PATH=/run/hal-call-context
Servicios creados:
- /etc/systemd/system/hal-ari.service
- /etc/systemd/system/hal-rtp.service
hal-ari.service:
- controlador ARI
- atiende llamadas
- crea bridge + External Media
hal-rtp.service:
- bridge RTP ↔ OpenAI Realtime
- puerto UDP 40000
- audio bidireccional
Activación:
systemctl daemon-reload systemctl enable --now hal-rtp.service hal-ari.service
Verificación:
systemctl --no-pager --full status hal-rtp.service hal-ari.service
Resultado final:
- ambos servicios quedaron activos y persistentes
Archivos clave del proyecto
En OpenClaw:
- /root/.openclaw/workspace/ari-hal/hal_ari_bridge_mvp.py
- /root/.openclaw/workspace/ari-hal/realtime_rtp_bridge.py
- /root/.config/hal-ari.env
- /etc/systemd/system/hal-ari.service
- /etc/systemd/system/hal-rtp.service
En FreePBX:
- /etc/asterisk/extensions_custom.conf
- /etc/asterisk/ari.conf
Lecciones aprendidas
- No insistir con pjsua2 en Python para esta integración si aparecen fallos SWIG/callbacks
- Asterisk/ARI + External Media es una arquitectura mucho más sólida
- Conviene separar:
- control de llamadas (ARI)
- media bridge (RTP/OpenAI)
- primero validar ruta PBX
- luego RTP entrante
- luego RTP saliente
- finalmente OpenAI
Este orden ahorra muchísimo tiempo de depuración.
Estado final
La integración quedó funcional con estas capacidades:
- llamar de 1003 a 1001
- HAL responde la llamada
- HAL saluda a Jorge por nombre
- HAL habla en español
- HAL espera a que Jorge hable
- HAL responde usando OpenAI Realtime
- todo corre persistentemente como servicios systemd
Siguientes mejoras recomendadas
- rotar la contraseña ARI si fue expuesta en pruebas
- mejorar barge-in / interrupciones
- afinar la personalidad HAL
- conectar acciones reales por voz:
- Nagios
- Home Assistant
- correo
- endurecer manejo de errores y reconexión
- registrar logs de conversación si hace falta auditoría
Conclusión
La forma correcta de conectar OpenClaw con FreePBX no fue registrar un cliente SIP Python, sino usar:
- FreePBX/Asterisk para telefonía
- ARI para control
- External Media para audio
- OpenAI Realtime para la voz e inteligencia
Ese enfoque resultó estable, funcional y apropiado para producción incremental.
```
Apéndice: llamadas salientes desde OpenClaw por FreePBX
Objetivo
Permitir que HAL/OpenClaw origine llamadas externas a través de FreePBX usando la ruta de salida normal del PBX.
Regla de marcado
En este PBX, para hacer una llamada saliente hay que marcar:
- 8 + número
Ejemplo real:
- celular de Jorge: 88372837
- marcado desde el PBX: 888372837
Esta regla debe recordarse para cualquier automatización futura de llamadas salientes.
Principio de implementación
No se debe adivinar ni forzar la troncal SIP manualmente si FreePBX ya tiene rutas de salida configuradas.
La forma correcta es originar una llamada hacia el dialplan interno usando:
Local/<numero>@from-internal
En el caso del celular de Jorge:
Local/888372837@from-internal
Esto permite que FreePBX:
- aplique sus rutas de salida normales
- seleccione la troncal correcta
- mantenga la lógica del PBX en un solo lugar
Verificaciones realizadas en el PBX
Se revisó el contexto de rutas salientes con:
asterisk -rx "dialplan show outbound-allroutes"
Resultado relevante:
- el contexto outbound-allroutes incluía rutas como:
- outrt-1
- outrt-3
También se observó que la central estaba usando el stack SIP clásico, no PJSIP:
asterisk -rx "sip show peers"
Se detectaron peers SIP válidos y una troncal/peer externo operativo.
Aun así, para llamadas salientes desde HAL, se optó por Local/...@from-internal para dejar que FreePBX resolviera la salida.
Script de prueba de llamada saliente
Se creó el archivo:
/root/.openclaw/workspace/ari-hal/ari_outbound_test.py
Este script usa ARI para originar una llamada hacia:
Local/888372837@from-internal
Variables relevantes:
- ARI_BASE
- ARI_USER
- ARI_PASS
- ARI_APP
- OUTBOUND_ENDPOINT
- OUTBOUND_CALLER_ID
Ejecución de prueba
Ejemplo de ejecución:
cd /root/.openclaw/workspace/ari-hal export ARI_BASE=http://pbx.fratec.net:8088 export ARI_USER=hal9000 export ARI_PASS='TU_PASSWORD_ARI' .venv/bin/python -u ari_outbound_test.py
El endpoint por defecto del script fue:
Local/888372837@from-internal
Resultado de la prueba real
La prueba saliente fue exitosa:
- HAL originó la llamada por FreePBX
- el celular de Jorge sonó
- se sostuvo una conversación funcional
Esto confirmó que:
- OpenClaw puede originar llamadas externas a través del PBX
- la regla 8 + número es correcta
- el método Local/...@from-internal funciona adecuadamente
Regla operativa para el futuro
Para llamadas salientes automáticas desde HAL/OpenClaw en este PBX:
- tomar el número destino
- anteponer 8
- originar la llamada por:
Local/<8+numero>@from-internal
Ejemplo:
Local/888372837@from-internal
Recomendación
Mantener este patrón como estándar para futuras funciones como:
- “Hal, llama a Jorge”
- “Hal, llama a mi celular”
- recordatorios por llamada
- llamadas automáticas programadas
Así se aprovecha la lógica normal de FreePBX y se evita acoplar la integración a una troncal específica.
```