Ask what a capture holds, then ask about one call in it. The first command lists every dialog: a phone registering, the PBX probing it with an OPTIONS, two subscriptions, and one call placed, answered and hung up.
The second command draws that call's ladder, with media beside signaling. So the 64.3% packet loss on one direction of the audio sits next to the transactions it belongs to. That loss arrived in three bursts rather than spread evenly, which sounds far worse to the person on the call.
demos/mcp-stdio.sh tests/pcap-samples/Asterisk_ZFONE_XLITE.pcap list_dialogs \
| jq -c '.dialogs[] | {method, state, msg_count, duration_sec}'
{"method":"REGISTER","state":"Registered","msg_count":4,"duration_sec":0.01}
{"method":"OPTIONS","state":"Completed","msg_count":2,"duration_sec":0.008}
{"method":"SUBSCRIBE","state":"Terminated","msg_count":4,"duration_sec":0.018}
{"method":"SUBSCRIBE","state":"Terminated","msg_count":4,"duration_sec":0.015}
{"method":"INVITE","state":"Completed","msg_count":13,"duration_sec":23.712}
demos/mcp-stdio.sh tests/pcap-samples/Asterisk_ZFONE_XLITE.pcap render_ladder \
'{"call_id":"ZDYzOWVlNjEwM2NjZTBjNzliNmM1ZTNiOGZjNWFhN2E.","format":"text"}' \
| sed -n '/^Result:/p; /^SIP Transactions:/,$p'
Result: Completed (BYE)
SIP Transactions:
INVITE -> 401
ACK
INVITE -> 100 (7ms) -> 180 (30ms) -> 200 (7650ms)
ACK
INVITE -> 200 (23624ms)
ACK
BYE -> 200 (23712ms)
Media Streams:
RTP 192.168.10.40->192.168.10.41 PCMU SSRC=0xb72a7104 pkts=790 jitter=1ms loss=0.1%
Loss pattern: random (not bursty)
RTP 192.168.10.41->192.168.10.40 PCMU SSRC=0xbee0f2ed pkts=205 jitter=0ms loss=64.3%
Loss pattern: BURSTY (3 bursts, avg duration 2473ms) — perceptually worse than random loss
RTP 192.168.10.41->192.168.10.2 PCMU SSRC=0xbee0f2ed pkts=2 jitter=0ms loss=0.0%
Issues Detected:
- Duration asymmetry: A leg lasted 15.8s, B leg 11.5s (Δ 4.4s) — one side may have hung up or dropped media early.
An agent asks why a call failed and gets a verdict, not a packet list: signaling rather than media, the 486 that ended it, and media explicitly ruled out. Over MCP on stdio — no daemon, no port, no database.
demos/mcp-stdio.sh tests/pcap-samples/sip-problem-call.pcap \
triage_call '{"call_id":"busy-3a2b1c@192.0.2.30"}'
{
"call_id": "busy-3a2b1c@192.0.2.30",
"final_status_code": 486,
"media": {
"hints": [],
"nat_mismatch": false,
"no_media": false,
"one_way_audio": false,
"problem": false,
"stream_count": 0
},
"schema_version": 1,
"signaling": {
"hints": [
"Call failed: 486 Busy Here."
],
"problem": true
},
"source_exhausted": true,
"source_stopped_early": false,
"state": "Failed",
"verdict": "signaling"
}
Every finding cites the RFC section it comes from, says what the message should have carried and what it actually carried, and explains the consequence — so an agent can act on it instead of guessing what a lint code meant.
demos/mcp-stdio.sh tests/pcap-samples/sip-lint-findings.pcap \
lint_dialog '{"call_id":"lint-maxfwd@192.0.2.10"}' \
| jq '.findings[] | select(.severity == "error")'
{
"basis": "must",
"expected": "Contact: <sip:user@host>",
"explanation": "§12.1.1 makes the UAS add a Contact to the response. It is the remote target for the dialog the 2xx creates, so without it the caller has nowhere to send the ACK and nowhere to send the BYE. The call answers and then cannot be hung up cleanly.",
"frame_ref": "tests/pcap-samples/sip-lint-findings.pcap#1@ad0e14efbbab6ffa",
"message_index": 1,
"observed": "⟦untrusted-capture-data⟧2xx to INVITE with no Contact header field⟦/untrusted-capture-data⟧",
"rfc": 3261,
"rule_id": "SIP-3261-12.1.1-CONTACT-MISSING-IN-2XX",
"section": "12.1.1",
"severity": "error"
}
Any answer can be taken back to the bytes it came from. The pointer carries a digest, and verified means sipnab re-read that frame and it still hashes to what it hashed to when the answer was made. File access stays off until you name a root.
MCP_FILE_ROOT=tests/pcap-samples demos/mcp-stdio.sh \
tests/pcap-samples/sip-problem-call.pcap show_evidence \
'{"refs":["tests/pcap-samples/sip-problem-call.pcap#5@884d4525ec86f71b"],"max_bytes":64}'
{
"frames": [
{
"frame_bytes": 540,
"hex": "02 00 00 00 00 01 02 00 00 00 00 02 08 00 45 00 02 0e 00 00 40 00 40 11 b4 98 c0 00 02 1e c0 00 02 28 13 c4 13 c4 01 fa 00 00 49 4e 56 49 54 45 20 73 69 70 3a 64 61 76 65 40 31 39 32 2e 30 2e",
"hex_bytes_shown": 64,
"ordinal": 5,
"pointer": "tests/pcap-samples/sip-problem-call.pcap#5@884d4525ec86f71b",
"source": "sip-problem-call.pcap",
"status": "verified",
"truncated": true
}
],
"requested": 1,
"resolved": 1,
"schema_version": 1,
"summary": "1 of 1 pointer(s) resolved; 1 verified against a recorded digest",
"verified": 1
}
A B2BUA answers on one Call-ID and dials out on another, so the two halves of one conversation share no identifier. sipnab returns the matched leg with the strategy that matched it and flags heuristic_only when nothing but timing tied them together — an agent that is told how weak the match is can weigh it.
demos/mcp-stdio.sh tests/pcap-samples/b2bua-asterisk.pcapng \
find_correlated '{"call_id":"b2bua-caller-synth@203.0.113.1"}'
{
"capture_identity": {
"dialog_generation": 64,
"instance": "3990fa18d04ed7cf971eae-2",
"node": "sbc-edge-1",
"stream_generation": 2
},
"heuristic_only": true,
"legs": [
{
"call_id": "b2bua-leg-synth@203.0.113.101:5060",
"identifier_match": false,
"observed_gap_ms": 3,
"score": 50,
"strategy": "timing_heuristic"
}
],
"schema_version": 1,
"source_call_id": "b2bua-caller-synth@203.0.113.1",
"source_exhausted": true,
"source_stopped_early": false,
"timing_clock": {
"available": true,
"est_error_us": 0,
"max_error_us": 524000,
"synchronized": true
},
"total_matched": 1
}
The terminal UI, which is what sipnab -I capture.pcap opens. Browse SIP dialogs, select one, and drill into its call-flow ladder with the parsed message beside it. Open a different capture any time with O — no restart.
The detail pane of the terminal UI reads the parsed message beside the ladder. Cycle how SDP is shown with d, and toggle wrapping with w when a header runs wider than the pane.
Measuring a gap, in the terminal UI. Press m on any message to mark it, then move to another: the Δ badge shows the exact millisecond distance between the two, so “the 200 came back slowly” becomes a number.
Finding one call among thousands, in the terminal UI. Press / and the dialog list narrows as you type, matching method, From, To and Call-ID at once.
One conversation, two Call-IDs. A back-to-back user agent answers on one and dials out on another, so the two halves look like unrelated calls. In the terminal UI, x stitches the correlated legs into a single ladder you can read end to end.
The media side, in the terminal UI. Tab switches from dialogs to RTP streams. Selecting one opens its detail: SSRC, codec, jitter, loss and estimated MOS, with quality plotted over the life of the call.
No terminal UI at all. -N runs sipnab headless for a script or a pipeline: emit every dialog as JSON with --json, narrow to the calls that went wrong with --problems, or print a full call report with --report.