On-prem AI voice gateway: Asterisk PBX + local STT/LLM/TTS, no cloud
75
Hands-on guide for installing, operating, and tuning the on-prem AI IP-PBX for an
ISP. See README.md (overview), PRD.md (product), SRD.md (spec).
| Service | Container | Role | How you reach it |
|---|---|---|---|
Asterisk PBX (ast) | andrius/asterisk | Call routing, WebRTC endpoint, ARI | SIP trunk :5060/u+tcp · WS :8088/ws (thru caddy for the web phone) · RTP 10000–10050/udp |
AI agent (agent) | ipbx-agent | Stasis app: answer → record → STT → LLM → TTS | Admin UI/API :8000 |
LLM (llm) | ollama/ollama | Local LLM (gemma2:2b) | internal http://llm:11434 |
Web phone (webphone) | caddy:2-alpine | HTTPS portal + SIP-over-WSS proxy + reverse proxy to agent | https on host :443 (http :80 and :8080 redirect) |
| Models | volume | VAD/ASR/TTS (Silero / Whisper int8 / Piper) | read-only /models inside agent |
caller ─▶ SIP trunk (5060) ─▶ Asterisk ── WSS:/ws (via caddy :443) ── browser JSSIP phone 2000
│
└─ ARI ─▶ agent ─▶ llm (ollama) ─┐
admin UI/API │
Note on port 80: if another web server (e.g. Apache) binds host :80, the web phone container is published as
8080:80/443:443so nothing conflicts; adjustdocker-compose.ymlif you prefer a different scheme.
cd ipbx
cp .env.example .env
Edit .env — at minimum change these three secrets before exposing the box:
| Variable | Example | Meaning |
|---|---|---|
ARI_PASSWORD | agent-secret-change-me | Agent↔Asterisk ARI auth |
WEBPHONE_PASSWORD | 1122 | Password of web extension 2000 |
ADMIN_TOKEN | change-me-admin-token | Bearer token for every admin API call |
Then:
make models # STT/VAD/TTS/espeak data + browser JSSIP bundle (~350 MB)
make up # builds images, starts stack, pulls the LLM on first boot
make status # sanity: asterisk channels + pjsip endpoints
docker compose ps
Everything is healthy when all four services report (healthy).
| What | URL | Notes |
|---|---|---|
| Web phone (agent phone) | https://localhost | Register ext 2000, dial 1000 to chat with the AI. Accept the self-signed cert once (or install §9.1). |
| Admin dashboard | http://localhost:8000/admin | Paste ADMIN_TOKEN. Everything else below is the same UI/API. |
| Admin API | http://localhost:8000/api/* | Authorization: Bearer $ADMIN_TOKEN |
| Recordings | listed in admin Recordings tab | WAV files also at spool/recording/ (see §6) |
Browsers lock the microphone on non-HTTPS origins. Always open the phone via https://localhost (the caddy TLS). Use hostname or IP as needed — the cert is issued on demand for any name → browser only trusts
localhost.
2000).1000 ▶ call. The AI answers with a Hindi/English greeting and listens.0 (DTMF) on the phone keypad at any time —
the caller (and context) is warm-transferred to extension 2000.1000 is the AI.LISTEN/SPEAK/TRANSFER), language, uptime;
hangup any call; open a per-call transcript.agent/configs/ivr.yaml in the browser: greeting text/language,
transfer number, intents ("operator"/"representative", your ISP FAQs). Saves
instantly; new calls use it.type=aor
section named exactly like the extension number (e.g. 2100), plus an
auth with matching username/password. Asterisk matches the REGISTER's
To-username to the AOR name strictly, so a aor2100-style name returns 404.
Passwords must match the WEBPHONE_* conventions.ast-spool
volume at spool/recording/ (/media/spool/recording in the agent container,
/var/spool/asterisk/recording in .s asterisk container). The dir is created
and owned by uid 1000 automatically on agent boot..env after manual edit).Admin API summary: GET /api/calls · GET /api/calls/{id}/transcript ·
POST /api/calls/{id}/hangup · GET|PUT /api/ivr · GET|POST /api/extensions ·
GET /api/recordings · GET|POST /api/config/llm · GET /api/system.
TOKEN=$(grep ADMIN_TOKEN .env | cut -d= -f2)
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/system
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/calls
docker compose exec llm ollama pull gemma2:2b.env (LLM_MODEL), then
docker compose restart llm agent. gemma2:2b is balanced for CPU;
gemma2:9b is better Hindi but slower and needs GPU.agent/config.yaml → call.* (record length,
silence-stop, playback timeout) and llm.temperature/max_tokens/stream../config/asterisk
and ./agent/configs/ivr.yaml (edit in the filesystem directly).config/, agent/configs/ivr.yaml, .env, and ast-spool
(e.g. docker run --rm -v ipbx_ast-spool:/d -v $PWD:/b alpine cp -a /d /b/spool-backup).make down stops the stack (volumes kept). docker compose down -v wipes
recordings/models volumes — only after re-running make models.make logs (all), make logs-agent (agent only).
Agent logs one line per STT/reply; Asterisk ARI/WS activity under
docker compose logs asterisk.The caddy webphone/Caddyfile uses :443 { tls internal { on_demand } }, so any
hostname (localhost, LAN IP, phone.local) gets an internal CA cert. For a public
name, edit webphone/Caddyfile: https://phone.yourisp.example { tls ... } and
let caddy obtain a real cert, then change the :80 block and docker compose restart webphone.
The web phone is published 8080:80 + 443:443 by default. Visit
http://localhost:8080 (redirects to https) or drop a reverse-proxy server block
that does proxy_pass https://127.0.0.1:443, proxy_set_header Host $host,
plus proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; for /ws*.
Web phone (JSSIP) → wss://localhost/ws → caddy /ws* → asterisk:8088/ws
(the PJSIP WebSocket transport listens on Asterisk's HTTP port, not the 5061
declared in pjsip.conf). Verify end-to-end with a raw SIP REGISTER; you should
see SIP/2.0 401 Unauthorized (digest challenge) when the password is wrong/absent.
Route PSTN DIDs to context inbound in config/asterisk/extensions.conf; any
channel entering inbound is picked up by Stasis(ipbx-agent) and handled by
the AI. Media for calls is Asterisk 8 kHz → STT auto-resamples to 16 kHz; make
sure UDP 10000–10050 is open for RTP.
| Symptom | Likely cause → fix |
|---|---|
models errors on boot | Not downloaded → make models; check paths in agent/config.yaml |
| Browser TLS warning on the phone | Internal Caddy CA — accept it, or install the cert (§9.1) |
| Mic blocked in browser | You're on http:// — use https://localhost |
| Web phone won't register | Wrong ext/password, or WS path broke — check .env WEBPHONE_PASSWORD vs pjsip endpoint 2000, Caddyfile /ws* → asterisk:8088, endpoint has webrtc=yes. Raw SIP REGISTER must 401, then 200 with correct digest. |
| Calls work but no greeting/audio | Two gotchas: (1) the andrius/asterisk image declares VOLUME /var/lib/asterisk/sounds, which shadows the shared volume — compose must mount ast-var:/var/lib/asterisk/sounds and the agent must write TTS to the same volume root (AST_SOUNDS_DIR=/media/sounds = ast-var root). (2) Asterisk's .wav format is 8 kHz only — Piper outputs 22.05 kHz; the TTS engine resamples to 8 k (don't "fix" that). Symptom is Playback failed for sound:tmp/… / Unable to open format wav in docker compose logs asterisk. |
| Calls work but no outbound media on LAN | WebRTC SDP must advertise a reachable IP — local_net (the docker net) + external_media_address + endpoint media_address set to the host LAN IP in pjsip.conf; verify answer SDP shows c=IN IP4 <LAN-IP>. |
Record → 500/Permission denied | spool/recording must exist & be owned by uid 1000 (agent fixes on boot). Manually: docker compose exec agent chown -R 1000:1000 /media/spool/recording |
| Empty-STT warnings in logs | Recording held no speech (silent caller) — expected; fallback reply plays, loop continues. |
| LLM slow / no reply | Check docker compose logs llm, make status; pull the model (§7); try gemma2:2b |
| ARI unreachable | ari.conf password vs .env ARI_PASSWORD; ports 8088/8089 free |
| I want to see a real answer in a terminal | docker compose exec -w /app agent python3 -c "from core.llm_client import LLMClient; print(next(LLMClient('http://llm:11434','gemma2:2b').stream_chat([{'role':'user','content':'hi'}])))" |
.env (see §3) — do not ship defaults.:443 (web phone), SIP trunk + RTP 10000–10050 (to trunk peer).
:8000, :8088, :8089, :11434 should stay off the public internet.llm isn't published to the host).docker compose down -v + change ARI password before a demo, and never
re-use the default admin bearer token.Content type
Image
Digest
sha256:00491801d…
Size
174.9 MB
Last updated
1 day ago
docker pull indianprogrammer/ipbx-agent