Getting started

From a bare Debian box to your first answered call.

Install the binary, edit one config file, activate a free demo licence, and place a call — all inside one morning.

Install

On Debian/Ubuntu with systemd, the one-liner detects your architecture, downloads and checksum-verifies the signed binary, sets up the systemd unit, and leaves a config skeleton to complete:

curl -fsSL https://lic.strowger.io/install.sh | sudo bash

To pin a version or target, download the script first:

curl -fsSL https://lic.strowger.io/install.sh -o get-strowger.sh
sudo bash get-strowger.sh --version 1.0.0-rc1 --target x86_64-unknown-linux-gnu

The installer verifies the SHA-256 checksum of the tarball before installing anything (and the minisign signature too, if minisign is present and a public key is configured) — it refuses to install on a mismatch. It then delegates to deploy/install.sh, which is idempotent and safe to re-run to upgrade the binary in place.

Building and installing from source works the same way, once you have a release binary:

cargo build --release
sudo ./deploy/install.sh target/release/strowgerd

deploy/install.sh creates a system user/group strowger, installs the binary at /opt/strowger/bin/strowgerd (and strowger-cli alongside it, symlinked into /usr/local/bin if present next to the daemon binary), writes a config skeleton to /etc/strowger/config.toml from config.example.toml (without overwriting an existing one), creates /etc/strowger/strowger.env, installs the systemd unit, and reserves the configured RTP port range in net.ipv4.ip_local_reserved_ports so ephemeral client sockets on the host never steal a port strowger needs. Re-run it whenever rtp_port_range changes.

Configuration

Everything lives in one TOML file, /etc/strowger/config.toml, whose path is read from the STROWGER_CONFIG environment variable. Secrets are never written in the clear here — they are interpolated from the environment with ${VAR_NAME} syntax so the file itself stays safe to version or hand to a colleague.

[daemon]

The core process settings: where the HTTP API binds, the public IP the SIP stack identifies itself with, and where state lives on disk.

[daemon]
http_host = "127.0.0.1"
http_port = 7060
public_ip = "203.0.113.10"        # REQUIRED — SIP identity; no STUN in v1
data_dir  = "/var/lib/strowger"   # strowger.db (SQLite) + recordings/ + cdr/
rtp_port_range = "20002-40001"    # default RTP media port range

public_ip is required — the daemon needs it to build correct SIP/SDP for your trunk. http_port defaults to 7060. Under the shipped systemd unit, ProtectSystem=strict makes everything except data_dir read-only, so data_dir must stay /var/lib/strowger in production; only change it for a local dev run outside systemd.

[ai.gemini] / [ai.openai]

The real-time speech-to-speech backend. Gemini Live is the default; OpenAI Realtime is a second backend with the same shape. Each call can override the model, and the API key can also be supplied per call (falling back to the config key if omitted).

[ai.gemini]
api_key    = "${STROWGER_GEMINI_API_KEY}"
model      = "gemini-3.1-flash-live-preview"
playout_ms = 200                  # emission playout buffer, in ms

# [ai.openai]                     # mirrors [ai.gemini]
# api_key = "${STROWGER_OPENAI_API_KEY}"

Without an API key configured, the AI backend is disabled and calls run in echo mode instead — useful for bench testing the SIP/RTP path in isolation.

[[trunk]]

One block per SIP trunk. Two authentication modes: REGISTER (credentials, register = true) or IP-auth (register = false — no REGISTER, the carrier authenticates by source IP and the OPTIONS keepalive is the liveness signal).

[[trunk]]
name            = "ovh-prod"
registrar       = "sip-domain.io"
outbound_proxy  = "oa285772-ovh-1.sip-proxy.io"
username        = "0033184163651"
auth_username   = "0033184163651"
password        = "${STROWGER_OVH_PASSWORD}"
number          = "+33184163651"
transport       = "udp"
register        = true            # false = IP-auth trunk, no REGISTER

[inbound] and [[inbound_route]]

Transverse inbound settings, then one route per DID. A route's handler is either a webhook (the consumer authorises and configures every call live) or static (objectives and first message fixed in the TOML).

[inbound]
answer_delay_ms      = 3000   # 180 Ringing -> 200 OK delay at answer
initial_silence_ms   = 1000   # silence before the agent's first word
webhook_timeout_secs = 5      # webhook GET timeout; on expiry -> SIP 486

[[inbound_route]]
trunk   = "ovh-prod"
did     = "+33184163651"      # exact +E164 match

[inbound_route.handler]
type    = "webhook"
url     = "http://127.0.0.1:9100/api/voice/inbound?token=secret"

[[inbound_route]]
trunk         = "ovh-prod"
did           = "*184163652"  # suffix match (>= 6 digits)

[inbound_route.handler]
type          = "static"
objectives    = "You are the company's receptionist…"
first_message = "Hello, how can I help you?"
language      = "en"

The did field of a route accepts three forms, matched after normalising to digits only (+, spaces, dots, dashes and URI params stripped):

PatternMeaning
+33184163651exact match — does not match a 0033…/0… presentation of the same number
*184163651suffix match (≥ 6 digits); 9 digits = a French national number, invariant across presentation formats — the sensible default
*catch-all for the trunk

Resolution is deterministic: exact > longest suffix > catch-all. Two patterns that normalise to the same value are rejected at load time.

Further optional knobs exist for advanced tuning — media watchdogs and RTP latch policy under [media], VAD/barge-in/silence handling under [ai.gemini]/[ai.openai], and daemon-level call/shutdown deadlines — see the commented reference in config.example.toml in the repository for the full list and their defaults.

Agent Call Transfer

Strowger supports autonomous agent call transfer (call deflection / human escalation) powered by the built-in transfer_call tool in Gemini Live and OpenAI Realtime.

Configure transfer policy and security whitelists in config.toml:

# /etc/strowger/config.toml
[transfer]
enabled       = true          # Enable transfer_call tool declaration (default: false)
mode          = "refer"       # "refer" (carrier delegation) or "hairpin" (bridged 2nd leg)
max_per_call  = 3             # Anti-loop protection: max transfers allowed per call
allow         = [             # Whitelist: exact E.164 (+331...), prefix (+33184*), or SIP URI
  "+33144556677",
  "+33184*",
  "sip:*@company.com",
]
deny          = [             # Blacklist overrides allow
  "+33184163999",
]
drain_timeout_ms   = 4000    # Playout drain before issuing transfer
refer_timeout_ms   = 15000   # Timeout waiting for carrier REFER/NOTIFY progression
hairpin_timeout_ms = 30000   # Timeout waiting for 2nd leg answer in hairpin mode

Transfer Modes:

  • refer (Default): Performs an in-dialog SIP REFER (RFC 3515) to delegate call routing to the upstream carrier/PBX, releasing audio and channels upon transfer.
  • hairpin: Originates a second outbound leg directly to the target destination, cross-connects bidirectional 8 kHz PCM audio between caller and target (A↔C), cleanly releases the AI voice session, and supervises both legs with cascading BYE.
AI backends & keys

The voice intelligence is a real-time speech-to-speech model, reached over a WebSocket. You bring your own provider key — audio goes to exactly one third party, the model provider you choose, under your own contract and DPA, billed at cost with no markup from us. The model is chosen per backend and can be overridden per call.

GEMINIGemini Live — default

Default backend. Reference model gemini-3.1-flash-live-preview. Get a key from Google AI Studioaistudio.google.com/apikey (or a Google Cloud service account). Set it as STROWGER_GEMINI_API_KEY.

OPENAIOpenAI Realtime

Same contract as a second backend. Get a key from the OpenAI platformplatform.openai.com/api-keys. Set it as STROWGER_OPENAI_API_KEY.

Put the keys in the secrets file (never in config.toml or the repo); the config references them by ${VAR}:

# /etc/strowger/strowger.env   (0640 root:strowger)
STROWGER_GEMINI_API_KEY=AIza...your-key...
# STROWGER_OPENAI_API_KEY=sk-...your-key...

Pick the backend per call with the backend field of the inbound webhook / POST /calls body (default: the configured backend). A call may also override model and carry its own key, falling back to the config key. With no key configured at all, calls run in echo mode (SIP/RTP path only, no AI).

Secrets

Real secret values never go in config.toml — and never in the repo. They live in /etc/strowger/strowger.env, created mode 0640 root:strowger by the installer, and are interpolated into the config file via ${VAR} at startup:

# /etc/strowger/strowger.env — 0640 root:strowger, never versioned
STROWGER_OVH_PASSWORD=...
STROWGER_GEMINI_API_KEY=...
STROWGER_OPENAI_API_KEY=...

The systemd unit loads this file as its EnvironmentFile, so the variables are only ever visible to the strowger service user, never in the unit file, never logged, and scrubbed from any HTTP payload that carries them.

Systemd
sudo systemctl enable --now strowger
journalctl -u strowger -f

Shutdown (systemctl stop, SIGTERM) is graceful: the HTTP API closes first, then the call worker, then the daemon cleanly un-REGISTERs its trunks, all within TimeoutStopSec=30. If you update the binary and re-run install.sh, note that daemon-reload does not restart the running process — the new binary only takes effect after a systemctl restart strowger.

Licensing & activation

The shipped binary enforces its licence — this is compiled into the binary, not a config toggle. Without an activated licence, new calls are refused. Activate it once, after the daemon is running:

# Free demo — light identification, keyless
strowger-cli register --company "Acme SARL" --contact "Jane Doe" \
  --email [email protected] --phone +33612345678

# Commercial licence issued in advance
strowger-cli register --key <activation-key>

The demo is capped at 1 trunk, 1 channel, calls limited to 60 seconds, with a 30-day sliding validity that renews itself as long as the daemon keeps talking to the licence server. Licensing is per concurrent channel: capacities such as max_concurrent_calls, max_trunks and max_call_seconds are values read straight from the verified licence, not booleans you can flip locally.

On expiry or revocation, only new calls are refused — a call already in progress always finishes normally, and /health reports degraded with a reason rather than the daemon stopping outright. To lift the demo caps, contact [email protected]: the same license_id is upgraded server-side, and the daemon picks up the new capacities on its next contact with the licence server — no reinstall, no new key to enter.

CDRs & recordings

Everything strowger writes to disk lives under data_dir (/var/lib/strowger under systemd): the SQLite state file (strowger.db), a recordings/ directory (opt-in per call), and a cdr/ directory holding the daily append-only journal.

Per-call CDRs are also pushed straight to your callback_url at the end of every call — this is the recommended way to consume them; see the API reference for the full callback payload and the CDR field list.

Your first call

With a trunk registered and a licence activated, you're ready to place or receive a call. The full POST /calls contract, the inbound authorisation webhook, and the end-of-call callback are documented in the API reference.