Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ringo

ringo

Make and test phone calls from your terminal.

Two tools that share one engine:

  • ringo-phone — a terminal softphone: manage SIP accounts and place calls without leaving the keyboard.
  • ringo-flow — a telephony scenario test runner: write call flows as Rhai scripts and run them headlessly in CI.

The source is on GitHub.

For tooling/agents: llms.txt indexes the docs, and the ringo-flow scenario API is available as Rhai type definitions (.d.rhai).

Introduction

ringophone

ringo is a terminal SIP softphone built on baresip, with a full ratatui TUI. It manages multiple accounts side by side — each with its own profile, call history and configuration — while keeping baresip running headless in the background.

It’s part of the ringo workspace; the crate is ringo-phone, the binary is ringo.

Features

  • Profile picker — fuzzy-search selector with inline create / edit / clone / delete.
  • Embedded baresip — baresip/libre linked in directly via FFI; no separate process, no stdio clutter.
  • ratatui TUI — status bar, command bar (: with tab-completion), Normal/Dial split, call list, DTMF, hold/resume, mute.
  • Contact book — TOML contacts with fuzzy number matching and $EDITOR editing.
  • Blind & attended transfer with a contact picker.
  • Call history (per-profile, redial) and dial history (global, Ctrl+R).
  • MWI message-waiting indicator.
  • Theming — every UI color configurable, with ready-made themes.
  • Remote control — drive a running session from another terminal or a script.
  • Multiple instances — each profile gets its own dynamically assigned port.

Next steps

For scripted, multi-agent telephony testing (assertions, audio, HTTP), see the companion tool ringo-flow.

Getting started

Install

baresip is built in and statically linked — no separate baresip install needed.

Homebrew (macOS / Linux):

brew install davidborzek/tap/ringo

Arch Linux (AUR) — prebuilt, via any AUR helper:

yay -S ringo-phone-bin   # or: paru -S ringo-phone-bin

Pre-built binaries for Linux and macOS (x86_64 + arm64) are on the releases page — download, extract and put ringo on your $PATH.

From crates.io:

cargo install ringo-phone

From GitHub (no clone needed):

cargo install --git https://github.com/davidborzek/ringo ringo-phone

Nix (flake):

nix profile install github:davidborzek/ringo#ringo-phone

Or manage ringo and its SIP profiles declaratively with the Home-Manager module.

Homebrew 6.0+ requires third-party taps to be trusted before use. If brew install prompts you to trust the tap, accept it — or trust it up front:

brew tap davidborzek/tap
brew trust --formula davidborzek/tap/ringo

Quick start

ringo        # open the profile picker → Ctrl+N to create your first profile

Fill in your SIP credentials in the form, press Enter to save, then select the profile and press Enter to launch. See Profiles for the fields.

Usage

ringo              # open the profile picker (default)
ringo start <name> # launch a specific profile directly
ringo list         # list all profiles
ringo list --plain # one name per line (for scripting)
ringo list --json  # as a JSON array

From here, Using the TUI covers the keybindings, and Remote control covers driving a running session from a script.

Profiles

Each SIP account is a profile, stored as TOML at ~/.config/ringo/profiles/<name>/profile.toml. Create and edit profiles right in the picker (Ctrl+N / Ctrl+E), or write the file by hand:

username     = "user123"
password     = "secret"
domain       = "sip.example.com"
display_name = "My Name"               # optional
transport    = "tls"                   # optional: udp, tcp, tls
outbound     = "sip:proxy.example.com" # optional
stun_server  = "stun:stun.example.com" # optional
media_enc    = "dtls_srtp"             # optional
notify       = true                    # desktop notifications (default: true)
mwi          = true                    # message-waiting indicator (default: true)

Password from a file or command

Instead of the inline password, ringo can resolve it at launch — handy for secret managers and for keeping the password out of the profile file:

password_file = "~/.secrets/sip"     # read the password from this file
# or:
password_cmd  = "pass show sip/work" # run a command; its stdout is the password

Precedence is password_cmd > password_file > password. A single trailing newline is stripped, and a leading ~/ in password_file expands to your home directory. The command runs via sh -c; a non-zero exit or an unreadable file fails the launch. Both fields are also editable in the profile form.

Custom SIP headers

Add headers to every outgoing INVITE. Order is preserved and duplicate keys are allowed (e.g. RFC 4244 History-Info). Values are percent-encoded for baresip’s uaaddheader — write them as plain text, no manual escaping. The ${uuid} placeholder is replaced per call with a fresh UUIDv4 (shared across all headers in the same INVITE); use $$ for a literal $.

custom_headers = [
  ["History-Info", "<sip:1@example.com>;index=1"],
  ["History-Info", "<sip:2@example.com>;index=1.1"],
  ["X-Trace-Id",   "call-${uuid}"],
]

File locations

PathDescription
~/.config/ringo/ringo.tomlGlobal config
~/.config/ringo/contacts.tomlContact book
~/.config/ringo/profiles/<name>/profile.tomlProfile config
~/.config/ringo/profiles/<name>/call_historyPer-profile call history (JSONL)
~/.local/share/ringo/historyGlobal dial history
~/.local/state/ringo/<name>.logApplication log (hooks, errors, lifecycle); $XDG_STATE_HOME
/tmp/ringo-baresip-<pid>/baresip conf dir (empty; auto-cleaned on exit)

Using the TUI

Launching a profile opens the ratatui interface: a status bar (registration, MWI), the call list, and a mode line. Below are the keybindings per mode and overlay.

Profile picker

KeyAction
EnterStart selected profile
Ctrl+NCreate new profile
Ctrl+EEdit selected profile
Ctrl+YClone selected profile
Ctrl+DDelete selected profile (confirmation)
/ Navigate (wrap-around)
EscQuit

Normal mode (default)

KeyAction
dEnter Dial mode
aAccept incoming call
b / DelHang up
h / rHold / Resume
mToggle mute
t / TBlind / attended transfer
0-9 * #DTMF tones (during a call)
f / TabOpen contacts (Tab switches calls when several are active)
e / l / cEvent log / baresip log / call history
Ctrl+RFuzzy-search dial history
Ctrl+EEdit profile (no active call)
Ctrl+PSwitch profile (back to picker)
:Open the command bar
q / Ctrl+CQuit (with / without confirmation)

Dial mode

KeyAction
EnterDial and return to Normal mode
EscCancel
BackspaceDelete character / exit when empty
/ Home EndMove / jump the cursor
/ Navigate dial history
TabOpen contacts
Ctrl+RFuzzy-search dial history

Transfer mode

KeyAction
EnterExecute the transfer
TabOpen contacts
/ / Ctrl+RDial-history navigation / search
EscCancel

Contacts overlay

KeyAction
/ , g / GNavigate / jump to top-bottom
EnterSelect number (dial or transfer)
/Search
a / e / dAdd / edit / delete contact
EOpen contacts in $EDITOR
f / EscClose

Command bar

Open with :. Tab-completes commands; Enter runs, Esc closes.

Commands: dial <n>, hangup, accept, hold, resume, mute, dtmf <digits>, transfer <uri>, contacts, events, log, history, edit, switch, help, quit.

Call history / log views

KeyAction
/ , g / GNavigate / jump
Enter(history) copy peer to dial input — redial
/(history) search
d / D(history) delete entry / clear all
e / l / cSwitch between event log / baresip log / call history
EscClose

Remote control

Drive a running session from another terminal — or a script — over a per-session Unix socket. ctl is an alias for control.

ringo control list                   # running sessions: PID, profile, account
ringo control -t <target> <command> [args]

# examples
ringo control -t work dial 4711      # target by profile name
ringo control -t 215709 hangup       # ...or by PID
ringo control -t work dtmf 123#      # send DTMF into the active call
ringo control -t work status         # registration + active calls

<target> is a profile name or a PID — use the PID (from ringo control list) when a name is awkward to type or the same profile runs more than once.

Commands: dial <n>, hangup, accept, hold, resume, mute, dtmf <digits>, transfer <uri>, status, shutdown.

Headless sessions

For scripting and automated testing, run a session without the TUI — it still binds the control socket and registers, so you drive it entirely via ringo control:

ringo start --headless work &    # runs in the background, no terminal needed
ringo control -t work status     # …drive it…
ringo control -t work shutdown   # stop it cleanly (or Ctrl-C the process)

JSON output

Add --json (-j) for machine-readable output: list emits an array of sessions, status a structured object (registration, active calls, and the most recently closed call under last_call with its reason/duration), and every other command an { "ok", "data", "error" } envelope. The exit code reflects success.

ringo control list --json
ringo control -t work status --json
ringo control -t work dial 4711 --json   # {"ok":true,"data":"Dialing 4711","error":null}

For full Rhai-scripted telephony test scenarios (multiple agents, assertions, audio verification), see ringo-flow.

Configuration

Global config lives at ~/.config/ringo/ringo.toml. Everything below is optional.

Picker subtitle

[picker]
# Fields shown next to each profile name in the picker. Available: aor, username,
# domain, display_name, transport, auth_user, outbound, stun_server, media_enc.
info = ["aor"]   # default

Theme

All UI colors are configurable — named values or #rrggbb hex.

RoleDefaultUsed for
accentcyanLogo, picker selection, DTMF input, history popup
subtledark_grayHints, log text, subtitles, unfocused labels
successgreenRegistered, established call, toggle on
dangerredMuted, missed calls, registration failed
attentionyellowSelected call, ringing, MWI, focused field
transfermagentaTransfer-mode input
[theme]
accent    = "cyan"
subtle    = "dark_gray"
success   = "green"
danger    = "red"
attention = "yellow"
transfer  = "magenta"

Ready-made themes (Catppuccin Mocha, Gruvbox, Nord, Tokyo Night) live in themes/.

baresip

ringo auto-detects the audio driver; override any of these in ringo.toml:

[baresip]
audio_driver = "pipewire"                   # an audio driver compiled into your build; auto-detected if unset
audio_player_device = "default"
audio_source_device = "default"
audio_alert_device  = "default"
sip_cafile   = "/etc/ssl/certs/ca-certificates.crt"  # SIP TLS CA file
sip_capath   = "/etc/ssl/certs"                       # CA dir ("" to disable)

# Arbitrary baresip config overrides, appended last (last value wins).
# ⚠️ Incorrect values can break ringo. See the baresip Configuration wiki.
[baresip.extra]
dns_server     = "10.0.0.1:53"
call_max_calls = "8"

Contacts

Contacts live at ~/.config/ringo/contacts.toml; names resolve in the call list and history, and numbers match across formats (01555…, +491555…, 491555…).

[[contacts]]
name = "Alice"
numbers = ["+491555123456", "alice.work"]

Manage them in the TUI (contacts overlay → a/e/d) or with $EDITOR (E).

Hooks

Run shell commands on events; each hook gets context via environment variables and runs in a background thread (errors go to the log at $XDG_STATE_HOME/ringo/<name>.log, default ~/.local/state/ringo/<name>.log).

[[hooks]]
event = "call_incoming"
command = "notify-send 'ringo' \"Call from $(echo $RINGO_EVENT_DATA | jq -r .number)\""
EventTriggerEvent data
profile_loadedProfile loaded, baresip spawned
call_incomingIncoming callcall_id, number, display_name
call_outgoingOutgoing call initiatedcall_id, number
call_endedCall closedcall_id, number, direction, duration_secs, reason, error

Each hook receives RINGO_EVENT, RINGO_PROFILE, RINGO_PROFILE_JSON (no password) and RINGO_EVENT_DATA (JSON).

Integrations

Shell completions

Completions are dynamic — profile names complete from your actual profiles under ~/.config/ringo/profiles/.

# fish — ~/.config/fish/config.fish
COMPLETE=fish ringo | source
# bash — ~/.bashrc
source <(COMPLETE=bash ringo)
# zsh — ~/.zshrc
source <(COMPLETE=zsh ringo)

After sourcing, ringo start <Tab> completes profile names.

rofi

cp scripts/ringo-rofi ~/.local/bin/

# sway / i3
bindsym $mod+p exec ringo-rofi

ringo-rofi uses $TERMINAL if set, otherwise tries kitty, alacritty, foot, wezterm, xterm.

tmux

cp scripts/ringo-tmux ~/.local/bin/
ringo-tmux

ringo-tmux uses fzf for multi-select profile picking and opens each selected profile in its own pane within a ringo tmux session. Requires tmux and fzf.

Call history format

One JSON object per line:

{"ts":"2024-01-15 14:30:05","dir":"outgoing","peer":"sip:alice@example.com","duration":"02:05:13","duration_secs":7513}
cat ~/.config/ringo/profiles/<name>/call_history | jq .

Home-Manager module

The flake ships a programs.ringo Home-Manager module that installs ringo and manages its configuration declaratively. Import it and enable:

{
  inputs.ringo.url = "github:davidborzek/ringo";

  # in your Home-Manager configuration:
  imports = [ ringo.homeManagerModules.default ];

  programs.ringo = {
    enable = true;
    settings.theme = "tokyo-night";
    profiles.work = {
      settings = {
        username = "alice";
        domain = "sip.example.com";
        regint = 600;
        audio_codecs = [ "opus" "PCMU" ];
      };
      passwordFile = config.sops.secrets."ringo/work".path;
    };
  };
}

Two config layers, two mutabilities

ringo has two kinds of config file, and they need different handling under Nix:

FileRewritten by the TUI?How the module manages it
~/.config/ringo/ringo.toml (theme, picker, baresip, hooks)No — ringo only reads itRead-only symlink into the store, from settings.
~/.config/ringo/profiles/<name>/profile.toml (SIP account)Yes — add/clone/edit/rename and in-call edit all call save()Rendered at activation as a real, writable 0600 file (a store symlink would be read-only and break the TUI’s write).

Because the TUI writes profiles back, a declaratively-managed profile and the TUI are two writers of the same file. Each profile’s mutable flag picks who wins (mirroring users.mutableUsers):

  • mutable = false (default) — Nix is the source of truth. The profile is rewritten on every home-manager switch; TUI edits to it are reverted on the next switch. Keeps it reproducible.
  • mutable = true — the profile is written only if it doesn’t exist yet, then left to the TUI. Later changes to settings/passwordFile in Nix are NOT propagated (delete the file, or flip to false once, to re-apply).

Profiles you don’t declare are never touched, so you can always create ad-hoc profiles in the TUI alongside the managed ones.

Options

OptionDefaultDescription
enablefalseInstall ringo and manage its config.
packageflake’s ringo-phonePackage providing the ringo binary.
settings{}Contents of ~/.config/ringo/ringo.toml. See Configuration.
profiles.<name>{}Declarative SIP profiles (see below).

Each profiles.<name>:

FieldDefaultDescription
settings{}The profile’s fields, minus the password (at least username and domain). Maps 1:1 to the profile schema.
passwordFilenullPath written to ringo’s native password_file. ringo reads the password from it at launch — the secret never enters profile.toml or the store. Point it at a runtime secret (e.g. a sops-nix secret path).
passwordCommandnullCommand written to ringo’s native password_cmd; ringo runs it at launch and uses its stdout. Call a secret manager — don’t embed the secret literally.
passwordnullInline plaintext. Discouraged — rendered into profile.toml and thus into the world-readable store. Prefer passwordFile/passwordCommand.
mutablefalseIf true, seed the profile once then leave it to the TUI; if false, Nix rewrites it on every switch (see above).

Set at most one of passwordFile / passwordCommand / password per profile.

Secrets

Prefer passwordFile or passwordCommand: they render ringo’s native password_file / password_cmd keys, so ringo resolves the password at launch and no secret is ever written into profile.toml or the Nix store. With sops-nix:

sops.secrets."ringo/work" = { sopsFile = ./secrets/ringo.yaml; };

programs.ringo.profiles.work = {
  settings = { username = "alice"; domain = "sip.example.com"; };
  passwordFile = config.sops.secrets."ringo/work".path;
};

sops-nix decrypts the secret to a runtime path (e.g. under /run/user/<uid>); ringo reads it fresh on each launch.

Only the inline password lands at rest (in profile.toml, and in the store via the rendered TOML) — which is why it’s discouraged. passwordFile and passwordCommand keep the secret out of both.

Introduction

ringoflow

ringo-flow is a declarative telephony scenario test runner for baresip. You write a scenario as a small Rhai script — bring up SIP agents, place and answer calls, assert on call state, DTMF, audio and HTTP — and run it headlessly, e.g. in CI.

let dom = env("SIP_DOMAIN");

let a = agent("A", #{
    username: env("A_USER"),
    domain: dom,
    password: env("A_PASS"),
});
let b = agent("B", #{
    username: env("B_USER"),
    domain: dom,
    password: env("B_PASS"),
});

a.register();
b.register();
await_until(|| assert(b.registered).is_true(), "10s");

a.dial(b);
await_until(|| assert(b.state).equals(State::Ringing), "15s");
b.accept();
await_until(|| assert(a.state).equals(State::Established));
a.hangup();

Highlights

  • Headless — virtual audio, no devices needed; runs on a build server.
  • Suitessetup / scenario / teardown, each scenario isolated with fresh agents. Select with --scenario, tag with --tag / --exclude-tag, disable with skip, focus with only.
  • Audio — send tones / files and assert what the other side receives (Goertzel tone detection).
  • HTTP — call backend APIs mid-scenario, and stand up a built-in mock server to test webhook-driven call control.

Next steps

The Rust library API is on docs.rs.

Getting started

Install

baresip is built in and statically linked — no separate baresip install needed.

Homebrew (macOS / Linux):

brew install davidborzek/tap/ringo-flow

Arch Linux (AUR) — prebuilt, via any AUR helper:

yay -S ringo-flow-bin   # or: paru -S ringo-flow-bin

Pre-built binaries for Linux and macOS (x86_64 + arm64) are on the releases page — download, extract and put ringo-flow on your $PATH.

From crates.io:

cargo install ringo-flow

From GitHub (no clone needed):

cargo install --git https://github.com/davidborzek/ringo ringo-flow

From a workspace checkout (no install):

cargo run -p ringo-flow -- run scenario.rhai

Nix (flake):

nix profile install github:davidborzek/ringo#ringo-flow

To run scheduled monitors as a systemd service, use the NixOS module.

Homebrew 6.0+ requires third-party taps to be trusted before use. If brew install prompts you to trust the tap, accept it — or trust it up front:

brew tap davidborzek/tap
brew trust --formula davidborzek/tap/ringo-flow

Run a scenario

Credentials and the SIP domain come from the environment (via env(...)), so nothing sensitive lives in the script:

SIP_DOMAIN=example.com A_USER=alice A_PASS=… B_USER=bob B_PASS=… \
  ringo-flow run scenario.rhai
ringo-flow run scenario.rhai     # one file
ringo-flow run scenarios/        # a directory (all *.rhai, recursively)
ringo-flow check scenario.rhai   # syntax-check only (no SIP traffic)

The exit code is non-zero if any scenario fails.

Useful flags

  • --scenario <pattern> — run a subset by name (re: for a regex).
  • --tag <tag> / --exclude-tag <tag> — filter by tag (repeatable, comma-separated).
  • --env-file FILE — load variables for env(...) (a sibling <scenario>.env is layered on top per file).
  • --log [<file>] — write the backend/SIP log to stderr (or a file); off by default.
  • --sip-trace [<file>] — trace every SIP request/response to its own destination (stderr, or a file); separate from --log, off by default. A .pcap path writes a capture for sngrep/Wireshark — see Debugging.
  • --save-audio — save sent/received WAVs to the working directory.
  • --json — emit NDJSON events (for CI).
  • -q / -v, --no-color.

See ringo-flow run --help for the full list.

Your first scenario

Let’s write a complete test: two agents place, answer and tear down a call. We’ll build it line by line — every concept you need for most scenarios is here.

You’ll need two SIP accounts.

The whole script

Save this as first.rhai:

let dom = env("SIP_DOMAIN");

let a = agent("A", #{
    username: env("A_USER"),
    domain: dom,
    password: env("A_PASS"),
});
let b = agent("B", #{
    username: env("B_USER"),
    domain: dom,
    password: env("B_PASS"),
});

a.register();
b.register();
await_until(|| assert(a.registered).is_true(), "10s");
await_until(|| assert(b.registered).is_true(), "10s");

a.dial(b);
await_until(|| assert(b.state).equals(State::Ringing), "15s");
b.accept();
await_until(|| assert(a.state).equals(State::Established));

wait(3); // the call must stay up
a.hangup();
await_until(|| assert(a.state).equals(State::Idle), "10s");

Run it:

SIP_DOMAIN=example.com A_USER=alice A_PASS=… B_USER=bob B_PASS=… \
  ringo-flow run first.rhai

Line by line

Credentials from the environment. env("SIP_DOMAIN") reads a variable, so no secrets live in the script. Pass them as shown above, or from an --env-file.

Create the agents. agent(name, #{ … }) connects a headless baresip instance and returns a handle you drive with verbs. name is just a label used in the log. See the Agents reference for every config field.

Register, then wait for it. SIP is asynchronous: register() only starts registration. await_until(|| <assertion>, "10s") re-runs the assertion until it holds or the timeout elapses — never sleep and hope. assert(a.registered) reads the agent’s state; .is_true() checks it.

Place the call. a.dial(b) calls B at its address (you can also dial a number or SIP URI as a string). We then wait until B is ringingb.state is one of State::Idle / State::Ringing / State::Established.

Answer and connect. b.accept() answers; both sides become Established. await_until without a timeout uses the default (overridable with default_timeout(...)).

Hold, then hang up. wait(3) holds for three seconds — and fails if an established call drops in that window, so it doubles as a stability check. a.hangup() ends the call; we confirm both return to Idle.

What failure looks like

Assertions report expect … — actual …, and the exit code is non-zero if any assertion fails — so this runs cleanly in CI. Add -v to see every assertion, or --log (SIP signaling to stderr, or --log <file>) when something’s off.

Next

Writing scenarios

A scenario is a Rhai script. The top level can be the whole test, or you can register several named scenarios as a suite.

Agents and call control

agent(name, #{ … }) connects a headless baresip instance and returns a handle you drive with verbs — register, dial, accept, hangup, hold, dtmf, transfer, … See Agents for the full set, the config options and the readable state (registered, state, …).

await_until

SIP is asynchronous, so assertions are polled: await_until re-runs an assert(...) until it holds or a timeout elapses. Use it instead of sleeping.

a.dial(b);
await_until(|| assert(b.state).equals(State::Ringing), "15s");

The matchers — equals, is_true, contains, … — are all on the assertion handle.

Suites: setup / scenario / teardown

setup() runs before each scenario and returns the context passed to it; each scenario(name, body) runs in isolation with fresh agents; teardown() runs after each (even on failure).

setup(|| {
    let caller = agent("Caller", #{
        username: env("A_USER"),
        domain: env("SIP_DOMAIN"),
        password: env("A_PASS"),
    });
    caller.register();
    await_until(|| assert(caller.registered).is_true(), "10s");
    #{ caller: caller }
});

scenario("answered call", #{ tags: ["smoke"] }, |ctx| {
    ctx.caller.dial("+49301234567");
    await_until(|| assert(ctx.caller.state).equals(State::Established), "15s");
});

Selecting, tagging and skipping

The scenario(name, #{ … }, body) options control which scenarios run:

  • Tags#{ tags: ["smoke"] }, then --tag smoke / --exclude-tag slow.
  • Skip#{ skip: true | "reason" } disables a scenario statically; or call skip("reason") at runtime (e.g. env-gated).
  • Focus#{ only: true } runs only the focused scenario(s), run-wide.

Skipped scenarios are reported but don’t fail the run.

More

Audio testing

ringo-flow runs baresip with virtual audio, so it can both play audio into a call and check what the other side receives — headless, no devices, CI-safe.

Send audio

agent.send_audio(source) switches the agent’s active-call audio source:

a.send_audio(tone(440));          // a 440 Hz sine tone
a.send_audio(file("prompt.wav")); // a WAV file
a.send_audio(silent());           // stop sending

tone, file and silent build an AudioSpec.

Verify what’s received

agent.verify_audio(freq, within) asserts the agent is receiving a tone at freq Hz within the time window (detected with a Goertzel filter):

a.send_audio(tone(440));
b.verify_audio(440, "5s"); // B hears A's tone within 5s

For a quick two-way check, verify_audio_connection(a, b) sends a tone each way and asserts both arrive:

verify_audio_connection(a, b);

Debugging

Run with --save-audio to write each agent’s sent/received WAVs to the working directory, so you can listen to what actually flowed.

See the Agents → Methods reference for the exact signatures.

Call quality

Beyond was there audio (Audio testing), ringo-flow can assert on how good the audio was — the RTP media metrics each agent reports for its call, grouped under agent.quality:

FieldMeaningUnit
agent.quality.mosEstimated Mean Opinion Score1.0 (bad) – 4.5 (excellent)
agent.quality.rttRound-trip timemilliseconds
agent.quality.jitterReceive-side inter-arrival jittermilliseconds
agent.quality.packet_lossReceive-side packet losspercent

The MOS is an estimate from the simplified ITU-T G.107 E-model, derived from latency, jitter and loss — a single number to gate call quality on.

When the values are available

The metrics come from RTCP reports, which the peers exchange only about every ~5 seconds. So right after the call is established each field is () (not present) — let the call run a few seconds first:

await_until(|| assert(caller.quality.mos).is_present(), "10s");

The values are snapshotted when the call closes, so they survive the hangup — you can read or assert on them after the call, not just during it.

Example

scenario("call quality", |ctx| {
    ctx.caller.dial(ctx.callee);
    await_until(|| assert(ctx.callee.state).equals(State::Ringing));
    ctx.callee.accept();
    await_until(|| assert(ctx.caller.state).equals(State::Established));

    // Let RTCP accumulate, then wait for the first report:
    await_until(|| assert(ctx.caller.quality.mos).is_present(), "10s");

    let q = ctx.caller.quality;
    log(`caller → MOS ${q.mos} · RTT ${q.rtt}ms · jitter ${q.jitter}ms · loss ${q.packet_loss}%`);

    ctx.caller.hangup();
    await_until(|| assert(ctx.caller.state).equals(State::Idle));

    // The snapshot survives the hangup — assert on the final values:
    assert(ctx.caller.quality.mos).at_least(4.0);
    assert(ctx.caller.quality.packet_loss).at_most(1.0);
    assert(ctx.caller.quality.rtt).at_most(150);
});

The values are raw floats (e.g. MOS 4.236…). To shorten a log line, round in Rhai: let mos = (caller.quality.mos * 100.0).round() / 100.0;.

Exporting metrics

To record these values without writing assertions — e.g. for trend monitoring — run with --metrics. ringo-flow then emits a per-agent metric event (MOS, jitter, loss, RTT + registered) at each scenario’s end, which a machine consumer can scrape from the --json stream.

Debugging

When a scenario misbehaves, two flags give you visibility into what the SIP backend is doing. Both are off by default and write nowhere unless you ask.

The backend log

--log [<file>] writes the backend’s log — registration, call state, module output — to stderr, or to a file if you pass a path:

ringo-flow run scenario.rhai --log            # → stderr
ringo-flow run scenario.rhai --log run.log    # → file

SIP tracing

--sip-trace [<file>] traces every SIP request and response (sent and received), to its own destination — separate from --log, so you can keep the protocol trace clean:

ringo-flow run scenario.rhai --sip-trace            # → stderr (text)
ringo-flow run scenario.rhai --sip-trace sip.txt    # → text file

Each message is printed as a timestamped block with its direction (TX → / RX ←) and transport.

Tracing into a pcap

Give --sip-trace a path ending in .pcap and you get a libpcap capture instead of text — readable by sngrep and Wireshark, including Wireshark’s Telephony → VoIP Calls flow graph:

ringo-flow run scenario.rhai --sip-trace flow.pcap
sngrep -I flow.pcap        # or: wireshark flow.pcap

This is the only way to inspect the SIP when the transport is TLS: a live sniffer on the wire sees only the encrypted bytes, but ringo-flow taps the trace inside the stack, so the capture holds the plaintext SIP. Each message is framed as one Ethernet/IP/UDP datagram (the original transport is irrelevant for parsing), so the tools render a clean ladder regardless of UDP/TCP/TLS.

HTTP & webhooks

Telephony rarely lives alone — there’s usually a backend that records calls or drives them. ringo-flow can both call an HTTP API mid-scenario and mock one your system under test calls back.

Call an API

http(method, url) makes a request and returns a response you can assert on:

let res = http("GET", env("API_URL") + "/calls/last");
res.expect_status(200);
assert(res.json("from")).equals("+49301234567");

res.json("a.b.0.c") walks a dotted JSON path; res.status / res.body / res.header(name) are there too. For requests with headers or a body, pass an options map — see HTTP:

http("POST", env("API_URL") + "/calls", #{
    headers: #{ "Content-Type": "application/json" },
    body: #{ to: "+49301234567" },
});

Mock a webhook (webhook-driven call control)

Some telephony APIs call your webhook for a call and expect you to answer with the actions to perform. Stand up a built-in mock server, point the API at it, and assert on what it received.

mock_server() starts the server; on(...) answers a route dynamically, json_response builds the body, and last_request / request_count inspect what arrived:

let hooks = mock_server();

// Answer the webhook with the call actions to perform.
hooks.on("POST", "/voice", |req| {
    if req.json("event") == "incoming_call" {
        json_response(#{ actions: [ #{ type: "answer" } ] })
    } else {
        json_response(#{ actions: [ #{ type: "hangup" } ] })
    }
});

// Tell the system under test where to send its webhooks.
http("PUT", env("API_URL") + "/config?webhook=" + hooks.url + "/voice");

a.dial(env("API_NUMBER"));

// Wait for the webhook the same way you wait for anything else.
await_until(|| assert(hooks.request_count("/voice")).equals(1), "10s");

let req = hooks.last_request("/voice");
assert(req.json("event")).equals("incoming_call");

Notes:

  • The on(...) responder runs on a worker thread, so keep it pure (request → response): no agent verbs inside it.
  • Routes match by exact path or regex("/calls/.*"), and by a method or any ("*" / omit the method). Re-register a route with respond(...) to stage the next answer between webhooks.
  • The server is stopped automatically at the end of the scenario.

See the HTTP mock server and Mock request reference for everything.

Running in CI

ringo-flow is built to run unattended on a build server: it’s headless (virtual audio), exits non-zero on failure, and can emit machine-readable output.

Exit code and output

The process exits non-zero if any scenario fails, so a CI step fails naturally. Add --json for one JSON object per event (NDJSON) instead of the human log:

ringo-flow run scenarios/ --json

Other handy flags: -q (only failures + result), -v (show every assertion), --log (write the SIP signaling to stderr, or --log <file>), --save-audio (dump sent/received WAVs), --no-color.

Metrics

Add --metrics to emit a per-agent media-quality summary at each scenario’s end. On its own it prints a compact human line; combined with --json it adds a metric event to the NDJSON stream, ready to scrape:

ringo-flow run scenarios/ --json --metrics
{"event":"metric","scenario":"call quality","agent":"caller","registered":true,"mos":4.24,"jitter_ms":2.1,"packet_loss_pct":0.0,"rtt_ms":18.0,"rx_lost":0,"ts":"…"}

The quality fields (MOS, jitter, loss, RTT) are present only when the agent had a measurable call; registered is always emitted. Without --metrics the stream is unchanged (no extra stat reads).

Credentials and environment

Scenarios read secrets via env(...). Provide them as environment variables, or from a dotenv file:

ringo-flow run scenarios/ --env-file ci.env

A sibling <scenario>.env next to a file is layered on top automatically. Keep real credentials in your CI secret store, not in the repo.

Selecting what to run

Run a whole directory (all *.rhai, recursively) or a subset:

ringo-flow run scenarios/                       # everything
ringo-flow run scenarios/ --scenario "answered" # by name (re: for regex)
ringo-flow run scenarios/ --tag smoke           # by tag
ringo-flow run scenarios/ --exclude-tag slow    # drop tagged ones

See Writing scenarios for tags, skip and only.

Docker

A small image with baresip compiled in is published to GHCR on each release — nothing to install:

docker run --rm --network host \
  -e SIP_DOMAIN=example.com -e A_USER=alice -e A_PASS=… -e B_USER=bob -e B_PASS=… \
  -v "$PWD/scenarios:/scn:ro" \
  ghcr.io/davidborzek/ringo-flow:latest run /scn

--network host is the simplest way to get working SIP/RTP and DNS. Use :latest or pin :<version>. See the README for recordings, dotenv mounting and private-CA TLS.

Monitoring

ringo-flow serve turns your scenarios into a synthetic monitor: it runs them on a schedule (and on demand over HTTP) and exposes the results as Prometheus metrics — call success, MOS, jitter, loss, RTT and registration, per scenario and agent. Point Grafana at it and you have a live view of how your telephony actually behaves.

ringo-flow serve monitor.toml

On NixOS, run this as a hardened systemd service with the services.ringo-flow module.

Configuration

The monitor reads a monitor.toml:

# HTTP listen address (default 127.0.0.1:9090).
listen = "0.0.0.0:9090"
# Default per-run timeout, overridable per scenario.
timeout = "120s"
# The ringo-flow binary spawned per run. Defaults to the running executable,
# so a single binary both serves and runs — only set this to use another build.
# binary = "/usr/local/bin/ringo-flow"

# Prometheus /metrics endpoint (optional; enabled by default).
[metrics]
enabled = true              # set false to not expose /metrics at all (404)
# bearer_token = "s3cret"   # if set, /metrics requires Authorization: Bearer s3cret

# A monitor names a scenario file (which may hold a whole suite) plus a schedule.
[[monitor]]
name = "smoke"                 # unique — the metric label and /run/<name>
path = "scenarios/smoke.rhai"  # a file or a directory of *.rhai
schedule = "*/5 * * * *"       # cron (5- or 6-field); omit for on-demand only
env_file = ["ci.env"]          # optional --env-file(s)

[[monitor]]
name = "quality"
path = "scenarios/quality.rhai"
schedule = "0 * * * *"         # hourly
timeout = "180s"               # per-monitor override
scenario = "answered"          # optional --scenario name filter within the file
tags = ["smoke"]               # optional --tag filters

A monitor with no schedule is only reachable via POST /run/<name> — handy for ad-hoc checks or driving runs from an external scheduler.

Overriding the basics (flags / env)

The deployment basics can be overridden without editing the config — useful in containers. Precedence is flag > env > config file:

FlagEnvOverrides
--listen <host:port>RINGO_FLOW_SERVE_LISTENfull listen address
--port <port>RINGO_FLOW_SERVE_PORTlisten port only (keeps host; wins over --listen)
--timeout <dur>RINGO_FLOW_SERVE_TIMEOUTdefault per-run timeout
--metrics <true|false>RINGO_FLOW_SERVE_METRICS/metrics on/off
--binary <path>RINGO_FLOW_SERVE_BINARYthe spawned ringo-flow binary
--log-level <lvl>RINGO_FLOW_SERVE_LOG_LEVELlog level (trace/debug/info/warn/error; default info). RUST_LOG overrides it.
--log-format <fmt>RINGO_FLOW_SERVE_LOG_FORMATtext (human, default) or json
RINGO_FLOW_SERVE_METRICS_TOKEN/metrics bearer token (env only — kept out of the process args)
RINGO_FLOW_SERVE_PORT=8080 RINGO_FLOW_SERVE_METRICS_TOKEN=s3cret \
  ringo-flow serve monitor.toml

How it runs

Each run is a fresh ringo-flow run --json --metrics subprocess. That’s deliberate: the baresip backend initialises global state once per process, so a long-lived server can’t reuse it — and a subprocess also gives crash isolation (a backend crash can’t take the monitor down) and a hard per-run timeout.

Runs are serialised through a single worker — one backend per process means two runs at once would collide. Both the cron schedulers and POST /run feed that one queue, so a manual trigger waits behind an in-flight run.

HTTP API

EndpointMethodDescription
/metricsGETPrometheus exposition (scrape this)
/monitorsGETThe configured monitors as JSON
/run/{name}POSTRun a monitor now; waits and returns the result (200 pass / 502 fail / 404 unknown). ?async=true enqueues and returns 202 immediately without waiting (result lands in /metrics).
/healthzGETLiveness — always ok

A synchronous run’s result is grouped by the scenarios the file executed, each with its agents:

curl -X POST http://localhost:9090/run/smoke            # waits for the result
curl -X POST "http://localhost:9090/run/smoke?async=true"  # 202, returns at once
{
  "monitor": "smoke",
  "passed": true,
  "timed_out": false,
  "duration_ms": 4120,
  "scenarios": [
    { "name": "callee accepts", "passed": true,
      "agents": [ { "agent": "Caller", "registered": true, "mos": 4.39, "jitter_ms": 8.2, "packet_loss_pct": 0.0, "rtt_ms": 31.8 } ] }
  ]
}

Metrics

Metrics are labelled by monitor (the configured [[monitor]]) → scenario (a scenario inside the file) → agent.

MetricTypeLabelsMeaning
ringo_monitor_runs_totalcountermonitor, resultRuns by result (pass/fail/timeout)
ringo_monitor_last_successgaugemonitor1 if the last run passed, else 0
ringo_monitor_last_duration_secondsgaugemonitorDuration of the last run
ringo_monitor_last_run_timestamp_secondsgaugemonitorUnix time of the last run
ringo_scenario_last_successgaugemonitor, scenario1 if that scenario passed in the last run
ringo_agent_registeredgaugemonitor, scenario, agent1 if the agent was registered
ringo_call_mosgaugemonitor, scenario, agentMOS of the last call
ringo_call_jitter_msgaugemonitor, scenario, agentJitter, milliseconds
ringo_call_packet_loss_pctgaugemonitor, scenario, agentPacket loss, percent
ringo_call_rtt_msgaugemonitor, scenario, agentRound-trip time, milliseconds

The ringo_call_* gauges come from the run’s metric events; a field is omitted for an agent that had no measurable call.

A Prometheus scrape config:

scrape_configs:
  - job_name: ringo-flow
    static_configs:
      - targets: ["localhost:9090"]
    # only if [metrics].bearer_token is set:
    # authorization: { credentials: "s3cret" }

Keep the scrape interval shorter than your run cadence — the gauges hold the last run’s values, with no persistence across restarts.

Disabling / protecting /metrics

By default /metrics is open (fine when bound to localhost or a trusted network). The [metrics] table changes that:

  • enabled = false — don’t expose /metrics at all (returns 404).
  • bearer_token = "…" — require Authorization: Bearer …; requests without it get 401.

Logging

The server logs to stderr with a configurable level (--log-level, default info) in either human-readable text or JSON (--log-format json) — handy for shipping logs to a collector:

ringo-flow serve monitor.toml --log-level debug --log-format json
{"timestamp":"2026-06-28T12:47:13.948Z","level":"INFO","fields":{"message":"monitor run passed","monitor":"smoke","duration_ms":4120,"scenarios":2},"target":"ringo_flow::serve"}

Building without the server

serve lives behind the server feature, which is on by default. To build a smaller binary without it (and without the toml/croner dependencies):

cargo build -p ringo-flow --no-default-features

NixOS module

The flake ships a services.ringo-flow module that runs ringo-flow serve as a hardened systemd service. Point your NixOS host at the flake and enable it:

{
  inputs.ringo.url = "github:davidborzek/ringo";

  outputs = { nixpkgs, ringo, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        ringo.nixosModules.default
        {
          services.ringo-flow = {
            enable = true;
            listen = "0.0.0.0:9090";
            openFirewall = true;
            monitors.smoke = {
              path = "/var/lib/ringo-flow/smoke.rhai";
              schedule = "*/5 * * * *";      # cron; omit for on-demand only
            };
          };
        }
      ];
    };
  };
}

The module generates the monitor.toml from the options below and runs the service as a transient DynamicUser. ringo-flow writes nothing to disk, so the unit runs with ProtectSystem=strict and no state directory.

Options

OptionDefaultDescription
enablefalseEnable the service.
packageflake’s ringo-flowPackage providing the ringo-flow binary.
listen"127.0.0.1:9090"Bind address for the HTTP API and /metrics.
openFirewallfalseOpen the TCP port parsed from listen.
schedulertrueRun the cron scheduler for scheduled monitors.
timeout"300s"Global per-run timeout (overridable per monitor).
metrics.enabletrueExpose Prometheus /metrics.
monitors.<name>{}Scenario monitors (see below).
logLevel"info"Log level (RUST_LOG overrides it).
logFormat"json"text or json (→ journald).
environmentFilenullsystemd EnvironmentFile for secrets (see below).
settings{}Extra raw keys merged into the generated monitor.toml.
configFilenullUse this monitor.toml verbatim instead of the generated one.
extraArgs[]Extra arguments appended to ringo-flow serve.

Each monitors.<name> entry maps 1:1 to a [[monitor]] block:

FieldDefaultDescription
path.rhai scenario file or directory.
schedulenullCron (5- or 6-field); null = on-demand only via POST /run/<name>.
timeoutnullPer-monitor timeout override.
envFile[]dotenv files with SIP credentials (see Secrets).
scenarionullScenario-name filter when path is a directory.
tags[]Tag filter.

When configFile is set it takes full control — the listen, monitors, scheduler, timeout and metrics options no longer shape the file (though listen is still used for openFirewall).

Secrets

ringo-flow serve needs two kinds of secret, and neither belongs in the Nix store:

  • SIP credentials — the scenarios read SIP_DOMAIN / *_USER / *_PASS from a monitor’s envFile (a dotenv file), not from monitor.toml.
  • /metrics bearer token — read only from the environment variable RINGO_FLOW_SERVE_METRICS_TOKEN, injected via environmentFile.

Both pair cleanly with sops-nix: the SIP dotenv stays on tmpfs under /run/secrets, and the token is rendered into an EnvironmentFile.

# SIP credentials as a dotenv secret
sops.secrets."ringo-flow/sip.env" = {
  sopsFile = ./secrets/ringo.yaml;
  format = "dotenv";
};

# Metrics token rendered into a KEY=VALUE EnvironmentFile
sops.secrets."ringo-flow/metrics-token" = { sopsFile = ./secrets/ringo.yaml; };
sops.templates."ringo-flow.env".content = ''
  RINGO_FLOW_SERVE_METRICS_TOKEN=${config.sops.placeholder."ringo-flow/metrics-token"}
'';

services.ringo-flow = {
  enable = true;
  environmentFile = config.sops.templates."ringo-flow.env".path;
  monitors.smoke = {
    path = "/var/lib/ringo-flow/smoke.rhai";
    schedule = "*/5 * * * *";
    envFile = [ config.sops.secrets."ringo-flow/sip.env".path ];
  };
};

sops-nix populates /run/secrets during system activation, before the service starts.

Hardening

The unit runs unprivileged (DynamicUser) with NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp/PrivateDevices, kernel and cgroup protections, and a @system-service syscall filter. The network stays open — serve makes outbound SIP/RTP and binds the HTTP listener, and baresip’s netroam uses AF_NETLINK, so RestrictAddressFamilies allows AF_INET/AF_INET6/AF_UNIX/AF_NETLINK (do not add PrivateNetwork).

See Monitoring for the metrics, HTTP API and Grafana scrape config.

API reference

The complete scenario vocabulary, generated from the engine (so it never drifts from the code) — organized by the thing you’re working with:

  • Scenario structure — defining and isolating tests: scenario, setup, teardown, skip.
  • Flow and timingawait_until, wait, parallel, default_timeout.
  • Agents — create SIP endpoints and drive calls: register, dial, accept, transfer, DTMF, audio.
    • Peer — the remote party of the active call.
    • Call state — the State::* phases for agent.state.
    • AudioSpec — audio sources for send_audio (tone, file, silent).
  • CallQuality
  • Assertions and matchers — the fluent assert(x).<matcher>(…), used inside await_until.
  • HTTPhttp(…) requests and the response.
  • HTTP mock servermock_server(…), routes and responders for webhook-driven flows.
    • Mock request — the recorded request a responder/assertion sees.
  • Environmentenv, load_env — credentials stay out of scripts.
  • Utilitieslog, uuid.

New to it? Start with Your first scenario, then Writing scenarios.

For editors and agents, the whole API is also available as Rhai type definitions (.d.rhai) — point the Rhai language server at it for completion and hover.

Scenario structure

scenario(name: string, body: Fn)

Register a named scenario, run in isolation (fresh agents, torn down after). The body may take the setup() context: |ctx| { … }.

Example

scenario("answered call", |ctx| {
    ctx.caller.dial(ctx.callee);
    await_until(|| assert(ctx.callee.state).equals(State::Ringing), "15s");
    ctx.callee.accept();
});

scenario(name: string, options: map, body: Fn)

Register a scenario with options #{ tags: ["smoke"], skip: true|"reason", only: true }. --tag/--exclude-tag filter by tag; a skipped scenario is reported but not run; if any scenario sets only, only those run.

Example

scenario("smoke: answered", #{ tags: ["smoke"] }, |ctx| {
    ctx.caller.dial(ctx.callee);
    ctx.callee.accept();
});

setup(body: Fn)

Run before each scenario; its return value is passed to the scenario (and teardown) as ctx. Typically creates and registers the agents.

Example

setup(|| {
    let caller = agent("Caller", #{ username: env("A_USER"), domain: env("SIP_DOMAIN"), password: env("A_PASS") });
    caller.register();
    #{ caller: caller }
});

skip()

Skip the current scenario at runtime (reported, not failed).

skip(reason: string)

Skip the current scenario at runtime with a reason (reported, not failed).

Example

if env("STAGE") != "prod" { skip("prod only") }

teardown(body: Fn)

Run after each scenario (even on failure); receives the setup context.

Example

teardown(|ctx| { ctx.caller.hangup(); });

Flow and timing

await_until(body: Fn)

Re-run the expression until its assertion holds or the default timeout elapses; returns the body’s value, so .value() can bind a verified value.

Example

await_until(|| assert(a.registered).is_true());

await_until(body: Fn, within: string)

Like await_until(body) but with an explicit timeout, e.g. "15s".

Example

await_until(|| assert(b.state).equals(State::Ringing), "15s");

default_timeout(duration: string)

Set the default await_until timeout for the rest of the script (e.g. "10s").

parallel(tasks: array)

Returns array

Run the given zero-arg closures concurrently and wait for all; returns their results as an array, and fails if any task fails. Use it for independent blocking work, e.g. verify_audio on several agents at once. Tasks may share captured variables (each gets an independent snapshot, so they can’t race). Don’t overlap await_until across tasks; its silencing is global.

Example

let results = parallel([
    || http("GET", env("A_URL")),
    || http("GET", env("B_URL")),
]);

wait(seconds: int)

Hold for N seconds; FAILS if a call that is established at the start drops.

Example

wait(3); // the call must stay up for 3s

Agents

Constructor

agent(name: string, config: map)

Returns Agent

Connect a headless baresip agent and return a handle.

Config optionsagent(name, #{ … }):

FieldTypeDescription
usernamestring · requiredSIP user (registration / auth)
domainstring · requiredSIP domain / registrar
passwordstringauth password
display_namestringcaller display name
transportstringudp (default), tcp or tls
auth_userstringauth user, if it differs from username
outboundstringoutbound proxy URI
stun_serverstringSTUN server, e.g. stun:host:port
media_encstringmedia encryption, e.g. srtp, zrtp, dtls_srtp
regintintre-registration interval (seconds); 0 disables
mwiboolsubscribe to message-waiting indication
dtmf_modestring"info" for reliable headless DTMF (SIP INFO)
headersmapextra SIP headers on the INVITE, e.g. #{ "X-Foo": "bar" }; a value may be an array for a repeated header, e.g. #{ "X-Foo": ["a", "b"] }
deflect_tostringdeflect inbound calls with a 302 to this URI/number (toggle at runtime with deflect()/stop_deflect())
metadatamapfree-form data carried on the agent and read back as agent.metadata (e.g. #{ role: "caller" }); not used for SIP

Example

let a = agent("A", #{
    username: env("A_USER"),
    domain: env("SIP_DOMAIN"),
    password: env("A_PASS"),
});

Methods

agent.abort_transfer()

Receiver Agent

Abort the pending attended transfer.

agent.accept()

Receiver Agent

Answer the agent’s incoming call.

Example

await_until(|| assert(b.state).equals(State::Ringing), "15s");
b.accept();

agent.attended_transfer(target: Agent)

Receiver Agent · Takes Agent

Start an attended transfer: place a consultation call to another agent. Complete it with complete_transfer() once that call is established.

Example

callee.attended_transfer(target);   // consult `target`
await_until(|| assert(target.state).equals(State::Established));
callee.complete_transfer();         // connect caller and target

agent.attended_transfer(target: string)

Receiver Agent

Start an attended transfer to a literal URI or bare number.

agent.complete_transfer()

Receiver Agent

Complete the pending attended transfer (REFER with Replaces).

agent.deflect(target: Agent)

Receiver Agent · Takes Agent

Deflect inbound calls with a 302 Moved Temporarily to another agent’s AOR (a Diversion header names the deflecting agent). Arm it before the caller dials; stays active until stop_deflect().

Example

callee.deflect(target);     // future calls to `callee` go to `target`
caller.dial(callee);
await_until(|| assert(target.state).equals(State::Ringing), "15s");

agent.deflect(target: string)

Receiver Agent

Deflect inbound calls (302) to a literal URI or bare number/extension.

agent.dial(target: Agent)

Receiver Agent · Takes Agent

Dial another agent at its AOR.

Example

a.dial(b);                 // dial agent B at its AOR
a.dial("+49301234567");    // …or a number/URI in A's domain
await_until(|| assert(b.state).equals(State::Ringing), "15s");

agent.dial(target: string)

Receiver Agent

Dial a literal SIP URI, or a bare number/extension in the agent’s own domain.

agent.dtmf(digits: string)

Receiver Agent

Send DTMF tones (characters 0-9, *, #, A-D) back-to-back.

Example

a.dtmf("123#");

agent.dtmf(digits: string, gap: string)

Receiver Agent

Send DTMF tones with a pause between digits.

Example

a.dtmf("123#", "200ms");

agent.hangup()

Receiver Agent

Hang up the agent’s active call.

Example

a.hangup();
await_until(|| assert(a.state).equals(State::Idle), "10s");

agent.header(name: string)

Receiver Agent · Returns string?

Value of a header on a received INVITE (string), or () if absent.

agent.headers()

Receiver Agent · Returns map

All received INVITE headers as a map (name → value); duplicates collapse, use header(name) for a specific one.

agent.hold()

Receiver Agent

Put the active call on hold.

agent.info()

Receiver Agent · Returns map

A map of the agent’s current state: name, aor, registered, state, reason, status_code, calls. Handy to print(...) or assert on.

agent.mute()

Receiver Agent

Toggle mute on the active call.

Example

a.mute(); // mute; call again to unmute

agent.register()

Receiver Agent

(Re-)register the agent’s account.

Example

a.register();
await_until(|| assert(a.registered).is_true(), "10s");

agent.respond_incoming(status: int, reason: string)

Receiver Agent

Answer inbound INVITEs with a custom SIP response (status + reason) instead of accepting — e.g. callee.respond_incoming(486, "Busy Here"). Arm before the caller dials; clear with stop_deflect().

agent.respond_incoming(status: int, reason: string, headers: map)

Receiver Agent

Custom response with extra headers, e.g. callee.respond_incoming(302, "Moved Temporarily", #{ "Contact": "<sip:bob@example.com>" }). Header values must not contain CR/LF.

agent.resume()

Receiver Agent

Resume a held call.

agent.send_audio(source: AudioSpec)

Receiver Agent · Takes AudioSpec

Switch the agent’s active-call audio source: tone(Hz), file(path) or silent().

Example

a.send_audio(tone(440));         // play a 440 Hz tone
a.send_audio(file("prompt.wav"));

agent.stop_deflect()

Receiver Agent

Stop deflecting / clear any armed response — inbound calls are accepted again.

agent.to_json()

Receiver Agent · Returns string

The agent’s current state as a JSON string (for log(...)/debugging).

agent.transfer(target: Agent)

Receiver Agent · Takes Agent

Blind-transfer (REFER) the active call to another agent’s AOR.

Example

callee.transfer(target); // hand the caller off to `target`

agent.transfer(target: string)

Receiver Agent

Blind-transfer (REFER) the active call to a literal URI or bare number.

agent.verify_audio(freq: int, within: string)

Receiver Agent

Assert the agent is receiving a tone at freq Hz within the window (Goertzel).

Example

a.send_audio(tone(440));
b.verify_audio(440, "5s"); // b hears A's 440 Hz tone

agent.verify_audio_connection(b: Agent)

Receiver Agent · Takes Agent

Assert two-way audio between two agents (a→b then b→a) at 1000 Hz.

Example

caller.verify_audio_connection(callee);

Fields

agent.metadata

Receiver Agent · Returns map

Free-form metadata attached at agent(...) via the metadata config field, e.g. caller.metadata.role. Empty map if none was given.

agent.peer

Receiver Agent · Returns Peer

The current call’s remote party (the caller for an incoming call); read peer.uri / peer.number / peer.name (each () if there’s no call).

agent.quality

Receiver Agent · Returns CallQuality

RTP media quality of the active call (or the last call’s snapshot); read quality.mos / .rtt / .jitter / .packet_loss (each () until the first RTCP report, ~5s into a call).

Example

await_until(|| assert(caller.quality.mos).is_present(), "10s");
assert(caller.quality.mos).at_least(4.0);

agent.reason

Receiver Agent · Returns string?

The last closed call’s reason (string), or () if none yet.

agent.received_dtmf

Receiver Agent · Returns string

DTMF digits received on the active/last call, in order (e.g. "1234#"); empty until any arrive — poll with await_until.

Example

caller.dtmf("1234#");
await_until(|| assert(callee.received_dtmf).equals("1234#"), "5s");

agent.registered

Receiver Agent · Returns bool

Whether the agent’s account is currently registered.

agent.state

Receiver Agent · Returns CallState

The agent’s current call phase: Idle, Ringing or Established.

agent.status_code

Receiver Agent · Returns int?

SIP status code from the last closed call’s reason (int, e.g. 603), or () if the reason isn’t a SIP response (local hangup, reset, …).

Peer

peer.name

Receiver Peer · Returns string?

The remote party’s display name, or () if absent.

peer.number

Receiver Peer · Returns string?

The remote party’s number (user-part of the URI), or ().

peer.uri

Receiver Peer · Returns string?

The remote party’s full URI (e.g. sip:bob@example.com), or ().

CallQuality

quality.jitter

Receiver CallQuality · Returns float?

Receive-side jitter in milliseconds, or () if not available yet.

quality.mos

Receiver CallQuality · Returns float?

Estimated MOS (1.0–4.5), or () until the first RTCP report.

quality.packet_loss

Receiver CallQuality · Returns float?

Receive-side packet loss in percent, or () if not available yet.

quality.rtt

Receiver CallQuality · Returns float?

Round-trip time in milliseconds, or () if not available yet.

Call state

agent.state returns a CallState — a call’s current phase. Compare it against the State::* constants, usually inside await_until:

await_until(|| assert(callee.state).equals(State::Ringing));
  • State::Idle — No active call.
  • State::Ringing — A call is ringing — incoming or outgoing — but not yet answered.
  • State::Established — The call is connected and media is flowing.

AudioSpec

file(path: string)

Returns AudioSpec

A WAV-file audio source, for send_audio.

silent()

Returns AudioSpec

A silent audio source (stop sending), for send_audio.

tone(freq: int)

Returns AudioSpec

A sine-tone audio source at the given frequency (Hz), for send_audio.

Example

a.send_audio(tone(440));

Assertions and matchers

Constructor

assert(actual)

Returns Assertion

Begin a fluent assertion on a value: assert(x).equals(y), .is_true(), .greater_than(n), etc. Matchers chain (.at_least(200).at_most(299)) and error (with a value-based message) on a mismatch. Asserting on a getter auto-labels the log line (assert(caller.state)Caller state, assert(res.status)HTTP status); .describe(…) overrides.

Methods

assertion.at_least(n: int)

Receiver Assertion · Returns Assertion

Assert the (numeric) value is >= n.

assertion.at_most(n: int)

Receiver Assertion · Returns Assertion

Assert the (numeric) value is <= n.

assertion.contains(needle: string)

Receiver Assertion · Returns Assertion

Assert the (string) value contains needle.

Example

assert(a.header("User-Agent")).contains("baresip");

assertion.describe(label: string)

Receiver Assertion · Returns Assertion

Label this assertion so the log line names it: assert(caller.registered) .describe("caller registered").is_true()caller registered: ✓ expect ….

assertion.equals(expected)

Receiver Assertion · Returns Assertion

Assert the value equals expected (is is a reserved word in Rhai).

Example

assert(a.state).equals(State::Established);

assertion.greater_than(n: int)

Receiver Assertion · Returns Assertion

Assert the (numeric) value is > n.

assertion.is_absent()

Receiver Assertion · Returns Assertion

Assert the value is absent (()).

assertion.is_empty()

Receiver Assertion · Returns Assertion

Assert the string/array/map value is empty.

assertion.is_false()

Receiver Assertion · Returns Assertion

Assert the value is false.

assertion.is_not_empty()

Receiver Assertion · Returns Assertion

Assert the string/array/map value is not empty.

assertion.is_present()

Receiver Assertion · Returns Assertion

Assert the value is present (not ()), e.g. a received header.

assertion.is_true()

Receiver Assertion · Returns Assertion

Assert the value is true.

Example

assert(a.registered).is_true();

assertion.less_than(n: int)

Receiver Assertion · Returns Assertion

Assert the (numeric) value is < n.

assertion.matches(pattern: string)

Receiver Assertion · Returns Assertion

Assert the (string) value matches the regex pattern.

assertion.not_equals(expected)

Receiver Assertion · Returns Assertion

Assert the value does not equal expected.

assertion.value()

Receiver Assertion

The value under assertion, so a verified value can be bound.

Example

let id = await_until(|| assert(callee.header("X-Id")).is_present().value());

HTTP

Constructor

http(method: string, url: string)

Returns HttpResponse

Make an HTTP request and return the response.

Example

let res = http("GET", env("API_URL") + "/calls");
res.expect_status(200);

http(method: string, url: string, options: map)

Returns HttpResponse

Make an HTTP request with options and return the response.

Optionshttp(method, url, #{ … }):

FieldTypeDescription
headersmaprequest headers, e.g. #{ "Content-Type": "application/json" }
bodystring or maprequest body; a map is encoded to JSON

Example

let res = http("POST", env("API_URL") + "/calls", #{
    headers: #{ "Content-Type": "application/json" },
    body: #{ to: "+49301234567" },
});

Methods

resp.expect_status(code: int)

Receiver HttpResponse

Assert and report the status; errors on mismatch.

resp.header(name: string)

Receiver HttpResponse · Returns string?

A response header value (string), or () if absent.

resp.json()

Receiver HttpResponse

The whole JSON body as a native value (object→map, array, …).

resp.json(path: string)

Receiver HttpResponse

The value at a dotted JSON path (e.g. "data.id"), typed: object→map, array, number, bool, null(). Errors if the path is missing.

Example

assert(res.json("data.id")).equals(42);

Fields

resp.body

Receiver HttpResponse · Returns string

The HTTP response body as a string.

resp.status

Receiver HttpResponse · Returns int

The HTTP response status code.

HTTP mock server

Constructor

mock_server()

Returns HttpMock

Start a mock HTTP server on a free port and return a handle. Stopped automatically at the end of the scenario. Use url to point the system under test at it, respond/on to define routes.

Example

let hooks = mock_server();
hooks.on("POST", "/voice", |req| json_response(#{ actions: [ #{ type: "answer" } ] }));
http("PUT", env("API_URL") + "/config?webhook=" + hooks.url + "/voice");

mock_server(config: map)

Returns HttpMock

Start a mock HTTP server with config; stopped automatically at scenario end. Omit port (or use mock_server()) for a free one.

Configmock_server(#{ … }):

FieldTypeDescription
portintport to bind (omit for a free one)

Example

let hooks = mock_server(#{ port: 8080 });

Methods

mock.last_request(path: PathPattern)

Receiver HttpMock · Takes PathPattern · Returns MockRequest

The most recent request on a regex(...) path (errors if none yet).

mock.last_request(path: string)

Receiver HttpMock · Returns MockRequest

The most recent request on path (errors if none yet). Read it after await_until confirms the webhook arrived.

Example

let req = hooks.last_request("/voice");
assert(req.json("event")).equals("incoming_call");

mock.on(method: string, path: PathPattern, responder: Fn)

Receiver HttpMock · Takes PathPattern

Dynamic responder for method and a regex(...) path.

mock.on(method: string, path: string, responder: Fn)

Receiver HttpMock

Answer method path dynamically: the |req| closure receives the MockRequest and returns a response map (e.g. json_response(#{…})). method may be "*" for any method. The closure runs on a runtime worker, so keep it pure (request → response): no agent verbs, no wait — those block a worker thread.

Example

hooks.on("POST", "/voice", |req| {
    if req.json("event") == "incoming_call" {
        json_response(#{ actions: [ #{ type: "answer" } ] })
    } else {
        json_response(#{ actions: [ #{ type: "hangup" } ] })
    }
});

mock.on(path: PathPattern, responder: Fn)

Receiver HttpMock · Takes PathPattern

Dynamic responder for a regex(...) path on any HTTP method.

mock.on(path: string, responder: Fn)

Receiver HttpMock

Dynamic responder for path on any HTTP method.

mock.request_count(path: PathPattern)

Receiver HttpMock · Takes PathPattern · Returns int

How many requests arrived on a regex(...) path (any method).

mock.request_count(path: string)

Receiver HttpMock · Returns int

How many requests arrived on path (any method). Poll it with await_until to wait for a webhook.

Example

await_until(|| assert(hooks.request_count("/voice")).equals(1), "10s");

mock.requests(path: PathPattern)

Receiver HttpMock · Takes PathPattern · Returns array

All requests on a regex(...) path, in arrival order, as MockRequests.

mock.requests(path: string)

Receiver HttpMock · Returns array

All requests received on path, in arrival order, as MockRequests.

mock.respond(method: string, path: PathPattern, response: map)

Receiver HttpMock · Takes PathPattern

Static response for method and a regex(...) path.

mock.respond(method: string, path: string, response: map)

Receiver HttpMock

Register a static response for method path: a map #{ status: 200, content_type: "…", headers: #{…}, body: <string|map> } (use json_response/text_response to build it). method may be "*" for any method. Re-register to stage the next answer between webhooks.

Example

hooks.respond("POST", "/voice", json_response(#{ actions: [ #{ type: "hangup" } ] }));

mock.respond(path: PathPattern, response: map)

Receiver HttpMock · Takes PathPattern

Static response for a regex(...) path on any HTTP method.

mock.respond(path: string, response: map)

Receiver HttpMock

Static response for path on any HTTP method.

mock.stop()

Receiver HttpMock

Stop the server now (it otherwise stops automatically at scenario end).

Fields

mock.port

Receiver HttpMock · Returns int

The port the server is listening on.

mock.url

Receiver HttpMock · Returns string

The server’s base URL, e.g. http://127.0.0.1:8080.

Helpers

json_response(body)

Returns map

Build a 200 application/json response map from body (JSON-encoded), for respond/on. body may be a map or an array.

Example

hooks.respond("POST", "/voice", json_response(#{ actions: [ #{ type: "answer" } ] }));

regex(pattern: string)

Returns PathPattern

A regex path matcher for respond/on/request_count/… anchored to the whole path (/calls/.* matches /calls/123). Errors on a bad pattern.

Example

hooks.on(regex("/calls/.*"), |req| text_response("ok"));

text_response(body: string)

Returns map

Build a 200 text/plain response map from body, for respond/on.

Mock request

Methods

req.header(name: string)

Receiver MockRequest · Returns string?

A request header value (case-insensitive), or () if absent.

req.json(path: string)

Receiver MockRequest

The value at a dotted JSON path in the body (object→map, array, number, bool, null()). Errors if the path is missing.

Example

assert(req.json("call.from")).equals("+49301234567");

req.query(name: string)

Receiver MockRequest · Returns string?

A query-string parameter value, or () if absent.

Fields

req.body

Receiver MockRequest · Returns string

The raw request body.

req.method

Receiver MockRequest · Returns string

The request method (upper-case).

req.path

Receiver MockRequest · Returns string

The request path.

Environment

env(name: string)

Returns string

Read a variable: first from --env-file/<scenario>.env/load_env, then the process environment. Errors if unset. Use it for per-env credentials.

Example

let dom = env("SIP_DOMAIN");
let a = agent("A", #{ username: env("A_USER"), domain: dom, password: env("A_PASS") });

load_env(path: string)

Load a dotenv file (KEY=VALUE lines) into env(...) for this scenario, resolved relative to the scenario file. Later loads override earlier keys.

Utilities

log(message: string)

Print a timestamped note to the scenario log (and the --json stream), unlike print which writes a bare line.

uuid()

Returns string

A fresh random UUID string.