Loading...
 
Skip to main content

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:

Copy to clipboard
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:

Copy to clipboard
/etc/asterisk/extensions_custom.conf


Contenido:

Copy to clipboard
[from-internal-custom] exten => 1001,1,NoOp(HAL 9000 via ARI) same => n,Answer() same => n,Stasis(hal9000) same => n,Hangup()


Luego se recargó:

Copy to clipboard
fwconsole reload


Verificación:

Copy to clipboard
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:

Copy to clipboard
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:

Copy to clipboard
/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:

  • 1003Jorge


El ARI controller escribe contexto de llamada por canal en:

Copy to clipboard
/run/hal-call-context/


Y el bridge RTP/OpenAI lee ese contexto para ajustar el saludo.

Saludo fijo final para Jorge:

Copy to clipboard
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:

Copy to clipboard
/root/.config/hal-ari.env


Variables típicas:

Copy to clipboard
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:

Copy to clipboard
systemctl daemon-reload systemctl enable --now hal-rtp.service hal-ari.service


Verificación:

Copy to clipboard
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:

Copy to clipboard
Local/<numero>@from-internal


En el caso del celular de Jorge:

Copy to clipboard
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:

Copy to clipboard
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:

Copy to clipboard
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:

Copy to clipboard
/root/.openclaw/workspace/ari-hal/ari_outbound_test.py


Este script usa ARI para originar una llamada hacia:

Copy to clipboard
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:

Copy to clipboard
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:

Copy to clipboard
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:
Copy to clipboard
Local/<8+numero>@from-internal


Ejemplo:

Copy to clipboard
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.
```