Tutorial: residential PBX, end-to-end
April 28, 2026 · View on GitHub
A complete worked example: from "empty machine" to "first call placed, CDRs verified, node drained, config rolled back." Every step uses tools this project ships. By the end you'll have the muscle memory for which tool to reach for at each stage of a deployment.
What we're building. A residential PBX serving subscribers at
pbx.example.com, MySQL for credentials and CDRs, NAT traversal via nathelper, accounting via theaccmodule. No clustering — single node — but the workflow extends naturally.
Prerequisites
- An MCP-aware client (Claude Desktop, Claude Code, Cursor, ...) connected
to
opensips-mcp. Thequickstart.mdcovers setup for each. - Docker + docker-compose available on the host (
docker_*tools wrap these). Or a system OpenSIPS install if you prefer not to use Docker. sudoaccess for the eventualsetcapon sngrep.
1 — survey what's available
cfg_list_scenarios()
Returns 15 scenarios. We want residential_pbx. Get its required vs
optional knobs:
cfg_get_scenario_params(scenario="residential_pbx")
Output (abbreviated):
{
"name": "residential_pbx",
"description": "Residential PBX with routing",
"required_params": ["db_url", "domain"],
"optional_params": [
"children", "dr_group_id", "fr_inv_timeout", "fr_timeout",
"httpd_port", "listen_ip", "listen_port", "log_level",
"max_contacts"
]
}
The required params are minimum input; the optional params are every
template knob (auto-discovered from default(...) markers — no hidden
tunables).
Don't know what one of these does? Ask
cfg_explain(topic="<name>"). The drift-prevention test guarantees every function in every scenario is documented.
2 — generate the config
cfg_generate(scenario="residential_pbx", params={
"db_url": "mysql://opensips:CHANGE_ME@db:3306/opensips",
"domain": "pbx.example.com",
"max_contacts": 5
})
Returns a 235-line opensips.cfg. Save it to opensips.cfg somewhere
under your safe-write path (/tmp, /var/tmp, $HOME, or
OPENSIPS_MCP_OUT_DIR_ROOT).
3 — lint
cfg_lint(config_content="<the rendered config>")
Expected: 0 errors, possibly 1 warning (OPS011, "no anti-flood module") because the residential PBX template doesn't ship pike by default — it's tuned for trusted-LAN deployments. If you're exposing this proxy publicly, add pike:
cfg_add_module(
config_content="<config>",
module_name="pike",
params={"reqs_density_per_unit": 30, "sampling_time_unit": 2}
)
Re-lint. Warning should be gone.
4 — validate (against opensips -C)
cfg_validate(config_content="<config>")
If the host running the MCP has the opensips binary installed, this
runs the real syntax checker and surfaces any line-level issues. If it
doesn't, you'll get a structured "binary unavailable at /usr/sbin/opensips"
message — the linter has done its job; the syntax check is deferred to
deploy-time.
5 — backup before any later change
cfg_backup(config_path="/tmp/opensips.cfg")
Returns the timestamped backup path (opensips.cfg.bak-YYYYMMDD-HHMMSS).
Make this a habit; rolling back is a one-call operation.
6 — deploy
If you're using Docker:
docker_list_scenarios()
docker_deploy_scenario(scenario="residential_pbx", env={
"OPENSIPS_DB_URL": "mysql://opensips:CHANGE_ME@db:3306/opensips",
"OPENSIPS_DOMAIN": "pbx.example.com"
})
Wait ~10 seconds for OpenSIPS to bind sockets and connect to MySQL.
docker_status()
Confirm the container is running and not in a crash loop.
If you're on bare metal: copy the config to /etc/opensips/opensips.cfg,
run opensips -C -f /etc/opensips/opensips.cfg to syntax-check, then
systemctl restart opensips.
7 — confirm reachability
Before doing anything else, prove the proxy is alive:
sip_options_ping(target="127.0.0.1:5060", count=3)
Expected: succeeded=3, avg_rtt_ms < 5. If you get timeouts, the
proxy isn't bound, the firewall is in the way, or the container's
network mode is wrong.
health_check()
Confirms MI is reachable + basic stats work. If MI is dead, every subsequent tool call will fail; debug this first.
8 — provision test subscribers
Build a CSV with a handful of test users:
username,domain,password,email_address
alice,pbx.example.com,test-secret-1,alice@pbx.example.com
bob,pbx.example.com,test-secret-2,bob@pbx.example.com
charlie,pbx.example.com,test-secret-3,charlie@pbx.example.com
Dry-run first — always:
subscriber_bulk_import(
csv_content="<the csv above>",
dry_run=True,
on_conflict="skip"
)
Expected:
{ "dry_run": true, "total_rows": 3, "would_insert": 3, "would_skip": 0 }
Now commit:
subscriber_bulk_import(
csv_content="<same csv>",
dry_run=False,
on_conflict="skip"
)
Expected: inserted: 3, skipped: 0, errors: [].
9 — register a test phone
Point a softphone (Linphone, Zoiper, MicroSIP) at the proxy:
| Setting | Value |
|---|---|
| Username | alice |
| Domain | pbx.example.com |
| Password | test-secret-1 |
| Outbound proxy | <your-host-ip>:5060 |
| Transport | UDP |
Watch the registration succeed:
ul_show_contacts(table="location", aor="alice@pbx.example.com")
Expected: a Contact line showing the softphone's IP, expiry, and
a received= field if the phone is behind NAT.
If the phone says "registration failed":
registrar_stats()
Look at the rejection counter. If it's incrementing, auth is failing —
verify with subscriber_get(username="alice", domain="pbx.example.com").
If registrar_stats shows zero attempts at all: SIP isn't reaching
OpenSIPS. Use sngrep (sngrep-and-ngrep.md) to
confirm.
10 — observe a real call
Have alice call bob (or 192.168.x.y, or any reachable destination).
Watch the dialog appear:
dlg_list()
Filter to one call:
dlg_list(callid="<from sngrep or homer>")
Snapshot the system load:
perf_hotspots()
Look at:
stats.dialog:active_dialogs— should match the number of calls in flight.stats.shmem:used_size— under 50% headroom for now? Bumpcfg_tunelater.stats.tm:received_replies— climbing, no errors.
11 — generate synthetic load
Build a SIPp scenario:
sip_generate_sipp_scenario(
kind="invite_uas_ringing",
target="<your-host>:5060",
from_user="alice",
to_user="bob",
calls_per_sec=10,
total_calls=100
)
Returns:
scenario_invite_uas_ringing.xmlcontent.- The exact
sipp -sf scenario_... -rsa ... -r ... -m ...command.
Save the XML to disk on a SIPp host (NOT the OpenSIPS host — load testing
from the same host as the proxy is the most common way to make a load
test useless), edit <local-ip> to the SIPp host's address, run.
While the test runs:
perf_sample_window(duration_sec=30, sample_count=10)
Returns a 10-sample time-series of core, tm, sl, dialog,
usrloc stats. Inspect the tm:received_replies and
dialog:active_dialogs deltas across the window.
12 — inspect CDRs
After the test:
cdr_summary(
date_from="2026-04-25T14:00:00",
date_to="2026-04-25T15:00:00",
group_by="hour"
)
Returns volume + answered/failed breakdown per hour.
cdr_count_by_code(
date_from="2026-04-25T14:00:00",
date_to="2026-04-25T15:00:00"
)
Returns histogram of SIP response codes. Healthy load test:
mostly 200 OK, low 408 (timeout from receiver), zero 503.
Audit for anomalies:
cdr_audit(
date_from="2026-04-25T14:00:00",
date_to="2026-04-25T15:00:00",
max_gap_sec=60
)
Looks for:
- Gaps — was the proxy receiving any calls during a stretch you expected traffic?
- Duplicate Call-IDs — same call recorded twice (typically from acc fired on both 200 and dialog end).
- Anomalies — negative durations, calls connected with 0-second duration, calls > 4 hours.
13 — practice draining (even though we're single-node)
In a multi-node cluster:
cluster_drain_node(cluster_id=1, node_id=2, timeout_sec=60)
Sets the node's clusterer status to disabled (peers stop sending new
dialogs to it), then polls dialog:active_dialogs every 5 seconds until
it hits zero or the timeout fires. Returns drained: true when
ready-to-stop.
Promote it back when maintenance is done:
cluster_promote_node(cluster_id=1, node_id=2)
Cluster sync health check:
cluster_sync_check(cluster_id=1)
Returns nodes / capabilities / sharing tags / topology in one view, plus
a derived healthy: true|false.
14 — change something, then roll it back
You decided you want to tighten the registration interval. Edit the config — but back it up first (you already did at step 5; do it again right before the change):
cfg_backup(config_path="/etc/opensips/opensips.cfg")
Returns /etc/opensips/opensips.cfg.bak-20260425-153012.
Modify the config (manually, or via cfg_edit with a description). Now
push it. If OpenSIPS fails to reload — the new min_expires is bogus,
say — roll back:
cfg_rollback(
backup_path="/etc/opensips/opensips.cfg.bak-20260425-153012",
target_path="/etc/opensips/opensips.cfg",
validate_after=True
)
The rollback itself takes a pre-rollback- snapshot before overwriting
— so even rolling back is reversible one more step. Validation runs
opensips -C on the restored config and surfaces any issues; the
rollback is not undone on validation failure (you asked for the
restore explicitly), but the result is reported.
To make OpenSIPS pick up the data-table changes (dispatcher, permissions, dialplan, ...) without a full restart:
reload_data_tables()
Fires every applicable *_reload MI command in dependency order.
Missing modules are reported as skipped, not failures. Pre-flight
the operation:
reload_dry_run()
Returns the planned sequence of reload commands.
15 — postmortem when things break
You'll have a bad day eventually. The diagnostic chain that consistently works:
health_check()— is OpenSIPS even responding to MI?perf_hotspots()— what's the system state right now?get_statistics(group="core")— error counters climbing?pike_list_blocked()— is anti-flood blocking legitimate traffic?dispatcher_list_db()— any backend marked inactive?homer_search_calls_v7(from_ts=..., to_ts=..., from_user="...")— what did the actual SIP messages look like?troubleshoot_calls(symptom="<one-liner>")— invokes the troubleshoot-prompt tree, which guides through the next steps.
For NAT-specific failures (one-way audio, registration drops):
diagnose_nat_flow(callid="<the broken call's id>")
Pulls dialog state + RTPEngine state + nathelper / tm / dialog stats into one report with heuristic hints.
For credential / auth issues:
troubleshoot_registration(username="alice", domain="pbx.example.com")
Fires the registration-troubleshooting prompt. The prompt drives the LLM to walk a complete diagnostic tree, calling the right MI commands at each step.
What you should now know how to do
After completing this tutorial, you have demonstrated:
- ✓ Picking and parameterising a scenario.
- ✓ Linting and validating a generated config.
- ✓ Backing up before changes.
- ✓ Deploying via Docker (or systemd).
- ✓ Provisioning subscribers in bulk.
- ✓ Verifying reachability with synthetic OPTIONS.
- ✓ Observing live calls via dialog + Homer.
- ✓ Driving load with a generated SIPp scenario.
- ✓ Inspecting CDRs for quality gaps.
- ✓ Draining cluster nodes (and promoting them back).
- ✓ Rolling back a config change with one tool call.
- ✓ Running a structured diagnostic when something fails.
This is the "what good looks like" workflow. From here, the project's
specialist scenarios — carrier_edge_sbc, siprec_recorder,
stir_shaken_attestation, e911_emergency, ims_scscf — follow the
same shape. Pick the scenario, parameterise, lint, validate, deploy,
observe.
Where to go next
- Build a different scenario:
cfg_list_scenarios()shows all 15.carrier_edge_sbcis the natural next step (carrier-grade with fraud + STIR/SHAKEN + topology hiding);webrtc_gatewayif you have browser clients;siprec_recorderif you need call recording. - Migrate from an older OpenSIPS:
cfg_migrate_plan(...)produces a Terraform-style plan;cfg_migrate(...)applies it. - Capacity planning:
cfg_tune(role="edge", cpu_cores=8, memory_mb=8192, cps=200, concurrent_calls=2000, dlg_size_bytes=4096). Thedlg_size_bytesparameter is critical — measure your actual dialog footprint before sizing shared memory; the default 2 KiB under-allocates 2-4x for any deployment using$dlg_val,topology_hiding, or B2BUA. - Security audit: the
security_auditprompt drives a structured review of any config; pair withcfg_lintfor the mechanical bits.