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 the acc module. No clustering — single node — but the workflow extends naturally.


Prerequisites

  • An MCP-aware client (Claude Desktop, Claude Code, Cursor, ...) connected to opensips-mcp. The quickstart.md covers 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.
  • sudo access for the eventual setcap on 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:

SettingValue
Usernamealice
Domainpbx.example.com
Passwordtest-secret-1
Outbound proxy<your-host-ip>:5060
TransportUDP

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? Bump cfg_tune later.
  • 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.xml content.
  • 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:

  1. health_check() — is OpenSIPS even responding to MI?
  2. perf_hotspots() — what's the system state right now?
  3. get_statistics(group="core") — error counters climbing?
  4. pike_list_blocked() — is anti-flood blocking legitimate traffic?
  5. dispatcher_list_db() — any backend marked inactive?
  6. homer_search_calls_v7(from_ts=..., to_ts=..., from_user="...") — what did the actual SIP messages look like?
  7. 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_sbc is the natural next step (carrier-grade with fraud + STIR/SHAKEN + topology hiding); webrtc_gateway if you have browser clients; siprec_recorder if 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). The dlg_size_bytes parameter 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_audit prompt drives a structured review of any config; pair with cfg_lint for the mechanical bits.