One daemon that owns the whole chain.
Strowger replaces the cobbled-together telephony stack — a SIP server, a media bridge, glue scripts — with a single Rust process that holds SIP signaling, RTP media (G.711) and the real-time AI session. One binary, one config file, an HTTP API. Powered by the sipvox engine.
- Self-contained. Usable by any project through its HTTP API. Zero coupling to the rest of your stack.
- You own the media. Strowger holds the whole RTP path end to end — nothing to wire up between boxes, no glue layer to babysit. A whole class of plumbing bugs is gone by construction.
- Sovereign. CDRs, recordings, transcripts and signaling never leave your infrastructure. Conversation audio goes to one third party: the model provider you choose, under your keys and your DPA.
- Hard-won audio lessons encoded as requirements (playout buffer, latency, barge-in, watchdog) — months of production debugging are design constraints here, not discoveries to redo.
A Cargo workspace of reusable crates — sip, media,
ai, call, httpd — assembled by the
sipvoxd daemon. Each crate is usable as a library on its own.
# Build
cargo build --release
# Run (dev: empty API key disables auth)
SIPVOX_CONFIG=/etc/sipvox/config.toml ./target/release/sipvoxd
# Health check
curl -s http://127.0.0.1:7060/health
A single TOML file (path via SIPVOX_CONFIG), secrets interpolated from the
environment so the file stays versionable:
[daemon]
http_host = "127.0.0.1"
http_port = 7060
public_ip = "203.0.113.10" # public IP of the server (no STUN in v1)
data_dir = "/var/lib/sipvox" # SQLite, recordings
rtp_port_range = "22000-22999"
[ai.gemini]
api_key = "${SIPVOX_GEMINI_API_KEY}"
model = "gemini-3.1-flash-live-preview"
playout_ms = 200
[[trunk]]
name = "ovh-prod"
registrar = "sip-domain.example"
outbound_proxy = "oaXXXXXX-ovh-1.sip-proxy.example"
username = "0033XXXXXXXXX"
auth_username = "0033XXXXXXXXX"
password = "${SIPVOX_OVH_PASSWORD}"
number = "+33XXXXXXXXX"
transport = "udp"
register = true # false = IP-auth trunk (no REGISTER)
[[inbound_route]]
trunk = "ovh-prod"
did = "*184163651" # exact +E164 | *suffix | * catch-all
handler = { type = "webhook", url = "http://127.0.0.1:9100/api/voice/inbound?token=…" }
Each inbound INVITE is authorised and configured by a single webhook to your service
(see the API reference for the HTTP→SIP mapping and the 200 body). The
answer is sequenced so the caller never hears dead air, and a dead AI backend never
answers — zero billing. There is no local call-back logic: your service sees the
caller in the webhook and recognises a return call on its side.
These are non-negotiable requirements, learned in production:
- Emission playout buffer ≥ 200 ms by default (choppy voice = TTS underrun).
- Full-duplex with barge-in; conservative default threshold (500 ms is too sensitive).
- Silence watchdog: a soft "nudge" after N seconds without AI audio.
- TTFT measured and logged per call; alert above 1.5 s (reference: 0.83 s).
- Prompt size logged — latency grows with prompt size.
- Dedicated RTP range, never shared — a collision looks like mass "no answer".
- Codecs: PCMA + PCMU, 20 ms frames; symmetric RTP latching keeps inbound media flowing without brittle workarounds. Native two-way recording → mixed + per-leg WAV.
A VoiceBackend trait isolates the model. Backend v1 is Gemini Live
with its hard-won settings (configurable VAD/barge-in, silence watchdog, full-duplex, TTFT
measurement). OpenAI Realtime is the deferred v2 backend. Models are brought
with your own keys, billed at cost — no hidden margin. Tools invoked by the AI
during a call are relayed over HTTP to your service (proxy_url) and their
results aggregated into the final callback.
- HTTP API: loopback bind +
X-API-Key. Never exposed publicly without your reverse proxy. - SIP: inbound INVITE/OPTIONS outside the allowlist (resolved registrar/proxy IPs + config CIDRs) are dropped silently + counted. Inbound REGISTER is always rejected (Strowger is not a registrar).
- Secrets: env/config
0600only. Never in logs, never in the API, never in callbacks (SIP auth is masked). - Recordings: opt-in per call,
0700directory, automatic retention.
tracing with call_id as a structured field everywhere;
Prometheus metrics (registrations by state, calls by status/direction, AI TTFT, prompt
size, playout underruns, RTP loss, anti-scan drops, proxy tool latency); one log file per
call with the full SIP timeline + AI events + tool calls.
cargo build --release
sudo ./deploy/install.sh target/release/sipvoxd
# then edit /etc/sipvox/config.toml and /etc/sipvox/sipvox.env (secrets)
sudo systemctl enable --now sipvox
journalctl -u sipvox -f
Shutdown (SIGTERM) is graceful: the API closes, then the call worker, then a clean
un-REGISTER of the trunks. Secrets live in /etc/sipvox/sipvox.env
(0640 root:sipvox), interpolated into config.toml via
${VAR} — never in the repo or the unit.
Strowger is licensed per concurrent channel — one channel = one simultaneous
call. The binary activates online against a licence server and renews a signed lease
(Ed25519); the lease duration is the offline tolerance (7 days for dated licences,
30 for perpetual). Capacities (max_concurrent_calls, trunks, recording, AI)
are values read from the verified lease — not booleans to flip. On lease expiry, only
new calls are refused; calls in progress always finish normally, and
/health turns degraded with a clear reason.
Without any licence, Strowger runs in a free self-service mode — capped at 1 trunk, 1 channel and a 1-minute maximum call duration, with community docs and AI email support. Enough to wire it to your trunk and hear it answer; activate a licence to lift the caps. See pricing and download.