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 JavaScript or TypeScript 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 TypeScript definitions
(.d.ts). The deprecated Rhai frontend also ships
.d.rhai definitions.
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
$EDITORediting. - 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
- Getting started — install and launch your first profile.
- Profiles and Configuration — set up accounts and tune the UI / baresip.
- Using the TUI — modes and keybindings.
- Remote control — drive sessions from scripts.
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 installprompts 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
| Path | Description |
|---|---|
~/.config/ringo/ringo.toml | Global config |
~/.config/ringo/contacts.toml | Contact book |
~/.config/ringo/profiles/<name>/profile.toml | Profile config |
~/.config/ringo/profiles/<name>/call_history | Per-profile call history (JSONL) |
~/.local/share/ringo/history | Global dial history |
~/.local/state/ringo/<name>.log | Application 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
| Key | Action |
|---|---|
Enter | Start selected profile |
Ctrl+N | Create new profile |
Ctrl+E | Edit selected profile |
Ctrl+Y | Clone selected profile |
Ctrl+D | Delete selected profile (confirmation) |
↑ / ↓ | Navigate (wrap-around) |
Esc | Quit |
Normal mode (default)
| Key | Action |
|---|---|
d | Enter Dial mode |
a | Accept incoming call |
b / Del | Hang up |
h / r | Hold / Resume |
m | Toggle mute |
t / T | Blind / attended transfer |
0-9 * # | DTMF tones (during a call) |
f / Tab | Open contacts (Tab switches calls when several are active) |
e / l / c | Event log / baresip log / call history |
Ctrl+R | Fuzzy-search dial history |
Ctrl+E | Edit profile (no active call) |
Ctrl+P | Switch profile (back to picker) |
: | Open the command bar |
q / Ctrl+C | Quit (with / without confirmation) |
Dial mode
| Key | Action |
|---|---|
Enter | Dial and return to Normal mode |
Esc | Cancel |
Backspace | Delete character / exit when empty |
← → / Home End | Move / jump the cursor |
↑ / ↓ | Navigate dial history |
Tab | Open contacts |
Ctrl+R | Fuzzy-search dial history |
Transfer mode
| Key | Action |
|---|---|
Enter | Execute the transfer |
Tab | Open contacts |
↑ / ↓ / Ctrl+R | Dial-history navigation / search |
Esc | Cancel |
Contacts overlay
| Key | Action |
|---|---|
↑ / ↓, g / G | Navigate / jump to top-bottom |
Enter | Select number (dial or transfer) |
/ | Search |
a / e / d | Add / edit / delete contact |
E | Open contacts in $EDITOR |
f / Esc | Close |
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
| Key | Action |
|---|---|
↑ / ↓, g / G | Navigate / jump |
Enter | (history) copy peer to dial input — redial |
/ | (history) search |
d / D | (history) delete entry / clear all |
e / l / c | Switch between event log / baresip log / call history |
Esc | Close |
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 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.
| Role | Default | Used for |
|---|---|---|
accent | cyan | Logo, picker selection, DTMF input, history popup |
subtle | dark_gray | Hints, log text, subtitles, unfocused labels |
success | green | Registered, established call, toggle on |
danger | red | Muted, missed calls, registration failed |
attention | yellow | Selected call, ringing, MWI, focused field |
transfer | magenta | Transfer-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)\""
| Event | Trigger | Event data |
|---|---|---|
profile_loaded | Profile loaded, baresip spawned | — |
call_incoming | Incoming call | call_id, number, display_name |
call_outgoing | Outgoing call initiated | call_id, number |
call_ended | Call closed | call_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:
| File | Rewritten by the TUI? | How the module manages it |
|---|---|---|
~/.config/ringo/ringo.toml (theme, picker, baresip, hooks) | No — ringo only reads it | Read-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 everyhome-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 tosettings/passwordFilein Nix are NOT propagated (delete the file, or flip tofalseonce, 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
| Option | Default | Description |
|---|---|---|
enable | false | Install ringo and manage its config. |
package | flake’s ringo-phone | Package providing the ringo binary. |
settings | {} | Contents of ~/.config/ringo/ringo.toml. See Configuration. |
profiles.<name> | {} | Declarative SIP profiles (see below). |
Each profiles.<name>:
| Field | Default | Description |
|---|---|---|
settings | {} | The profile’s fields, minus the password (at least username and domain). Maps 1:1 to the profile schema. |
passwordFile | null | Path 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). |
passwordCommand | null | Command 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. |
password | null | Inline plaintext. Discouraged — rendered into profile.toml and thus into the world-readable store. Prefer passwordFile/passwordCommand. |
mutable | false | If 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
passwordlands at rest (inprofile.toml, and in the store via the rendered TOML) — which is why it’s discouraged.passwordFileandpasswordCommandkeep 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 JavaScript or TypeScript file — bring up SIP agents, place and answer calls, assert on call state, DTMF, audio and HTTP — and run it headlessly, e.g. in CI.
// @ts-check
const domain = env("SIP_DOMAIN");
const a = new Agent("A", { username: env("A_USER"), domain, password: env("A_PASS") });
const b = new Agent("B", { username: env("B_USER"), domain, password: env("B_PASS") });
a.register();
b.register();
await until(() => expect(b.registered).toBeTruthy(), "10s");
a.dial(b);
await until(() => expect(b.state).toBe(State.Ringing), "15s");
b.accept();
await until(() => expect(a.state).toBe(State.Established));
a.hangup();
Highlights
- Headless — virtual audio, no devices needed; runs on a build server.
- Typed, in your editor — the generated
ringo-flow.d.tstypes the whole DSL, so agent config keys, matchers and argument types are checked as you type. Author in plain.jswith// @ts-check, or in real TypeScript. - Suites —
setup/scenario/teardown, each scenario isolated with fresh agents. Parametrise withscenario.each, select with--scenario, tag with--tag/--exclude-tag, disable withskip, focus withonly. - 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.
Scenarios used to be written in Rhai. That frontend still runs
.rhaifiles but is deprecated and will be removed — see Rhai frontend for the reasons and a migration table.
Next steps
- Getting started — install and run.
- Your first scenario — a guided, line-by-line walkthrough.
- Writing scenarios — suites, selection, and the patterns.
- Audio testing and HTTP & webhooks — the feature guides.
- The JS API reference (in the sidebar) — every class, verb and matcher, generated from the type definitions.
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.js
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 installprompts 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.js
ringo-flow run scenario.js # one file
ringo-flow run scenarios/ # a directory (all *.js, recursively)
ringo-flow check scenario.js # syntax-check only (no SIP traffic)
The exit code is non-zero if any scenario fails.
The frontend follows the file extension, so there is nothing to configure:
.js runs on the JavaScript frontend, .rhai on the
deprecated Rhai one.
Editor support
Write the type definitions next to your scenarios once, and any editor with TypeScript support checks the whole DSL as you type:
ringo-flow definitions --lang js ringo-flow.d.ts
// jsconfig.json — next to your scenarios
{
"compilerOptions": {
"checkJs": true,
"strict": true,
"noEmit": true,
"target": "es2022",
"module": "esnext",
"types": []
},
"files": ["ringo-flow.d.ts", "scenario.js"]
}
Start each scenario with // @ts-check to get the same errors on the command
line via tsc --noEmit. For authoring in real TypeScript, see
Writing scenarios.
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 forenv(...)(a sibling<scenario>.envis 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.pcappath 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.js:
// @ts-check
const domain = env("SIP_DOMAIN");
const a = new Agent("A", { username: env("A_USER"), domain, password: env("A_PASS") });
const b = new Agent("B", { username: env("B_USER"), domain, password: env("B_PASS") });
a.register();
b.register();
await until(() => expect(a.registered).toBeTruthy(), "10s");
await until(() => expect(b.registered).toBeTruthy(), "10s");
a.dial(b);
await until(() => expect(b.state).toBe(State.Ringing), "15s");
b.accept();
await until(() => expect(a.state).toBe(State.Established));
await wait(3); // the call must stay up
a.hangup();
await until(() => expect(a.state).toBe(State.Idle), "10s");
Run it:
SIP_DOMAIN=example.com A_USER=alice A_PASS=… B_USER=bob B_PASS=… \
ringo-flow run first.js
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. new 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
Agent reference for every config field — with the
.d.ts in place your editor completes them and flags a typo
before the script ever runs.
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.
expect(a.registered) reads the agent’s state;
.toBeTruthy() 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 ringing — b.state is one of
State.Idle / State.Ringing / State.Established.
Answer and connect. b.accept() answers;
both sides become Established. until without a timeout uses the default
(overridable with defaultTimeout(...)).
Hold, then hang up. await 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 to await. Only the blocking verbs are Promises: until, wait,
http, verifyAudio and verifyAudioConnection. Everything instant (dial,
accept, register, hangup, dtmf, …) is a plain synchronous call. A
scenario file runs as an ES module, so top-level await — as above — is fine.
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 — group several tests into a suite and select/tag/skip them.
- Audio testing — assert what the other side actually hears.
- HTTP & webhooks — drive and mock a backend API.
- The JS API reference — every class, verb and matcher.
Writing scenarios
A scenario is a JavaScript file (or TypeScript, transpiled — see below). The top level can be the whole test, or you can register several named scenarios as a suite.
Each file runs as an ES module, so you can import helper files and use
top-level await.
Agents and call control
new Agent(name, { … }) connects a headless baresip
instance and returns a handle you drive with verbs —
register,
dial,
accept,
hangup, hold, dtmf, transfer, … See
Agent for the full set, the config options and the
readable state (registered,
state, …).
until
SIP is asynchronous, so assertions are polled:
until re-runs an
expect(...) until it holds or a timeout elapses.
Use it instead of sleeping.
a.dial(b);
await until(() => expect(b.state).toBe(State.Ringing), "15s");
The matchers — toBe,
toBeTruthy,
toContain, … — are all on the
assertion handle, and are the Jest names, so they should already be familiar.
until resolves with the value the condition returned, which lets you bind a
verified value in one step:
const traceId = await until(() => expect(b.header("X-Trace-Id")).toBeDefined().value());
Because until yields the event loop while it polls, independent waiters can run
concurrently:
await Promise.all([callee.verifyAudio(440, "5s"), caller.verifyAudio(480, "5s")]);
Suites: setup / scenario / teardown
setup() runs before each scenario; each
scenario(name, body) runs in isolation with
fresh agents; teardown() runs after each (even
on failure).
Keep fixtures in closure-scoped variables: declare them once up top, assign
them in setup(), and read them everywhere. With a single @type annotation per
fixture, every body gets completion and type-checking, with no per-scenario
typing:
// @ts-check
/** @type {Agent} */ let caller;
/** @type {Agent} */ let callee;
setup(() => {
caller = new Agent("caller", { username: env("A_USER"), domain: env("SIP_DOMAIN"), password: env("A_PASS") });
callee = new Agent("callee", { username: env("B_USER"), domain: env("SIP_DOMAIN"), password: env("B_PASS") });
});
teardown(() => caller.hangup());
scenario("answered call", { tags: ["smoke"] }, async () => {
caller.dial(callee);
await until(() => expect(callee.state).toBe(State.Ringing), "15s");
callee.accept();
await until(() => expect(caller.state).toBe(State.Established), "10s");
});
setup()’s return value — if you return one — is passed to each scenario body
(and to teardown) as its first argument, which is handy when a fixture must be
built per scenario. It is typed any, so the closure-variable pattern above is
the one that keeps full type-checking.
Parametrised scenarios: scenario.each
To run the same body over a table of inputs, scenario.each(table) returns a
registration function: it registers one scenario per row and passes the row to the
body as a second argument. $key tokens in the name are replaced with that row’s
field, so each scenario gets a distinct — and individually selectable — name:
scenario.each([
{ kind: "internal", target: "201", within: "10s" },
{ kind: "external", target: "+4921112345", within: "20s" },
])("dial $kind target reaches ringing", { tags: ["dialplan"] }, async (ctx, p) => {
caller.dial(p.target);
await until(() => expect(caller.state).toBe(State.Ringing), p.within);
});
The options object is optional (scenario.each(table)(name, body) works too) and
applies to every row. Rows are registered in table order.
Type-checking a plain .js scenario
Generate the definitions once and point a jsconfig.json at them; with
// @ts-check at the top of each scenario, your editor — and tsc --noEmit in
CI — checks the whole DSL:
ringo-flow definitions --lang js ringo-flow.d.ts
// jsconfig.json
{
"compilerOptions": {
"checkJs": true,
"strict": true,
"noEmit": true,
"target": "es2022",
"module": "esnext",
"types": []
},
"files": ["ringo-flow.d.ts", "scenario.js"]
}
The .d.ts declares the whole DSL as ambient globals (scenario,
new Agent(...), expect, …), so a scenario needs no imports for them. A wrong
matcher, an unknown agent-config key or a forgotten await is reported before
baresip ever starts.
Writing scenarios in TypeScript
Scenarios execute as JavaScript, but you can author them in real TypeScript and
transpile to JS for ringo-flow run.
-
Generate the type definitions and point a
tsconfig.jsonat them:ringo-flow definitions --lang js ringo-flow.d.ts # writes the .d.ts next to your scenarios// tsconfig.json { "compilerOptions": { "strict": true, "noEmit": true, "target": "es2022", "module": "esnext", "types": [] }, "files": ["ringo-flow.d.ts", "scenario.ts"] } -
Write
scenario.ts. The globals and theAgent/MockServerclasses are typed, sotsc(or your editor) catches a wrong matcher, an unknown config key or a bad argument before baresip ever starts:scenario("answered call", async () => { const caller = new Agent("caller", { username: env("A_USER"), domain: env("SIP_DOMAIN") }); caller.register(); await until(() => expect(caller.registered).toBeTruthy(), "10s"); }); -
Type-check, transpile with Bun, and run the emitted JS:
tsc --noEmit # optional: fail on a type error bun build scenario.ts --outfile scenario.js # strip types → plain JS ringo-flow run scenario.js
bun build strips the type annotations and leaves the ambient globals untouched, so
the runtime executes a plain .js. Relative imports of helper .ts files are bundled
into the one output — or keep them as separate .js and let ringo-flow resolve the
imports at run time.
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 }or{ skip: "reason" }disables a scenario statically; or callskip("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
- Assertions and matchers — the full matcher set.
- Audio testing — send tones/files and assert what’s received.
- HTTP & webhooks — call and mock a backend API.
- Rhai frontend — the deprecated frontend and how to migrate off it.
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.sendAudio(source) switches the
agent’s active-call audio source:
a.sendAudio(tone(440)); // a 440 Hz sine tone
a.sendAudio(file("prompt.wav")); // a WAV file
a.sendAudio(silence()); // stop sending
tone, file and
silence build an
AudioSpec.
Verify what’s received
agent.verifyAudio(freq, within) asserts
the agent is receiving a tone at freq Hz within the time window (detected with a
Goertzel filter). It returns a Promise, so await it:
a.sendAudio(tone(440));
await b.verifyAudio(440, "5s"); // B hears A's tone within 5s
The blocking detection window runs off the scenario thread, so several agents can listen at once instead of one after another:
a.sendAudio(tone(440));
b.sendAudio(tone(480));
await Promise.all([b.verifyAudio(440, "5s"), a.verifyAudio(480, "5s")]);
For a quick two-way check,
verifyAudioConnection(a, b) sends a
tone each way and asserts both arrive:
await verifyAudioConnection(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 Agent 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:
| Field | Meaning | Unit |
|---|---|---|
agent.quality.mos | Estimated Mean Opinion Score | 1.0 (bad) – 4.5 (excellent) |
agent.quality.rtt | Round-trip time | milliseconds |
agent.quality.jitter | Receive-side inter-arrival jitter | milliseconds |
agent.quality.packetLoss | Receive-side packet loss | percent |
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 agent.quality is
still undefined — let the call run a few seconds first:
await until(() => expect(caller.quality).toBeDefined(), "10s");
The whole object appears at once (the fields arrive together, so there are no
per-field gaps), which is why the optional chain in caller.quality?.mos is only
needed before the first report.
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
// @ts-check
/** @type {Agent} */ let caller;
/** @type {Agent} */ let callee;
setup(() => {
caller = new Agent("caller", { username: env("A_USER"), domain: env("SIP_DOMAIN"), password: env("A_PASS") });
callee = new Agent("callee", { username: env("B_USER"), domain: env("SIP_DOMAIN"), password: env("B_PASS") });
});
scenario("call quality", async () => {
caller.dial(callee);
await until(() => expect(callee.state).toBe(State.Ringing));
callee.accept();
await until(() => expect(caller.state).toBe(State.Established));
// Let RTCP accumulate, then wait for the first report:
const q = await until(() => expect(caller.quality).toBeDefined().value(), "10s");
log(`caller → MOS ${q.mos} · RTT ${q.rtt}ms · jitter ${q.jitter}ms · loss ${q.packetLoss}%`);
caller.hangup();
await until(() => expect(caller.state).toBe(State.Idle));
// The snapshot survives the hangup — assert on the final values:
expect(caller.quality?.mos).toBeGreaterThanOrEqual(4.0);
expect(caller.quality?.packetLoss).toBeLessThanOrEqual(1.0);
expect(caller.quality?.rtt).toBeLessThanOrEqual(150);
});
The values are raw floats (e.g.
MOS 4.236…). To shorten a log line, round them:log(`MOS ${q.mos.toFixed(2)}`).
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.js --log # → stderr
ringo-flow run scenario.js --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.js --sip-trace # → stderr (text)
ringo-flow run scenario.js --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.js --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) performs the request off-thread
and resolves with a response you can assert on:
const res = await http("GET", env("API_URL") + "/calls/last");
res.expectStatus(200);
expect(res.json("from")).toBe("+49301234567");
res.json("a.b.0.c") walks a dotted
JSON path and returns a native JS value;
res.status /
res.body /
res.header(name) are there too. For
requests with headers or a body, pass an options object — an object body is
JSON-encoded for you:
await http("POST", env("API_URL") + "/calls", {
headers: { "Content-Type": "application/json" },
body: { to: "+49301234567" },
});
Since each request is a Promise, Promise.all fires several at once:
const [a, b] = await Promise.all([
http("GET", env("API_URL") + "/calls/1"),
http("GET", env("API_URL") + "/calls/2"),
]);
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.
new MockServer() starts the server;
respond(...) answers a route (statically
or from a per-request closure),
jsonResponse builds the body, and
lastRequest /
requestCount inspect what arrived:
const hooks = new MockServer();
// Answer the webhook with the call actions to perform.
hooks.respond("POST", "/voice", (req) => {
const event = JSON.parse(req.body).event;
return event === "incoming_call"
? jsonResponse({ actions: [{ type: "answer" }] })
: jsonResponse({ actions: [{ type: "hangup" }] });
});
// Tell the system under test where to send its webhooks.
await 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(() => expect(hooks.requestCount("/voice")).toBe(1), "10s");
const req = hooks.lastRequest("/voice");
expect(JSON.parse(req?.body ?? "{}").event).toBe("incoming_call");
Notes:
- The responder closure runs on the scenario thread, pumped from
until, so it may close over scenario state (a counter, a flag) — but it must stay pure request → response: no agent verbs and noawaitinside it. A scenario that never reaches anuntilalso never serves a request. - The request it receives is
{ method, path, query, headers, body }—bodyis the raw string, so parse it withJSON.parse(unlike the HTTP response, which has ajson(path)helper). - Routes match by exact path or
regex("/calls/.*"), and by a method or any ("*"/ omit the method). Re-register a route withrespond(...)to stage the next answer between webhooks. - A static object works where you don’t need the request:
hooks.respond("/health", { status: 204 }), orhooks.respond("/config", jsonResponse({ ok: true })). - The server is stopped automatically at the end of the scenario;
stop()ends it early.
See the MockServer and MockRequestInfo 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 *.js, 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-flowmodule.
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.js" # a file or a directory of *.js
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.js"
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:
| Flag | Env | Overrides |
|---|---|---|
--listen <host:port> | RINGO_FLOW_SERVE_LISTEN | full listen address |
--port <port> | RINGO_FLOW_SERVE_PORT | listen port only (keeps host; wins over --listen) |
--timeout <dur> | RINGO_FLOW_SERVE_TIMEOUT | default per-run timeout |
--metrics <true|false> | RINGO_FLOW_SERVE_METRICS | /metrics on/off |
--binary <path> | RINGO_FLOW_SERVE_BINARY | the spawned ringo-flow binary |
--log-level <lvl> | RINGO_FLOW_SERVE_LOG_LEVEL | log level (trace/debug/info/warn/error; default info). RUST_LOG overrides it. |
--log-format <fmt> | RINGO_FLOW_SERVE_LOG_FORMAT | text (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
| Endpoint | Method | Description |
|---|---|---|
/metrics | GET | Prometheus exposition (scrape this) |
/monitors | GET | The configured monitors as JSON |
/run/{name} | POST | Run 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). |
/healthz | GET | Liveness — 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.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
ringo_monitor_runs_total | counter | monitor, result | Runs by result (pass/fail/timeout) |
ringo_monitor_last_success | gauge | monitor | 1 if the last run passed, else 0 |
ringo_monitor_last_duration_seconds | gauge | monitor | Duration of the last run |
ringo_monitor_last_run_timestamp_seconds | gauge | monitor | Unix time of the last run |
ringo_scenario_last_success | gauge | monitor, scenario | 1 if that scenario passed in the last run |
ringo_agent_registered | gauge | monitor, scenario, agent | 1 if the agent was registered |
ringo_call_mos | gauge | monitor, scenario, agent | MOS of the last call |
ringo_call_jitter_ms | gauge | monitor, scenario, agent | Jitter, milliseconds |
ringo_call_packet_loss_pct | gauge | monitor, scenario, agent | Packet loss, percent |
ringo_call_rtt_ms | gauge | monitor, scenario, agent | Round-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/metricsat all (returns 404).bearer_token = "…"— requireAuthorization: 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.js";
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
| Option | Default | Description |
|---|---|---|
enable | false | Enable the service. |
package | flake’s ringo-flow | Package providing the ringo-flow binary. |
listen | "127.0.0.1:9090" | Bind address for the HTTP API and /metrics. |
openFirewall | false | Open the TCP port parsed from listen. |
scheduler | true | Run the cron scheduler for scheduled monitors. |
timeout | "300s" | Global per-run timeout (overridable per monitor). |
metrics.enable | true | Expose Prometheus /metrics. |
monitors.<name> | {} | Scenario monitors (see below). |
logLevel | "info" | Log level (RUST_LOG overrides it). |
logFormat | "json" | text or json (→ journald). |
environmentFile | null | systemd EnvironmentFile for secrets (see below). |
settings | {} | Extra raw keys merged into the generated monitor.toml. |
configFile | null | Use 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:
| Field | Default | Description |
|---|---|---|
path | — | Scenario file (.js, or a deprecated .rhai) or a directory of scenarios. |
schedule | null | Cron (5- or 6-field); null = on-demand only via POST /run/<name>. |
timeout | null | Per-monitor timeout override. |
envFile | [] | dotenv files with SIP credentials (see Secrets). |
scenario | null | Scenario-name filter when path is a directory. |
tags | [] | Tag filter. |
When
configFileis set it takes full control — thelisten,monitors,scheduler,timeoutandmetricsoptions no longer shape the file (thoughlistenis still used foropenFirewall).
Secrets
ringo-flow serve needs two kinds of secret, and neither belongs in the Nix
store:
- SIP credentials — the scenarios read
SIP_DOMAIN/*_USER/*_PASSfrom a monitor’senvFile(a dotenv file), not frommonitor.toml. /metricsbearer token — read only from the environment variableRINGO_FLOW_SERVE_METRICS_TOKEN, injected viaenvironmentFile.
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.js";
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.
ringo-flow JS API
Namespaces
Enumerations
Classes
Interfaces
- AgentConfig
- AgentInfo
- Assertion
- AudioSpec
- CallQuality
- HttpOptions
- HttpResponse
- MockRequestInfo
- MockResponseSpec
- PathMatch
- Peer
- ScenarioEachFactory
- ScenarioOptions
Type Aliases
Functions
- defaultTimeout
- env
- expect
- file
- http
- jsonResponse
- loadEnv
- log
- regex
- scenario
- setup
- silence
- skip
- teardown
- textResponse
- tone
- until
- uuid
- verifyAudioConnection
- wait
Class: Agent
Constructors
Constructor
new Agent(
name,config):Agent
Parameters
name
string
config
Returns
Agent
Properties
headers
readonlyheaders:Record<string,string[]>
All received INVITE headers as { name: [value, …] } (repeated headers keep every value).
metadata
readonlymetadata:Record<string,unknown>
Free-form metadata attached at new Agent(...) via the metadata config field
(e.g. caller.metadata.role). Empty object if none was given.
peer?
readonlyoptionalpeer?:Peer
The current call’s remote party: { uri, number, name }, or undefined.
quality?
readonlyoptionalquality?:CallQuality
RTP media quality of the active/last call ({ mos, rtt, jitter, packetLoss }),
or undefined until metrics are available (no RTCP report yet).
reason?
readonlyoptionalreason?:string
receivedDtmf
readonlyreceivedDtmf:string
registered
readonlyregistered:boolean
state
readonlystate:State
statusCode?
readonlyoptionalstatusCode?:number
Methods
abortTransfer()
abortTransfer():
void
Returns
void
accept()
accept():
void
Returns
void
attendedTransfer()
attendedTransfer(
target):void
Start an attended transfer to a target: another Agent or a URI string.
Parameters
target
string | Agent
Returns
void
completeTransfer()
completeTransfer():
void
Returns
void
deflect()
deflect(
target):void
Deflect inbound calls (302) to a target: another Agent or a URI / number string.
Parameters
target
string | Agent
Returns
void
dial()
dial(
target):void
Dial a target: another Agent (at its AOR) or a SIP URI / number string.
Parameters
target
string | Agent
Returns
void
dtmf()
dtmf(
digits,gap?):void
dtmf(digits) sends back-to-back; dtmf(digits, gap) inserts a pause
(e.g. "200ms") between digits.
Parameters
digits
string
gap?
string
Returns
void
hangup()
hangup():
void
Returns
void
header()
header(
name):string|undefined
First value of a received INVITE header (a.header("X-Trace-Id")).
Parameters
name
string
Returns
string | undefined
hold()
hold():
void
Returns
void
info()
info():
AgentInfo
A snapshot of the agent’s observable state as an object. (For a JSON string,
just JSON.stringify(agent.info()).)
Returns
mute()
mute():
void
Returns
void
register()
register():
void
Returns
void
respondIncoming()
respondIncoming(
code,reason,headers?):void
Answer inbound INVITEs with a custom SIP response instead of accepting.
respondIncoming(486, "Busy Here"), or with extra header lines:
respondIncoming(302, "Moved Temporarily", { Contact: "<sip:bob@example.com>" }).
Parameters
code
number
reason
string
headers?
Record<string, string>
Returns
void
resume()
resume():
void
Returns
void
sendAudio()
sendAudio(
spec):void
Set this agent’s audio source on the active call (a.sendAudio(tone(440))).
Parameters
spec
Returns
void
stopDeflect()
stopDeflect():
void
Returns
void
transfer()
transfer(
target):void
Blind-transfer the current call to a target: another Agent or a URI string.
Parameters
target
string | Agent
Returns
void
verifyAudio()
verifyAudio(
freq,within):Promise<void>
Assert the agent receives a freq Hz tone within within (e.g. "5s").
Returns a Promise: the blocking detection window runs on the runtime’s
blocking pool, so await Promise.all([a.verifyAudio(...), b.verifyAudio(...)])
listens on several agents concurrently instead of serially.
Parameters
freq
number
within
string
Returns
Promise<void>
Class: MockServer
Constructors
Constructor
new MockServer(
opts?):MockServer
Parameters
opts?
port?
number
Returns
MockServer
Properties
port
readonlyport:number
url
readonlyurl:string
The server’s base URL (http://127.0.0.1:<port>), to point the SUT at.
Methods
lastRequest()
lastRequest(
path):MockRequestInfo|undefined
The most recent request on path (string or regex(...)) as
{ method, path, query, headers, body }, or undefined.
Parameters
path
string | PathMatch
Returns
MockRequestInfo | undefined
requestCount()
requestCount(
path):number
How many requests arrived on path (string or regex(...), any method) —
poll via until.
Parameters
path
string | PathMatch
Returns
number
requests()
requests(
path):MockRequestInfo[]
All requests on path (string or regex(...)), in arrival order.
Parameters
path
string | PathMatch
Returns
respond()
Call Signature
respond(
method,path,response):void
Register a route: a static response object, or a per-request closure (runs on
the scenario thread, pumped from until). path is a string or
regex(...); a leading method arg is optional.
Parameters
method
string
path
string | PathMatch
response
Returns
void
Call Signature
respond(
path,response):void
Register a route: a static response object, or a per-request closure (runs on
the scenario thread, pumped from until). path is a string or
regex(...); a leading method arg is optional.
Parameters
path
string | PathMatch
response
Returns
void
stop()
stop():
void
Stop the server early (it otherwise stops at scenario teardown).
Returns
void
Function: defaultTimeout()
defaultTimeout(
duration):void
Set the default until timeout for the rest of the script (e.g. "10s").
Parameters
duration
string
Returns
void
Function: env()
env(
key):string
Read a variable: the per-file env map (--env-file/<scenario>.env/loadEnv)
first, then the process environment; errors if unset.
Parameters
key
string
Returns
string
Function: expect()
expect<
T>(actual):Assertion<T>
Begin a fluent assertion on a value.
Type Parameters
T
T
Parameters
actual
T
Returns
Assertion<T>
Function: file()
file(
path):AudioSpec
A WAV-file audio source for sendAudio.
Parameters
path
string
Returns
Function: http()
http(
method,url,opts?):Promise<HttpResponse>
Performs the request off-thread and resolves with the response; await it.
await Promise.all([http(...), http(...)]) fires several requests concurrently.
Parameters
method
string
url
string
opts?
Returns
Promise<HttpResponse>
Function: jsonResponse()
jsonResponse(
body,status?):MockResponseSpec
A application/json response spec (body JSON-encoded) for respond.
Parameters
body
any
status?
number
Returns
Function: loadEnv()
loadEnv(
path):void
Merge a dotenv file into this file’s env at run time, resolved relative to the scenario’s directory (later loads win).
Parameters
path
string
Returns
void
Function: log()
log(
msg):void
Print a timestamped note to the scenario log (and the --json stream).
Parameters
msg
string
Returns
void
Function: regex()
regex(
pattern):PathMatch
A regex path matcher for the mock server’s respond/requestCount/lastRequest/requests.
Parameters
pattern
string
Returns
Function: scenario()
Call Signature
scenario(
name,body):void
Register a scenario(...): with a third arg, a is the options object and
b the body; otherwise a is the body. Persists the body and records
tags/skip/only from the options.
Parameters
name
string
body
Returns
void
Call Signature
scenario(
name,opts,body):void
Register a scenario(...): with a third arg, a is the options object and
b the body; otherwise a is the body. Persists the body and records
tags/skip/only from the options.
Parameters
name
string
opts
body
Returns
void
Function: setup()
setup(
body):void
Register a setup(fn) body, run before every scenario. Its return value becomes
the per-scenario context passed to the body (and to teardown).
Parameters
body
() => any
Returns
void
Function: silence()
silence():
AudioSpec
A silent audio source for sendAudio.
Returns
Function: skip()
skip(
reason?):void
Abort the current scenario as skipped (reported, not failed).
Parameters
reason?
string
Returns
void
Function: teardown()
teardown(
body):void
Register a teardown(fn) body, run after every scenario with the context that
setup returned.
Parameters
body
(ctx) => void
Returns
void
Function: textResponse()
textResponse(
body,status?):MockResponseSpec
A text/plain response spec for respond.
Parameters
body
string
status?
number
Returns
Function: tone()
tone(
freq):AudioSpec
A constant-tone audio source for sendAudio.
Parameters
freq
number
Returns
Function: until()
until(
cond,within?):Promise<any>
Resolves with cond’s value once it stops throwing, or rejects on timeout.
await it (reads as await until(...)); the resolved value lets .value() bind a
verified value. While waiting it yields the event loop, so several until/
verifyAudio can run under await Promise.all([...]).
Parameters
cond
() => unknown
within?
string
Returns
Promise<any>
Function: uuid()
uuid():
string
A fresh random UUID v4 string.
Returns
string
Function: verifyAudioConnection()
verifyAudioConnection(
a,b):Promise<void>
Assert two-way audio between two agents (a→b then b→a); resolves on success. Blocking detection runs on the runtime’s blocking pool so the JS thread is free.
Parameters
a
b
Returns
Promise<void>
Function: wait()
wait(
seconds):Promise<void>
Hold for N seconds; rejects if a call that is established at the start drops.
Parameters
seconds
number
Returns
Promise<void>
scenario
Functions
Function: each()
each<
T>(table):ScenarioEachFactory<T>
Type Parameters
T
T
Parameters
table
T[]
Returns
Interface: AgentConfig
Properties
auth_user?
optionalauth_user?:string
auth user, if it differs from username.
deflect_to?
optionaldeflect_to?:string
deflect inbound calls with a 302 to this URI/number.
display_name?
optionaldisplay_name?:string
caller display name.
domain
domain:
string
SIP domain / registrar. Required.
dtmf_mode?
optionaldtmf_mode?:string
"info" for reliable headless DTMF (SIP INFO).
headers?
optionalheaders?:Record<string,string|string[]>
({ "X-Foo": ["a", "b"] }) sends the header repeated, once per element.
media_enc?
optionalmedia_enc?:string
media encryption, e.g. srtp, zrtp, dtls_srtp.
metadata?
optionalmetadata?:Record<string,unknown>
(e.g. { role: "caller" }); not used for SIP.
mwi?
optionalmwi?:boolean
subscribe to message-waiting indication.
outbound?
optionaloutbound?:string
outbound proxy URI.
password?
optionalpassword?:string
Auth password.
regint?
optionalregint?:number
re-registration interval (seconds); 0 disables.
stun_server?
optionalstun_server?:string
STUN server, e.g. stun:host:port.
transport?
optionaltransport?:string
udp (default), tcp or tls.
username
username:
string
SIP user (registration / auth). Required.
Interface: AgentInfo
Properties
aor
aor:
string
The agent’s address-of-record (sip:user@domain).
calls
calls:
number
Number of active calls on this agent.
name
name:
string
The agent’s name (as passed to new Agent(name, …)).
peer?
optionalpeer?:Peer
The current call’s remote party, if there is a call.
reason?
optionalreason?:string
SIP reason phrase of the last response, if any.
registered
registered:
boolean
Whether the agent is currently registered.
state
state:
State
Current call phase (compare against State.*).
statusCode?
optionalstatusCode?:number
SIP status code of the last response, if any.
Interface: Assertion<T>
Type Parameters
T
T
Properties
not
readonlynot:Assertion<T>
Negate the next matcher (Jest-style): expect(x).not.toBe(2). Applies only to
the matcher immediately after — the handle it returns is positive again.
Methods
as()
as(
label):Assertion<T>
Label this assertion (.as("caller registered")) — chainable, Jest has no
equivalent so the name avoids colliding with a test-grouping describe.
Parameters
label
string
Returns
Assertion<T>
toBe()
toBe(
expected):Assertion<T>
Parameters
expected
T
Returns
Assertion<T>
toBeDefined()
toBeDefined():
Assertion<T>
Returns
Assertion<T>
toBeEmpty()
toBeEmpty():
Assertion<T>
Returns
Assertion<T>
toBeFalsy()
toBeFalsy():
Assertion<T>
Returns
Assertion<T>
toBeGreaterThan()
toBeGreaterThan(
n):Assertion<T>
Parameters
n
number
Returns
Assertion<T>
toBeGreaterThanOrEqual()
toBeGreaterThanOrEqual(
n):Assertion<T>
Parameters
n
number
Returns
Assertion<T>
toBeLessThan()
toBeLessThan(
n):Assertion<T>
Parameters
n
number
Returns
Assertion<T>
toBeLessThanOrEqual()
toBeLessThanOrEqual(
n):Assertion<T>
Parameters
n
number
Returns
Assertion<T>
toBeTruthy()
toBeTruthy():
Assertion<T>
Returns
Assertion<T>
toBeUndefined()
toBeUndefined():
Assertion<T>
Returns
Assertion<T>
toContain()
toContain(
needle):Assertion<T>
Parameters
needle
string
Returns
Assertion<T>
toMatch()
toMatch(
pattern):Assertion<T>
Parameters
pattern
string
Returns
Assertion<T>
value()
value():
T
The value under assertion, so a verified value can be bound, e.g.
const id = await until(() => expect(callee.header("X-Id")).toBeDefined().value()).
Returns
T
Interface: AudioSpec
Properties
__audioSpec?
readonlyoptional__audioSpec?:undefined
Interface: CallQuality
Properties
jitter
readonlyjitter:number
Jitter in milliseconds.
mos
readonlymos:number
Mean Opinion Score (1.0–5.0); higher is better.
packetLoss
readonlypacketLoss:number
Receive-side packet loss, in percent (0.0–100.0).
rtt
readonlyrtt:number
Round-trip time in milliseconds.
Interface: HttpOptions
Properties
body?
optionalbody?:string|object
request body; an object is JSON-encoded.
headers?
optionalheaders?:Record<string,string>
Request headers to send.
Interface: HttpResponse
Properties
body
readonlybody:string
status
readonlystatus:number
Methods
expectStatus()
expectStatus(
code):void
Parameters
code
number
Returns
void
header()
header(
name):string|undefined
Parameters
name
string
Returns
string | undefined
json()
json(
path?):any
The JSON value at a dotted path (empty for the whole body), as a native
JS value.
Parameters
path?
string
Returns
any
Interface: MockRequestInfo
Properties
body
body:
string
Raw request body.
headers
headers:
Record<string,string>
Request headers.
method
method:
string
HTTP method (GET, POST, …).
path
path:
string
Request path (without query string).
query
query:
Record<string,string>
Parsed query-string parameters.
Interface: MockResponseSpec
Properties
body?
optionalbody?:string
Response body (a string; use jsonResponse/textResponse for shorthands).
contentType?
optionalcontentType?:string
Content-Type header to set.
headers?
optionalheaders?:Record<string,string>
Extra response headers.
status?
optionalstatus?:number
HTTP status code to return (default 200).
Interface: PathMatch
A regex path matcher built with regex(...), for the mock server’s path args.
Properties
__pathMatch?
readonlyoptional__pathMatch?:undefined
Interface: Peer
Properties
name?
readonlyoptionalname?:string
The remote party’s display name, if the call signalled one.
number
readonlynumber:string
The remote party’s number / user part.
uri
readonlyuri:string
Full SIP URI of the remote party (e.g. sip:bob@example.com).
Interface: ScenarioEachFactory()<T>
Type Parameters
T
T
Call Signature
ScenarioEachFactory(
name,body):void
Parameters
name
string
body
Returns
void
Call Signature
ScenarioEachFactory(
name,opts,body):void
Parameters
name
string
opts
body
Returns
void
Interface: ScenarioOptions
Properties
only?
optionalonly?:boolean
If any scenario sets only: true, only those run.
skip?
optionalskip?:string|boolean
true to skip, or a string reason (reported, not run).
tags?
optionaltags?:string[]
Tags for filtering with --tag / --exclude-tag.
Enumeration: State
Enumeration Members
Established
Established:
"established"
Idle
Idle:
"idle"
Ringing
Ringing:
"ringing"
Type Alias: MockResponder
MockResponder =
MockResponseSpec| ((req) =>MockResponseSpec)
A static response, or a closure invoked per request (runs on the scenario
thread, pumped from until, so it may close over scenario state).
Type Alias: ScenarioBody
ScenarioBody = (
ctx) =>void|Promise<void>
Parameters
ctx
any
Returns
void | Promise<void>
Type Alias: ScenarioEachBody<T>
ScenarioEachBody<
T> = (ctx,param) =>void|Promise<void>
Type Parameters
T
T
Parameters
ctx
any
param
T
Returns
void | Promise<void>
Rhai frontend (deprecated)
The Rhai frontend is deprecated and will be removed in a future release. Write new scenarios in JavaScript or TypeScript — see Writing scenarios. Existing
.rhaiscenarios keep running for now, and the Rhai API reference stays published until the frontend is removed.
ringo-flow started out with Rhai as its scripting language. It did the job, but writing real test suites in it turned out to be worse than writing them in JavaScript in three concrete ways.
Why it is going away
No usable IDE tooling. This is the big one. A Rhai language server does exist — rhaiscript/lsp — but its own README calls it “experimental … incomplete and not recommended for general use”, it has never cut a release, and its last commit landed in October 2022. No editor plugin ships it either: the VS Code extension lives inside that repo and has to be built and side-loaded by hand. Getting anything beyond syntax highlighting is a build-it-yourself exercise, and what you end up with is a stale experiment.
In practice that means no completion, no hover, no jump-to-definition and — most
importantly — no errors until you actually run the script. A typo in a config key
or a matcher name surfaces after baresip has started and the SIP traffic has
begun. The shipped .d.rhai documents the signatures, but
nothing checks your script against them.
The JS frontend ships a generated ringo-flow.d.ts instead.
Every editor with TypeScript support — that is, essentially all of them —
type-checks the whole DSL: agent config keys, matcher names, argument types and
await-ing the blocking verbs. With // @ts-check in a plain .js file, or by
authoring in .ts, mistakes surface while you type instead of mid-call.
Missing language features. Rhai’s scoping rules bite as soon as a suite grows
past one file’s worth of top-level code. Most painfully, a fn cannot see
top-level variables — Rhai functions do not close over the enclosing scope, so
shared fixtures have to be threaded through the scenario context or re-created in
every helper. Neither is there async/await, so concurrent waiting needs a
dedicated parallel(...) verb rather than the language’s own primitives.
JavaScript has closures, real modules, async/await, destructuring, template
literals, JSON and a standard library — none of which had to be invented for
the DSL.
Hardly anyone knows it. Rhai is a niche embedded scripting language, so
everyone touching a scenario has to learn a new syntax (#{ … } maps, ||
closures, State::Ringing paths) before writing the first test. JavaScript is
already familiar to nearly everyone who would write an integration test, and it
is what test runners in the wider ecosystem look like — expect(...),
Jest-style matchers, scenario.each tables.
Migrating a scenario
The vocabulary maps almost one-to-one; the differences are naming
(snake_case → camelCase), object literals, and await on the blocking verbs.
| Rhai | JavaScript |
|---|---|
agent("A", #{ … }) | new Agent("A", { … }) |
#{ key: value } | { key: value } |
|| assert(x).equals(y) | () => expect(x).toBe(y) |
await_until(cond, "10s") | await until(cond, "10s") |
default_timeout("10s") | defaultTimeout("10s") |
State::Ringing | State.Ringing |
assert(x).is_true() | expect(x).toBeTruthy() |
assert(x).is_present() | expect(x).toBeDefined() |
assert(x).at_least(n) | expect(x).toBeGreaterThanOrEqual(n) |
assert(x).at_most(n) | expect(x).toBeLessThanOrEqual(n) |
assert(x).contains(s) | expect(x).toContain(s) |
a.send_audio(silent()) | a.sendAudio(silence()) |
a.verify_audio(440, "5s") | await a.verifyAudio(440, "5s") |
verify_audio_connection(a, b) | await verifyAudioConnection(a, b) |
agent.quality.packet_loss | agent.quality?.packetLoss |
mock_server() | new MockServer() |
hooks.on(m, p, |req| …) | hooks.respond(m, p, (req) => …) |
hooks.request_count(p) | hooks.requestCount(p) |
hooks.last_request(p) | hooks.lastRequest(p) |
req.json("event") | JSON.parse(req.body).event |
parallel([|| …, || …]) | await Promise.all([…]) |
load_env("ci.env") | loadEnv("ci.env") |
Two structural differences beyond the names:
awaitthe blocking verbs.until,wait,http,verifyAudioandverifyAudioConnectionreturn Promises. Instant verbs (dial,accept,hangup,dtmf, …) stay synchronous. A scenario body that awaits anything must beasync.- Fixtures live in closure variables. Rhai forced shared state through the
setup()context because functions could not see the top level. In JS you can still use thectxargument, but declaringlet caller;up top and assigning it insetup()is both shorter and fully typed — see Writing scenarios.
The full side-by-side is in the two API references: JS and Rhai.
Running Rhai in the meantime
Nothing changes for existing scenarios. The frontend is picked from the file
extension, so .rhai files keep using Rhai and .js files use the JS frontend:
ringo-flow run legacy.rhai # Rhai (deprecated)
ringo-flow run scenario.js # JavaScript
--lang rhai forces the Rhai frontend explicitly, and ringo-flow definitions
still writes the .d.rhai. A directory containing any .js file selects the JS
frontend, so migrate a suite file by file rather than mixing both in one run.
API reference (Rhai — deprecated)
The Rhai frontend is deprecated and will be removed in a future release. New scenarios should use the JavaScript/TypeScript frontend — see Writing scenarios and the JS API reference. This page documents the Rhai vocabulary for existing scenarios; Rhai frontend explains why and how to migrate.
The complete Rhai 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 timing —
await_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 foragent.state. - AudioSpec — audio sources for
send_audio(tone,file,silent).
- CallQuality
- Assertions and matchers — the fluent
assert(x).<matcher>(…), used insideawait_until. - HTTP —
http(…)requests and the response. - HTTP mock server —
mock_server(…), routes and responders for webhook-driven flows.- Mock request — the recorded request a responder/assertion sees.
- Environment —
env,load_env— credentials stay out of scripts. - Utilities —
log,uuid.
For editors and agents, the whole Rhai API is also available as Rhai type definitions (.d.rhai). In practice this is a reference you read, not tooling you get: the only Rhai language server is an unreleased experiment that no editor plugin ships, so there is no type-checking and no inline error reporting — one of the reasons the frontend is being retired in favour of JS/TS.
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 options — agent(name, #{ … }):
| Field | Type | Description |
|---|---|---|
username | string · required | SIP user (registration / auth) |
domain | string · required | SIP domain / registrar |
password | string | auth password |
display_name | string | caller display name |
transport | string | udp (default), tcp or tls |
auth_user | string | auth user, if it differs from username |
outbound | string | outbound proxy URI |
stun_server | string | STUN server, e.g. stun:host:port |
media_enc | string | media encryption, e.g. srtp, zrtp, dtls_srtp |
regint | int | re-registration interval (seconds); 0 disables |
mwi | bool | subscribe to message-waiting indication |
dtmf_mode | string | "info" for reliable headless DTMF (SIP INFO) |
headers | map | extra 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_to | string | deflect inbound calls with a 302 to this URI/number (toggle at runtime with deflect()/stop_deflect()) |
metadata | map | free-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)
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)
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)
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)
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)
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
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.
Options — http(method, url, #{ … }):
| Field | Type | Description |
|---|---|---|
headers | map | request headers, e.g. #{ "Content-Type": "application/json" } |
body | string or map | request 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.
Config — mock_server(#{ … }):
| Field | Type | Description |
|---|---|---|
port | int | port 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.