Minimal diff: one blockquote after the status note. The from-source page stays the developer path; the Docker quickstart is now the operator fast path (slice-G docs tree). Signed-off-by: Aaron D. Lee <himself@adlee.work>
7.8 KiB
Quickstart
Get Rutster running and hear your own voice echoed back in under 5 minutes.
Status: Slices 1–4 (WebRTC media core, WS tap, OpenAI Realtime brain, barge-in / VAD-driven playout kill) are merged to
main. Slice 4½ (sim/benchmark harness) + step 5 (PSTN via rented Twilio Media Streams transport) are the active build targets, in flight. This quickstart's first section exercises the slice-1 WebRTC media loopback (the simplest end-to-end demo onmain); the optional "Make a real phone call" section below covers step-5 PSTN ingress. Seedocs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.mdfor the active build target's design.
Fastest path: if you want a running deployment — TLS, a domain, a phone number — skip the source build and use the Docker quickstart:
docs/deploy/quickstart-docker.md. This page is the from-source developer path.
Prerequisites
1. Rust toolchain
Install via rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
The repo pins a specific stable channel in rust-toolchain.toml — rustup
will pick it up automatically on first cargo invocation. No manual
toolchain selection needed.
2. libopus (FFI dependency)
The opus crate links system libopus via FFI (per PORT_PLAN §7's
"🦀 Core (FFI)" disposition — Opus is the codec surface Rust doesn't need
to re-implement). Install the dev headers:
| Platform | Command |
|---|---|
| Debian/Ubuntu | sudo apt-get install -y libopus-dev |
| Fedora | sudo dnf install -y opus-devel |
| Arch | sudo pacman -S opus |
| macOS (Homebrew) | brew install opus |
Verify: pkg-config --cflags opus should print a path with no error.
That's the only system dependency in slice 1. Everything else is pure Rust from crates.io.
Run the server
cargo run
# listening on http://0.0.0.0:8080
First build takes ~2 minutes (str0m + axum + tokio compile fresh). Subsequent builds are incremental.
Hear the echo
- Open a browser to http://localhost:8080/.
- Click Start call.
- Grant microphone permission when the browser prompts.
- Speak — you should hear yourself back within ~200 ms (no perceptible delay).
- Click Hang up to tear down. The server logs
Closing → Closedfor the session.
Verbose tracing for debugging:
RUST_LOG=rutster=debug cargo run
Make a real phone call (PSTN via Twilio Media Streams, optional)
The WebRTC loopback above proves the media core + the reflex loop. Spearhead step 5 takes it to a real phone number: a PSTN caller dials your Twilio number, Twilio forks the call's audio over a WebSocket to rutster, and the same reflex loop (barge-in, brain tap) runs against the PSTN leg — no first-party SIP stack (ADR-0007).
Prerequisites
- A Twilio account (trial works).
- A Twilio phone number capable of Voice + Media Streams.
- A publicly-reachable HTTPS URL for rutster (Twilio must call back to you).
For local dev, use ngrok to tunnel
localhost:8080to a public URL:ngrok http 8080.
Twilio credentials
Set these environment variables to enable PSTN ingress:
| Env var | Purpose | Example |
|---|---|---|
RUTSTER_TWILIO_ACCOUNT_SID |
Twilio account ID | ACxxx... |
RUTSTER_TWILIO_AUTH_TOKEN |
Twilio auth token (secret; never logged) | xxx... |
RUTSTER_TWILIO_MEDIA_BIND |
Where rutster's Media Streams WSS server binds | 0.0.0.0:8081 |
RUTSTER_TWILIO_WEBHOOK_BASE |
Your public URL Twilio calls back to | https://your.ngrok.io |
Without these set, the binary runs WebRTC-only (slices 1–4 ingress + barge-in).
With them set, the binary accepts PSTN fork calls against /twilio/media-stream.
Run with Twilio enabled
The live Twilio REST client is feature-gated behind twilio-live — the routine
CI gate uses the in-process mock; you only need the live client for a real call:
export RUTSTER_TWILIO_ACCOUNT_SID=AC...
export RUTSTER_TWILIO_AUTH_TOKEN=...
export RUTSTER_TWILIO_MEDIA_BIND=0.0.0.0:8081
export RUTSTER_TWILIO_WEBHOOK_BASE=https://your.ngrok.io
cargo run --features=twilio-live
Point Twilio at rutster
- In the Twilio console, configure your phone number's A CALL COMES IN
webhook to
POSTtohttps://your.ngrok.io/v1/trunk/webhook. - Twilio answers an inbound call, hits your webhook; rutster responds with
TwiML instructing Twilio to
<Connect><Stream>againstwss://your.ngrok.io/twilio/media-stream. - Twilio opens the WSS; rutster's
TwilioMediaStreamsServerreceives the call's audio (µ-law @ 8 kHz), decodes viaG711Codecto 24 kHz PCM, and feeds the same reflex loop + brain tap as a WebRTC leg.
Make an outbound call
curl -X POST http://localhost:8080/v1/trunk/sessions \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","from":"+15550000000"}'
The handler calls TwilioCallControlClient::originate; Twilio places the call
and forks the audio back — the PSTN caller reaches the reflex loop.
Auth note: the
/v1/trunk/*routes are not authenticated in slice 5 (authn defers to slice 6, the spend cap). Do not expose them to the public internet without a reverse-proxy auth layer.
What's different from WebRTC
Nothing, architecturally. The PSTN leg enters the same MediaThread tick loop
via a MediaLeg::Trunk(TrunkSession) variant (the WebRTC leg is MediaLeg::WebRTC).
The reflex stack (Reflex<TapAudioPipe> + LocalVadReflex) is reused
verbatim from slice 4 — barge-in fires on PSTN caller speech the same way it
fires on WebRTC caller speech. The only difference is the ingress: str0m's RTP
decode (WebRTC) vs the Media Streams WSS pump + G.711 codec (trunk).
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
error: linking with cc failed / could not find opus |
libopus dev headers not installed. Re-run the install command above. |
| Browser shows no mic prompt | Another tab/app holding the mic, or mic permissions disabled for localhost. Check browser settings. |
ICE connection failed in the browser |
Shouldn't happen on loopback (host candidates only). If it does, check the server console for the str0m error. |
| Click Start call, nothing happens | Open the browser console (F12). The page logs ICE state + connection state to a <pre> element. Look for the failure there. |
| Port 8080 already in use | Set RUTSTER_HTTP_BIND, e.g. RUTSTER_HTTP_BIND=0.0.0.0:8090 cargo run -p rutster |
The browser test page at GET / is a single self-contained HTML file
with inline JS — no build step. View source to see exactly what the
client side is doing.
What's happening
When you click "Start call":
- Browser captures microphone audio via
getUserMedia. - Browser creates an
RTCPeerConnectionand generates an SDP offer (audio-only, Opus codec). - Browser POSTs the offer to
POST /v1/sessions/:id/offer. - The Rutster core (built on
str0m, a sans-IO WebRTC implementation) accepts the offer, generates an SDP answer with its DTLS fingerprint + ICE credentials. - Browser sets the answer as remote description; ICE + DTLS handshake completes.
- RTP starts flowing: browser → core terminates DTLS-SRTP → decodes Opus to 16-bit PCM @ 24 kHz mono → echoes PCM back → re-encodes to Opus → DTLS-SRTP → browser plays it.
The "codec-to-PCM boundary" is the canonical point where, in a future slice, the audio tap for an external AI brain splices in. Slice 1 just echoes; step 2 of the spearhead swaps the echo for a real tap.
For the why, see ARCHITECTURE.md. For the dev loop,
see DEVELOPMENT.md.