Repository navigation
MCP Tools Core
The core bundle is the smallest set of tools that answers what happened on
one call, and whether the fault was in signaling or media, from listing the
calls to the bytes that prove it.
Load only these tools with --mcp-tools core, or combine bundles as Choosing
which tools load describes. Every
tool on this page follows the rules every tool
follows, and each section states only
what its tool does differently. The MCP tool reference lists
the other bundles and finds any tool by name.
| Tool | Parameters | Returns |
|---|---|---|
capture_status |
-- | What this server captures: live or file, uptime, and whether stopping loses unsaved packets |
get_capture_report |
format? |
Whole-capture analysis: findings, orphaned media, STUN/ICMP evidence, what the caps shed |
list_dialogs |
filter?, limit?, cursor?, fields?, max_response_bytes?
|
A page of dialog summaries, with the total behind it |
aggregate_dialogs |
group_by, filter?, top_n?
|
Counts dialogs grouped by ONE field, in the store rather than in the model |
find_problems |
kinds?, filter?, limit?, cursor?, fields?, max_response_bytes?
|
A page of dialogs matching one or more diagnostic alias names |
search_messages |
query, limit?, cursor?, max_response_bytes?
|
A page of substring matches across method/From/To/UA/body, with the total behind it |
triage_call |
call_id |
First-pass verdict: signaling problem, media problem, both, or none, with evidence |
get_dialog |
call_id, max_messages?, cursor?, max_response_bytes?
|
Paginated dialog with full SIP messages |
rtp_stats |
call_id?, min_mos?, max_mos?, orphaned?, limit?, cursor?, max_response_bytes?
|
One call's RTP quality and diagnosis, or a capture-wide stream sweep |
Start here when you have a capture and no particular suspect. These answer "what is in it", "how much", and "is the server even seeing traffic".
Ask this first. It says what the server reads: a live interface or a capture file. No other tool reports that.
No parameters. Against tests/pcap-samples/sip-rtp-g711.pcap it returns:
Read unsaved before you stop a server. It is true only for a live
capture with no output file, where the packets exist only in memory. A capture
file is already on disk, so a file source is never unsaved.
source: "unknown" means the server has no capture context. It
reports unknown rather than a guess.
capture_identity says which capture this is and how many times its stores have
changed. Compare it across calls: a higher generation on the same instance means
the capture grew, and a different instance means open_capture loaded a
different file and every cursor you hold is void. The same object appears on
capture_status, list_dialogs, find_problems, tail_dialogs, find_correlated,
save_findings, open_capture and the capture-wide rtp_stats sweep. node names
the box that answered, which decides whose capture a fact came from once an agent
holds several servers at once.
load is null except while an open_capture read runs. During one:
"load": {
"filename": "outage-0722.pcap",
"instance": "1f4a17c8e2b91d40-2",
"packets": 184320, // climbing
"elapsed_sec": 4,
"done": false,
"error": null // set when a load stopped early
}The stores fill as the read goes, so dialogs appear before done. Wait for
done before concluding anything about how many calls the capture holds — a
partial answer looks exactly like a complete one.
Read the two unanalysed_ pairs before reading anything into dialog_count.
They report SIP that sipnab recognized and did not analyze, and they are the
only way to tell a capture that holds no calls from one whose calls fell
outside a port setting. They count different losses with different
remedies, so a non-zero figure names its own flag:
-
unanalysed_sip_messages/unanalysed_busiest_ports— plain SIP signaling with both ports outside--portrange. Re-run with a range that covers the ports listed. -
unanalysed_websocket_messages/unanalysed_websocket_ports— SIP-over-WebSocket (RFC 7118) on a port outside the WebSocket set. Re-run with--ws-portrangecovering the ports listed. Widening--portrangerecovers none of it. This is the common case on a WSS listener behind a reverse proxy, and on Kamailio, OpenSIPS and Janus, which all default outside sipnab's shipped 80/443/8080/8443.
Both are zero on a live capture, because the capture filter discards out-of-range traffic before sipnab can count it.
Asterisk's Manager Interface (AMI) speaks a line protocol on TCP/5038, and a
login sends Action: login, Username: and Secret: as plain text. AMI can
originate calls, read configuration and run shell commands, so a password seen
here is a more serious exposure than the SIP digest --digest-leak reports.
ami_cleartext lists each AMI greeting or cleartext login sipnab saw. AMI is
not SIP, so this traffic never appears in unanalysed_sip_messages. Read this
field to find it.
| Field | Type | Description |
|---|---|---|
observation |
object | One key: banner (the server greeted a client) or cleartext-login
|
observation.cleartext-login.username |
string? | Always null in this list, because sipnab counts sightings per address pair and not per account |
observation.cleartext-login.secret_present |
bool | Whether a Secret: line was there. A boolean, never the value
|
src / dst / dst_port
|
string / string / u16 | Sender, receiver and receiver's port of the payload. For a banner the sender is the AMI server, so dst_port is the client's port |
default_port |
bool | Whether dst_port is 5038 |
count |
u64 | Payloads carrying this observation between this pair |
A capture holding one AMI greeting and one login reports:
[
{
"count": 1,
"default_port": true,
"dst": "192.0.2.20",
"dst_port": 5038,
"observation": {
"cleartext-login": {
"secret_present": true,
"username": null
}
},
"src": "192.0.2.10"
},
{
"count": 1,
"default_port": false,
"dst": "192.0.2.10",
"dst_port": 40000,
"observation": {
"banner": {
"version": null
}
},
"src": "192.0.2.20"
}
]The secret is never recorded. A finding reports only that a Secret: line
was present (secret_present), never its value.
sipnab matches the AMI greeting and the login lines and records where they were. It does not follow AMI sessions or decode other AMI messages.
A login to AMI on another port is still reported, with
default_port: false.
This check needs no flag, unlike the detectors behind
security_findings. sipnab checks
every capture. A login with an AuthType: MD5 challenge and no Secret: line
reports secret_present: false, so it never counts as a cleartext password.
sipnab counts sightings per address pair and observation kind, so a client that
reconnects in a loop is one row with a count, whatever username each attempt
sent.
The field is always present. [] means sipnab checked and found no AMI
traffic.
capture_quality above counts what the capture lost. caveats counts
traffic sipnab saw and, by design, did not analyze.
| Key | What it counts |
|---|---|
media_creating_commands |
rtpengine subscribe, publish and start recording commands seen and not attributed to a call leg |
tls |
TLS decryption state — absent unless a decryptor ran |
Those commands create media for a call that is not one of its two legs, such as a recording fork. sipnab counts them and does not attribute their media to the call, so a two-party call still reports two streams.
Read this before reasoning about a stream count. When it is non-zero, the
capture holds media that no rtp_stats row explains. The key is always
present, and 0 means the capture held no such command.
Absent when nobody supplied keys, which is not a decryption failure. When a
decryptor ran, the block carries keylog_entries, sessions_with_keys,
app_data_records, decrypted_records, undecrypted_records,
late_recovered, late_evicted and read_nothing.
Act on read_nothing, not on the counts. Some TLS records are never
decrypted even when the keys are right, so app_data_records > decrypted_records is not by itself a failure. read_nothing is true only
when application data arrived and none of it decrypted.
A capture of ciphertext sipnab cannot open lists no dialogs, the same as a
quiet network. Before you conclude that a capture held no SIP, check
read_nothing. server_capabilities.can_decrypt says whether the build can
decrypt, and this block says whether this run did.
GET /v1/stats carries the same block under the same name.
The whole-capture analysis that --analyze prints. Use it for what concerns
the capture as a whole: orphaned media, STUN, ICMP errors quoting SIP or RTP,
and what the retention caps discarded.
get_dialog_report and
render_ladder answer for one Call-ID.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
format? |
string? |
"json", "yang-json", "markdown" or "text". Anything else fails with unknown format 'x', expected json|yang-json|markdown|text. |
"json". |
frames_read is the same count the Prometheus metric
sipnab_capture_packets_total reports.
yang-json answers the same analysis as json, encoded per
RFC 7951 against the YANG module
sipnab-diagnosis, which sipnab --print-yang-module prints. The document is
the first content block, and its one top-level member is
sipnab-diagnosis:capture-analysis. RFC 7951 allows no other member there, so
capture_identity, source_exhausted and source_stopped_early arrive in a
second block instead of inside the document. complete inside the document still reads false
while the capture loads, as it does in json.
The YANG section of Output formats lists every difference
between the two encodings.
The text rendering opens with a summary line. Against
tests/pcap-samples/sip-rtp-g711.pcap:
Capture analysis: 1 finding(s) across 2 dialog(s), 2 stream(s), 852 frame(s) read.
A capture with no findings returns a statement that sipnab found nothing, not an
empty body. Against tests/fixtures/sip_call.pcap:
No problems found in 1 dialog(s) and 0 stream(s) across 7 frame(s). Every frame decoded, no port gate discarded SIP, and no retention cap was reached — so this is a statement about the capture, not only about what sipnab could read of it.
json is the default and returns an OBJECT, serialized from the analysis
itself — schema_version, findings, dialogs_examined, streams_examined,
frames_read and complete — plus capture_identity, which names the capture
the analysis came from. Apart from capture_identity it is the object
--json-analyze prints and GET /v1/report answers, described by
tests/schemas/capture_analysis.schema.json.
It never carries filter, because this tool analyzes every dialog in the
store.
Each findings row is one ranked problem, worst first:
| Field | Type | Description |
|---|---|---|
kind |
string | Which problem this is, such as one_way_audio. |
severity |
string | How bad it is, carried on the row so a consumer sorts without a second table. |
occurrences |
u64 | How many times sipnab saw it, in units. Exact, and not the length of evidence, which a cap bounds. |
unit |
string | What one occurrence is — call, stream, frame. |
evidence |
object[] | Up to a fixed cap of verifiable instances. |
evidence_omitted |
u64 | Evidence rows the cap kept out. One evidence row can stand for several occurrences, so this is not occurrences minus the length of evidence. |
An evidence row carries call_id, endpoints (most specific first), at,
counts (named integers such as rtp_packets and streams) and note.
sipnab leaves out any of the five that does not apply rather than sending
null. A capture-level finding that belongs to no call, such as a STUN probe
or an undecodable frame, simply has no call_id.
// get_capture_report { "format": "json" } — one findings row, against
// tests/pcap-samples/sip-rtp-g711.pcap
{
"kind": "one_way_audio",
"severity": "critical",
"occurrences": 2,
"unit": "call",
"evidence": [
{
"call_id": "1-1966@10.0.2.20",
"endpoints": ["10.0.2.20:5060 -> 10.0.2.15", "SDP 10.0.2.20"],
"at": "2016-11-26T14:52:59.666393Z",
"counts": { "rtp_packets": 425, "streams": 1 },
"note": "10.0.2.15:27942 -> 10.0.2.20:6000 carried 425 packet(s); nothing came back the other way"
}
],
"evidence_omitted": 0
}Read complete before the findings: it is false when the capture
lost packets, hit a retention cap, or held frames no decoder could read, and a
findings list from such a capture is a floor, not a total.
complete also reads false while the capture is still loading, and for a
source whose read stopped before its end. source_exhausted and
source_stopped_early arrive beside it and say which.
markdown and text return a rendered document. A report drawn while the
capture is still loading, or over one whose read stopped before its end, ends
with an INCOMPLETE RUN block naming each reason, the same block --report
appends. A capture read in full adds no block. Ask for json when you want the
two booleans as fields.
GET /v1/report answers the same question over REST.
Returns one page of dialog summaries from the live capture store.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
filter? |
string? | A diagnostic alias name — problems, slow-setup, short-calls, one-way, nat-issues, codec-asym, ptime-asym, payload-asym, duration-asym, late-media — or a raw filter DSL expression, which may use a named filter as @name. A value may be bare or quoted: method == INVITE and method == 'INVITE' are the same filter. Anything else fails with invalid_params naming the position it stopped parsing at, or, for an undefined @name, listing the names the run defines. |
Every dialog in the store matches. |
limit? |
u32? | Ceiling is --mcp-max-rows (1000 unless the operator changed it). Higher clamps to it, 0 means the default. |
50 rows. |
cursor? |
string? | The previous response's next_cursor, verbatim (<RFC 3339 created_at>|<Call-ID>). A malformed timestamp half fails with invalid_params. |
Starts at the oldest dialog. |
fields? |
string[]? | Field names to keep on each row, plus call_id, which survives whether or not you list it. An unknown name fails with invalid_params naming both the typo and the fields a row carries. See Narrowing a row with fields. |
The whole row. |
max_response_bytes? |
u64? | Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole row with truncated_by: "bytes" — see Rules every tool follows. |
The ceiling. |
Returns — a page object, not a bare array:
| Field | Type | Description |
|---|---|---|
dialogs |
DialogSummary[] |
This page, oldest first (ties broken by Call-ID). |
returned |
usize | Rows in dialogs, so counting the array is never necessary. |
total_matched |
usize | Dialogs matching the filter across the whole store, whatever limit and cursor say. This is the number that answers "how many". |
by_method |
{method, count}[] |
total_matched split by the method that opened each dialog, dominant class first. Covers the whole match set, not this page. |
truncated |
bool |
true when matches remain after this page. Absent while the answer is not whole — see Rules every tool follows. |
truncated_by |
string? |
rows when the row limit cut the page, bytes when the byte budget did. Absent when neither bound cut the page. |
source_exhausted |
bool |
true once sipnab has read the capture source to its end. |
source_stopped_early |
bool |
true when a source's read ended before the source did. |
next_cursor |
string? | Pass back to continue. null on the final page. |
schema_version |
u32 |
1 for this shape. |
capture_identity |
object | Which capture answered — see capture_status. A changed instance voids every cursor you hold. |
Each DialogSummary row carries call_id, state, method, from_user,
to_user, msg_count, duration_sec, created_at, updated_at, a timing
object (pdd_ms, setup_ms, retransmits, duration_ms, each null when the
capture never showed it) and a frame pointer for
show_evidence. from_user and to_user arrive fenced,
because an endpoint chose them.
Two more keys appear when sipnab knows them, and are left out rather than sent as null when it does not, so an absent key means "not known here":
-
final_status_code— the final response the call reached.statecollapses every release cause into one word, so 403, 404, 408, 486, 503 and 603 all read asFailed, and this is the field that tells them apart. Absent while a call is still in progress, never0. -
input_origin— which capture source delivered the message that OPENED the dialog:wire,heporuprobe. Read it before you followframe: a uprobe pointer (uprobe:opensips/954#3) has the same shape as a capture offset, and only awirepointer leads to bytes in a capture file. -
tags— the labels--taggave this run's dialogs. Absent when the run set none.get_dialog'sdialogcarries it too.
The example below runs against tests/pcap-samples/sipp-branch-scenario.pcapng,
which holds 1334 dialogs. limit: 2 therefore reports 2 of 1334 — and says so:
// list_dialogs { "limit": 2 }
{
"schema_version": 1,
"dialogs": [
{
"call_id": "call-1-synth@192.0.2.10",
"state": "Registered",
"method": "REGISTER",
"from_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"to_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"msg_count": 7,
"final_status_code": 403,
"duration_sec": 0.036,
"created_at": "2016-11-17T21:52:35.303349+00:00",
"updated_at": "2016-11-17T21:52:35.339349+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": null,
"retransmits": 0,
"duration_ms": null
},
"frame": "tests/pcap-samples/sipp-branch-scenario.pcapng#0@0f039ad14545671e",
"input_origin": "wire"
},
{
"call_id": "call-2-synth@192.0.2.10",
"state": "Registered",
"method": "REGISTER",
"from_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"to_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"msg_count": 7,
"final_status_code": 403,
"duration_sec": 0.036,
"created_at": "2016-11-17T21:52:35.403349+00:00",
"updated_at": "2016-11-17T21:52:35.439349+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": null,
"retransmits": 0,
"duration_ms": null
},
"frame": "tests/pcap-samples/sipp-branch-scenario.pcapng#7@f7951dd197e419b9",
"input_origin": "wire"
}
],
"returned": 2,
"total_matched": 1334,
"by_method": [
{ "method": "REGISTER", "count": 1334 }
],
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2016-11-17T21:52:35.403349Z|call-2-synth@192.0.2.10",
"capture_identity": {
"node": "capture-01",
"instance": "1ae7318cb5c11b1a306dd-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}The page holds two dialogs, and by_method shows that all 1334 matches are
REGISTER. The rows alone cannot tell you that.
Two fields matter when you write a client. from_user reads
⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧, not ua-a, so a
comparison against the bare name never matches: strip the markers, or match
inside them. capture_identity.node names the host that answered, which tells
you whose capture a fact came from when an agent queries several servers.
Count from total_matched, never from the rows you hold: a page is at most
limit rows, and --mcp-max-rows caps limit. To read every
matching dialog:
- Call
list_dialogswith thefilterandlimityou want. - If
truncatedistrue, call again withcursorset to the response'snext_cursor, unchanged. - Stop when
next_cursorisnull.
next_cursor pairs a created_at timestamp with a Call-ID, because many
dialogs can share one timestamp. Pass the value back unmodified. A bare
RFC 3339 timestamp also parses,
so a cursor rebuilt by hand without the Call-ID returns no error and can skip
or repeat the dialogs that share that timestamp.
list_dialogs pages on created_at, which does not change, so a dialog that
receives another message during the sweep keeps its place.
tail_dialogs pages on updated_at
instead, because it reports changes.
Counts dialogs grouped by one field, inside the store. Use it instead of fetching pages and counting rows.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
group_by |
string | One of state, response_code, method, from.user, to.user, ua, src.ip, dst.ip, rtp.codec. Anything else fails with invalid_params naming the legal set. |
Required. |
filter? |
string? | Alias or DSL, applied before grouping. | No filter — the whole store. |
top_n? |
integer? | Bounded by --mcp-max-rows, the same setting that bounds dialog rows. other_count counts the groups past it. |
The same default as any row limit. |
Returns group_by (echoed), buckets (each { value, count }, largest
first), other_count, distinct_values, total_matched, capture_identity
and schema_version.
The example runs against tests/pcap-samples/sipp-branch-scenario.pcapng,
whose 1334 dialogs land in two states, with top_n: 1 asking for only the
larger one:
// aggregate_dialogs { "group_by": "state", "top_n": 1 }
{
"schema_version": 1,
"group_by": "state",
"buckets": [ { "value": "Registered", "count": 1207 } ],
"other_count": 127,
"distinct_values": 2,
"total_matched": 1334,
"capture_identity": {
"node": "capture-01",
"instance": "f078218d2bfca5995cf37-2",
"dialog_generation": 9015,
"stream_generation": 0
}
}The buckets plus other_count always equal total_matched. A dialog with
no value for the field lands in a bucket whose value is the literal
(none), so {"group_by": "ua"} also answers how many dialogs carry no
User-Agent.
One field per call, and no time buckets. To count within a time window,
narrow it with filter.
Grouping by from.user, to.user, ua or rtp.codec returns fenced
values, because the packet's sender wrote that text (the codec name comes
from the SDP). A state, a method, a status code or an IP address is sipnab's
own value and comes back unfenced.
You know something is wrong but not which call. These cut a capture down to the dialogs worth reading: by symptom, by text, by time, or by relationship to a call you already have.
Convenience wrapper over list_dialogs that ORs each named alias, then ANDs
the optional filter.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
kinds? |
string[]? | One or more of the ten diagnostic aliases listed under list_dialogs, OR-ed together. An unknown name fails with invalid_params. An empty array behaves as omitted. |
["problems"]. |
filter? |
string? | An alias name or a raw DSL expression, ANDed with the alias match. | The alias match alone decides the page. |
limit? |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 rows. |
cursor? |
string? | The previous response's next_cursor, verbatim. |
Starts at the oldest match. |
fields? |
string[]? | Field names to keep on each row, plus call_id. Same rules as list_dialogs — see Narrowing a row with fields. |
The whole row. |
max_response_bytes? |
u64? | Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole row with truncated_by: "bytes" — see Rules every tool follows. |
The ceiling. |
Returns the same page object as list_dialogs — dialogs,
returned, total_matched, by_method, truncated, next_cursor,
schema_version and capture_identity — with the same meanings and the same
fenced from_user and to_user.
Read by_method first. The traffic a deployment sends most often can fill
a problems page. OPTIONS keepalives to unreachable peers, for example, match an
alias on retransmissions alone. When by_method shows that, exclude them with
filter, for example "filter": "method != OPTIONS".
Use filter to restrict the aliases to your own traffic:
{"kinds": ["problems"], "filter": "dst.ip == '203.0.113.9'"} returns the
problem dialogs sent to that address. The alias match and the filter are
ANDed.
An unknown alias, or a filter that neither names an alias nor parses, returns invalid_params (-32602) naming the offending value.
// find_problems { "limit": 1, "filter": "msg_count > 5" }
{
"schema_version": 1,
"dialogs": [
{
"call_id": "call-1197-synth@192.0.2.10",
"state": "Failed",
"method": "REGISTER",
"from_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"to_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"msg_count": 6,
"final_status_code": 403,
"duration_sec": 0.03,
"created_at": "2016-11-17T21:54:34.903349+00:00",
"updated_at": "2016-11-17T21:54:34.933349+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": null,
"retransmits": 0,
"duration_ms": null
},
"frame": "tests/pcap-samples/sipp-branch-scenario.pcapng#8028@42ba0f02341ca2f7",
"input_origin": "wire"
}
],
"returned": 1,
"total_matched": 6,
"by_method": [
{ "method": "REGISTER", "count": 6 }
],
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2016-11-17T21:54:34.903349Z|call-1197-synth@192.0.2.10",
"capture_identity": {
"node": "capture-01",
"instance": "1ae7318cb5c11b1a306dd-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}The same capture answers find_problems {} with total_matched: 127. Six of
those 127 carry more than five messages, which is what the filter selects.
Case-insensitive substring search over method, status, From, To, User-Agent, and body across all dialogs.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
query |
string | Any substring. Matching ignores case. | Required — the call fails. |
limit? |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 hits. |
cursor? |
string? | The previous response's next_cursor, verbatim (<RFC 3339 created_at>|<Call-ID>#<zero-padded message index>). A malformed timestamp half fails with invalid_params. |
Starts at the oldest match. |
max_response_bytes? |
u64? | Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole row with truncated_by: "bytes" — see Rules every tool follows. |
The ceiling. |
Returns — the same page shape list_dialogs returns, not
a bare array, plus the provenance note as a second content block:
| Field | Type | Description |
|---|---|---|
hits |
object[] | This page of { call_id, message_index, snippet }, ordered by the dialog's created_at, then Call-ID, then message index. |
returned |
usize | Rows in hits. |
total_matched |
usize | Messages matching the query across the whole store, whatever limit and cursor say. This is the number that answers "how many". |
truncated |
bool |
true when matches remain after this page. Absent while the answer is not whole — see Rules every tool follows. |
truncated_by |
string? |
rows when the row limit cut the page, bytes when the byte budget did. Absent when neither bound cut the page. |
source_exhausted |
bool |
true once sipnab has read the capture source to its end. |
source_stopped_early |
bool |
true when a source's read ended before the source did. |
next_cursor |
string? | Pass back to continue. null on the final page. |
schema_version |
u32 |
1 for this shape. |
capture_identity |
object | Which capture answered. A changed instance voids the cursor. |
snippet holds the whole raw message, fenced, and stops at 4 KB. Pass call_id
and message_index straight to get_message for the parsed
form.
Count from the fields, never from the rows. A capped result and a complete one hold the same shape, so the row count cannot tell them apart: on the sample capture below,
{ "query": "REGISTER" }returns 50 rows andlimit: 1000returns 1000, while 2668 messages match.total_matchedis the number that answers "how many are there",truncatedsays whether you are seeing all of them, andnext_cursorreaches the rest.
The example runs against tests/pcap-samples/sipp-branch-scenario.pcapng:
// search_messages { "query": "REGISTER", "limit": 1 }
{
"schema_version": 1,
"hits": [
{
"call_id": "call-1-synth@192.0.2.10",
"message_index": 0,
"snippet": "⟦untrusted-capture-data⟧REGISTER sip:example.net SIP/2.0\nVia: SIP/2.0/UDP 192.0.2.10:5061…⟦/untrusted-capture-data⟧"
}
],
"returned": 1,
"total_matched": 2668,
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2016-11-17T21:52:35.303349Z|call-1-synth@192.0.2.10#0000000000",
"capture_identity": {
"node": "capture-01",
"instance": "12100b18cb6971cf461cff-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}Passing that next_cursor back returns message 1 of call-1-synth@192.0.2.10
and reports the same total_matched: 2668: the total describes the store, not
the page.
The message index in the cursor carries leading zeros, because the server
compares cursors as text. A cursor rebuilt by hand without them can skip
messages. Pass it back exactly as it arrived.
You have a call-id. These explain what happened to that one call -- its signaling, its media, and the specific negotiations that tend to break.
Start here. It answers which half of the call failed. Signaling decides whether a call connects, and RTP decides whether the parties hear each other. The two have different causes and different fixes.
Parameters: call_id (string, required) — a Call-ID the store holds, as
returned by list_dialogs. An unknown one fails with
invalid_params (-32602) naming the value. There are no optional parameters.
// triage_call { "call_id": "1-1966@10.0.2.20" }
{
"schema_version": 1,
"call_id": "1-1966@10.0.2.20",
"verdict": "media", // "signaling" | "media" | "both" | "none"
"state": "Completed",
"final_status_code": 200,
"signaling": { "problem": false, "hints": [] },
"media": {
"problem": true,
"one_way_audio": true,
"nat_mismatch": false,
"no_media": false,
"stream_count": 1,
"hints": ["RTP flowed 10.0.2.15:27942 -> 10.0.2.20:6000 only (SSRC 0x343da99b). No reverse media flow detected."]
},
"termination": { // absent when nothing said why the call ended
"cause_code": 16,
"cause_text": "NORMAL_CLEARING",
"frame_ref": 4,
"protocol": "Q.850",
"source_header": "Reason"
},
"capture_identity": { "node": "capture-01", "instance": "cc2cb18df0ea68adfb9c9-2",
"dialog_generation": 13, "stream_generation": 2 },
"source_exhausted": true,
"source_stopped_early": false
}A clean 200 OK with one-way audio is a media problem. Nothing in the SIP
exchange is wrong, and time spent reading it is time lost.
Where to go next, by verdict:
| Verdict | Next tool |
|---|---|
signaling |
explain_response_code on the final code, then get_dialog
|
media |
rtp_stats, and check_codec_negotiation if the call failed |
both |
Signaling first — media symptoms are often downstream of a failed negotiation |
none |
The call is fine. Check you have the right Call-ID |
termination is a fact about the call, not a fault, so it never moves
verdict. A call that cleared normally reports verdict: "none" and a
Q.850 cause of 16 at the same time, and both are true.
It says why the call ended, which the verdict does not.
| Field | Type | Description |
|---|---|---|
cause_code |
u16? | The cause number, on the scale protocol names. Absent when the message named a protocol and no cause — never zero for a missing value, because 0 is itself a Q.850 cause |
cause_text |
string? | The human-readable cause, with control characters removed and cut to the first 200 characters |
protocol |
string? | Which scale cause_code is on. 16 is normal clearing in Q.850 and is not a SIP status code at all, so a code without this is not interpretable |
source_header |
string | Which header carried the cause: Reason (RFC 3326) or X-Asterisk-HangupCauseCode
|
frame_ref |
usize | Index into the dialog's message list of the message that carried it |
sipnab reads the last message in the dialog that names a cause, including
a Reason header on a BYE or a CANCEL. When one message carries both a
SIP and a Q.850 cause, sipnab reports the Q.850 one, because the SIP
cause repeats final_status_code.
The same block appears on get_dialog_report and on
GET /v1/dialogs/{call_id}/report.
Paginated dialog with full SIP messages.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. An unknown one fails with call_id 'x' not found. |
Required — the call fails. |
max_messages? |
u32? | At least 1; 0 fails with invalid_params. Ceiling is --mcp-max-rows (1000 by default), and higher clamps to it. |
100 messages. |
cursor? |
string? | The previous response's next_cursor, verbatim (<RFC 3339 created_at>|<Call-ID>#<zero-padded message index>, the shape search_messages uses). A malformed cursor, or one issued for a different dialog, fails with invalid_params. A cursor past the last message returns an empty page. |
Starts at message 0. |
max_response_bytes? |
u64? | Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole message with truncated_by: "bytes" — see Rules every tool follows. |
The ceiling. |
Returns:
| Field | Type | Description |
|---|---|---|
dialog |
object | The same summary list_dialogs returns, with from_user and to_user fenced. |
messages |
object[] | This page of full messages, in dialog order. |
returned |
usize | Messages in messages. |
first_index |
usize | The index of messages[0] in the dialog, so row N of the page is get_message index: first_index + N. |
total_messages |
usize | Messages in the whole dialog. |
truncated |
bool |
true when messages remain after this page. Absent while the answer is not whole — see Rules every tool follows. |
truncated_by |
string? |
rows when max_messages cut the page, bytes when the byte budget did. Absent when neither bound cut the page. |
next_cursor |
string? | Pass back to continue. null on the final page. |
complete |
bool |
true when this page reaches the end of the dialog. Never true over a partial read of the capture. |
schema_version |
u32 |
2 for this shape. |
capture_identity |
object | Which capture answered — see capture_status. |
Every messages row carries call_id, is_request, cseq (method and
number), from, to, src, src_port, dst, dst_port, transport,
timestamp, frame and schema_version. Two more appear when sipnab observed
them and are left out rather than sent as null when it did not:
-
dscp— the RFC 2474 marking on the frame that carried this message, 0 to 63. Absent means no IP header was read, which is what a HEP-delivered message looks like.0means sipnab saw the frame and the frame said best effort. Unmarked signaling is itself a common fault, so the two must not collapse into one value. -
input_origin—wire,heporuprobe, the source that delivered this message.
The rest depends on the direction, so branch on is_request rather than
expecting one shape:
-
A request adds
method, andcontactandsdpwhen it carried them. -
A response adds
status_code,reason,response_contextandua, and carries nomethod— readcseq.methodfor the transaction it answers.
This tool returns more capture text than any other, and fences it.
reason,from,to,contact,ua,sdpandmalformedinmessages[]come back wrapped in⟦untrusted-capture-data⟧markers, asget_messagereturns them. The SDP body keeps its line structure and--mcp-max-body-bytesbounds it. Header values arrive capped, with every control character removed.Treat every string in
messages[]as written by an attacker: the markers say where the text came from, not that it is safe.
The example runs against tests/pcap-samples/sip-rtp-g711.pcap:
// get_dialog { "call_id": "1-1966@10.0.2.20", "max_messages": 1 }
{
"schema_version": 2,
"complete": false,
"returned": 1,
"first_index": 0,
"total_messages": 6,
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2016-11-26T14:52:59.666393Z|1-1966@10.0.2.20#0000000000",
"capture_identity": { "node": "capture-01", "instance": "cc2cb18df0ea68adfb9c9-2",
"dialog_generation": 13, "stream_generation": 2 },
"dialog": {
"call_id": "1-1966@10.0.2.20",
"state": "Completed",
"method": "INVITE",
"from_user": "⟦untrusted-capture-data⟧sipp⟦/untrusted-capture-data⟧",
"to_user": "⟦untrusted-capture-data⟧test⟦/untrusted-capture-data⟧",
"msg_count": 6,
"final_status_code": 200,
"duration_sec": 8.504,
"created_at": "2016-11-26T14:52:59.666393+00:00",
"updated_at": "2016-11-26T14:53:08.170676+00:00",
"timing": { "pdd_ms": null, "setup_ms": 4, "retransmits": 0, "duration_ms": 8499 },
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"input_origin": "wire"
},
"messages": [
{
"call_id": "1-1966@10.0.2.20",
"method": "INVITE",
"is_request": true,
"cseq": { "method": "INVITE", "number": 1 },
"from": "⟦untrusted-capture-data⟧\"PCMU/8000\" <sip:sipp@10.0.2.20:5060>;tag=1⟦/untrusted-capture-data⟧",
"to": "⟦untrusted-capture-data⟧test <sip:test@10.0.2.15:5060>⟦/untrusted-capture-data⟧",
"contact": "⟦untrusted-capture-data⟧sip:sipp@10.0.2.20:5060⟦/untrusted-capture-data⟧",
"src": "10.0.2.20",
"src_port": 5060,
"dst": "10.0.2.15",
"dst_port": 5060,
"transport": "UDP",
"timestamp": "2016-11-26T14:52:59.666393+00:00",
"sdp": "⟦untrusted-capture-data⟧v=0\no=- 42 42 IN IP4 10.0.2.20\ns=-\nc=IN IP4 10.0.2.20\nt=0 0\nm=audio 6000 RTP/AVP 0\na=rtpmap:0 PCMU/8000\na=recvonly\n⟦/untrusted-capture-data⟧",
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"dscp": 0,
"input_origin": "wire",
"schema_version": 1
}
]
}The SDP above arrives with \n line endings, not the \r\n the wire carried:
fencing removes every control character except \n and \t. Compare it as
text, not byte for byte against the packet. Use
show_evidence when you need the
original bytes.
total_messages is 6 and returned is 1, so five messages remain. Call again
with cursor set to that next_cursor to continue, and stop when complete
turns true.
cursor is a string, passed back unchanged. An integer cursor fails with
invalid_params (-32602) and data.reason "invalid_arguments":
failed to deserialize parameters: invalid type: integer `1`, expected a string. To address one message by position, use first_index with
get_message.
Per-stream RTP quality for one call, or across the whole capture.
This tool has two modes, and call_id is the switch. Pass it for one
dialog's streams. Omit it to sweep the whole capture. Six parameters belong to
the sweep alone:
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id? |
string? | A Call-ID a dialog carries, or one a media relay named for its streams. | Switches modes — the tool sweeps every stream in the capture, orphans included. |
min_mos? |
f64? | Sweep only. Keeps streams scoring at or above this. Rejected alongside call_id. |
No lower bound. |
max_mos? |
f64? | Sweep only. Keeps streams scoring strictly below this. Rejected alongside call_id. |
No upper bound. |
orphaned? |
bool? | Sweep only. true keeps only streams no dialog claims, false keeps only streams a dialog claimed. Independent of the MOS bounds. |
Both kinds sweep. |
limit? |
u32? | Sweep only. Ceiling is --mcp-max-rows (1000 by default), higher clamps to it, 0 means the default. |
50 streams. |
cursor? |
string? | Sweep only. The previous response's next_cursor, verbatim (<RFC 3339>|0xSSRC@src>dst). |
Starts at the earliest stream. |
max_response_bytes? |
u64? | Sweep only. Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole row with truncated_by: "bytes". |
The ceiling. |
Only the two MOS bounds fail alongside a call_id. They ask a question
about the whole capture, so sending one with a Call-ID is a misunderstanding of
the tool, and sipnab answers invalid_params (-32602) rather than quietly
doing nothing. Per-call mode simply skips orphaned, limit and cursor: a
call holds every stream it holds, so there is nothing there to page or to
filter.
With call_id the answer keeps its existing shape — { call_id, streams, diagnosis },
where streams is an array of stream JSON objects (codec, MOS, jitter, loss%,
packets, SSRC, quality intervals) and diagnosis carries the standard one-way /
NAT-mismatch flags plus the asymmetry signals (codec_asymmetry,
ptime_asymmetry, payload_type_asymmetry, duration_asymmetry,
late_media). A MOS bound alongside a call_id returns invalid_params
(-32602) rather than quietly doing nothing. dialog_seen says whether this
capture holds the call's SIP dialog.
On a media-relay host the dialog is not required. A relay sees the RTP and
its own control traffic, never the SIP, so its dialog store is empty while its
streams carry the Call-ID the relay named. rtp_stats answers from those
streams: dialog_seen is false, and dialog_absent says why no dialog
explains the media, with the same reason and note that
reconcile_orphans gives it. The diagnosis still runs,
without the SDP checks (nat_mismatch, no_media, the RFC 4961 port hints), which
need the dialog. media_diagnostics,
explain_attribution and
export_audio follow the same rule.
// rtp_stats { "call_id": "B2B.201.6748799.1790773715.1438467624" }, on the relay host
{
"call_id": "B2B.201.6748799.1790773715.1438467624",
"dialog_seen": false,
"dialog_absent": {
"reason": "relay-asserted-but-no-dialog",
"note": "a relay named this endpoint but no captured dialog claims it -- the signaling is missing, not the media"
},
"streams": [
{ "associated_dialog": "B2B.201.6748799.1790773715.1438467624", "dialog_assertion": "media-relay", "src": "192.0.2.70:41004", "dst": "192.0.2.71:31004", "codec": "PCMU", "mos": 4.36 /* ... */ },
{ "associated_dialog": "B2B.201.6748799.1790773715.1438467624", "dialog_assertion": "media-relay", "src": "192.0.2.71:31004", "dst": "192.0.2.70:41004", "codec": "PCMU", "mos": 4.36 /* ... */ }
],
"diagnosis": { "one_way_audio": false, "nat_mismatch": false, "no_media": false, "sdp_media": null /* ... */ }
}sipnab refuses a Call-ID that neither a dialog nor any stream carries, with
invalid_params (-32602) and a message naming both searches: no SIP dialog and no RTP stream associated with call_id '…' in this capture. On a relay host that
means the call never crossed this relay, not that the lookup failed.
// rtp_stats { "call_id": "1-1966@10.0.2.20" }
{
"call_id": "1-1966@10.0.2.20",
"streams": [
{
"associated_dialog": "1-1966@10.0.2.20",
"dialog_assertion": "signaled",
"codec": "PCMU",
"src": "10.0.2.15:27942",
"dst": "10.0.2.20:6000",
"ssrc": "0x343da99b",
"payload_type": 0,
"packets": 425,
"octets": 68000,
"loss_pct": 0.0,
"jitter_ms": 0.0054046519599899685,
"mos": 4.358100599599484,
"mos_grounded": true,
"mos_grounding": "published",
"r_factor": 90.79987028835296,
"orphaned": false,
"dscp": 0,
"input_origin": "wire",
"first_seen": "2016-11-26T14:52:59.689083+00:00",
"last_seen": "2016-11-26T14:53:08.169060+00:00",
"round_trip_note": "Not measured. No endpoint reported a round trip for this stream, so latency is unknown rather than good — a stream with clean jitter and no loss can still be unusable on delay alone (ITU-T G.114).",
"quality_intervals": [
{
"jitter_ms": 0.0063823922199494915,
"loss_pct": 0.0,
"mos": 4.358100053908192,
"mos_grounded": true,
"packets": 252,
"r_factor": 90.79984682258672,
"timestamp": "2016-11-26T14:53:04.709076+00:00",
"verdict": "acceptable"
}
],
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#5@ae02f78d2d48b4f0",
"schema_version": 1
}
],
"diagnosis": {
"actual_media": null,
"hints": [
"RTP flowed 10.0.2.15:27942 -> 10.0.2.20:6000 only (SSRC 0x343da99b). No reverse media flow detected."
],
"nat_mismatch": false,
"no_media": false,
"one_way_audio": true,
"private_media_address": false,
"sdp_media": "10.0.2.20"
},
"dialog_seen": true
}diagnosis always carries one_way_audio, nat_mismatch, no_media,
private_media_address, sdp_media, actual_media and hints. The asymmetry
signals named above appear only when sipnab found one, so an absent
codec_asymmetry means the legs agreed rather than that the check did not run.
Per-call mode returns no total_matched, truncated or next_cursor, because
it returns every stream of the call. quality_intervals holds one entry per
completed sampling window, so a call shorter than one window returns an empty
array, and this eight-second call returns one row. The window is five seconds
by default. --quality-interval narrows it, down to one second, without
shortening the hour of call time the trend covers.
Each interval carries its own mos, r_factor and verdict.
Read them to find short bursts of loss or jitter that the stream-level MOS, a
mean over the whole call, hides. sipnab scores an interval on its own jitter
and loss and on the stream's path delay, so the interval figures and the
stream figure use the same delay.
verdict has three states:
verdict |
What it means |
|---|---|
acceptable |
R at or above 70, where ITU-T G.107's worst user-satisfaction category is still "some users dissatisfied". |
degraded |
R below 70, where that category becomes "many users dissatisfied". |
not_scorable |
The codec has no published or declared impairment value, so sipnab refuses to judge the interval. The mos beside it is a placeholder, and mos_grounded is false. |
not_scorable means neither good nor bad. When you count degraded intervals,
count degraded only, and report not_scorable intervals separately as not
judged.
Each stream carries mos_grounded and mos_grounding. sipnab has
published ITU-T G.113 impairment values only for G.711, G.729 and Opus. For
AMR, AMR-WB, EVS, G.722 and a stream whose codec was never identified, the MOS
is the same placeholder figure for every codec. When mos_grounded is false
the MOS means unknown, and a mos_note says so.
mos_grounding names what the number rests on:
mos_grounding |
mos_grounded |
What the number rests on |
|---|---|---|
published |
true |
An ITU-T G.113 impairment value sipnab implements. |
operator_declared |
true |
An Ie this deployment supplied in [media.codec_ie]. A mos_note says so, so an agent citing the figure cites the operator rather than a standard. |
unpublished |
false |
Nothing published and nothing declared. Placeholder. |
For AMR-WB specifically the placeholder is wrong by roughly a full MOS point in either direction: its nine modes genuinely span about 4.49 down to 3.51. Do not report a MOS to a human without checking this field. See MOS and codecs for the full picture.
An AMR-WB stream whose mode sipnab read also carries the wideband score, the
same three fields GET /v1/streams returns:
| Field | Meaning |
|---|---|
mos_wideband |
MOS_CQEW on the ITU-T G.107.1 wideband scale |
mos_wideband_context |
The listening context sipnab scored the stream in: monotic or diotic
|
mos_wideband_unavailable |
Why there is no wideband score: unpublished_mode or loss_not_computable
|
mos_wideband and mos are different scales. Do not compare them. A stream
that is not AMR-WB carries none of the three. See
Where the wideband score appears.
Each stream also says who asserted the SDP media endpoint that tied it to its Call-ID:
dialog_assertion |
Who said it | What the address is |
|---|---|---|
signaled |
a negotiating party, in its own SDP | that party's endpoint |
media-relay |
an rtpengine relay, about a port it allocated | the leg's midpoint |
A relay cannot be wrong about which socket it opened, so the address is
authoritative — and it is still not an endpoint. An agent reasoning about where
media went must not report a media-relay address as the far party's, because
it names the box in the middle and the two lead to opposite conclusions.
sipnab emits the key whenever it knows the answer, signaled included. Absent
means nobody recorded who asserted it, never "a party did". Do not default it.
Distinct from dialog_origin, which names the capture source that delivered
the assertion rather than its author. See
rtpengine relay attribution.
Omit call_id to sweep every stream. { "max_mos": 3.5 } returns every stream
scoring below 3.5 in one call, without a call per dialog. The sweep also
returns streams no dialog claims, which per-call mode cannot reach because they
have no Call-ID.
| Field | Type | Description |
|---|---|---|
streams |
object[] | This page, oldest first_seen first. |
returned |
usize | Rows in streams. |
total_matched |
usize | Streams matching across the whole store. |
ungrounded_excluded |
usize | Streams a MOS bound could not judge. |
truncated |
bool |
true when matches remain after this page. |
truncated_by |
string? |
rows when the row limit cut the page, bytes when the byte budget did. Absent when neither bound cut the page. |
next_cursor |
string? | Pass back to continue. null on the final page. |
capture_identity |
object | Which capture answered — see capture_status. A changed instance voids every cursor you hold. |
schema_version |
u32 |
1 for this shape. |
A MOS bound only judges codecs sipnab has a real impairment value for —
one G.113 publishes, or one this deployment declared in [media.codec_ie].
min_mos and max_mos skip every ungrounded stream and count it in
ungrounded_excluded. Report that count beside the result: on a network
carrying AMR-WB, EVS or G.722, "2 streams below 3.5" omits every stream sipnab
could not score.
Omit both bounds and the sweep lists every stream, including the codecs with no
published value, each still carrying mos_grounded.
The example runs against tests/pcap-samples/codec-negotiation.pcap, which
carries four streams — two PCMU, two G722 — and no dialogs at all:
// rtp_stats { "max_mos": 4.5, "limit": 1 }
{
"schema_version": 1,
"streams": [
{
"codec": "PCMU",
"dscp": 0,
"dst": "127.0.0.1:5084",
"first_seen": "2026-07-08T18:35:27.407583+00:00",
"frame": "tests/pcap-samples/codec-negotiation.pcap#5@5d4c3e3d970a836b",
"input_origin": "wire",
"jitter_ms": 0.26954164761616456,
"last_seen": "2026-07-08T18:35:30.407077+00:00",
"loss_pct": 0.0,
"mos": 4.357953149337916,
"mos_grounded": true,
"mos_grounding": "published",
"octets": 24160,
"orphaned": true,
"packets": 151,
"payload_type": 0,
"quality_intervals": [],
"round_trip_note": "Not measured. No endpoint reported a round trip for this stream, so latency is unknown rather than good — a stream with clean jitter and no loss can still be unusable on delay alone (ITU-T G.114).",
"schema_version": 1,
"src": "127.0.0.1:5094",
"ssrc": "0x0e330af3"
}
],
"returned": 1,
"total_matched": 2,
"ungrounded_excluded": 2,
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2026-07-08T18:35:27.407583Z|0x0e330af3@127.0.0.1:5094>127.0.0.1:5084",
"capture_identity": {
"node": "capture-01",
"instance": "22fa418cb5c799c57abef-1",
"dialog_generation": 1,
"stream_generation": 4
}
}The sweep adds frame, capture_identity and the page fields the per-call mode
omits.
orphaned means "no dialog claims this stream", and nothing else. It is
true exactly when associated_dialog is absent, from the stream's first
packet. Every stream above reports orphaned: true, because the capture holds
four streams and no dialogs. A short unclaimed stream is what a NAT or
one-way-audio fault often looks like from the media side, so filter with
orphaned: true to find one.
total_matched: 2 and ungrounded_excluded: 2 account for all four streams.
The two G722 streams carry the placeholder MOS, so the max_mos bound skips
them and counts them in ungrounded_excluded.
Website · Repository · Issues · Generated from docs/ — edit there, not here.
Getting started
- Installation
- First CLI Triage
- Glossary
- Cookbook
- Runnable Examples
- Real World Captures
- Troubleshooting
- Tuning Capture
- Encapsulations
- Rtpengine Relays
- Backers
Using the TUI
CLI & automation
- CLI Reference
- Error Messages
- Filter DSL
- Output Formats
- SIP Response Codes
- SIP Methods
- SIP Header Fields
- SIP Parameters
- SIP Lint Rules
- MOS and Codecs
Configuration
Integrations (API & MCP)
- REST API
- Prometheus Metrics
- Authentication
- MCP
- MCP Deployment
- MCP Across an Estate
- MCP Tools
- MCP Tools Core
- MCP Tools Signaling
- MCP Tools Captures
- MCP Tools Security
- MCP Tools Media
- MCP Tools Relay
- MCP Tools TFPS
- MCP Tools Server
- MCP Tools vCon
- MCP Tools TLS
- MCP Protocol
- Uprobe Walkthrough
- Capture SIP over TLS
- WASM Plugins
- vCon Export
- vCon Capture Stack
- G729 Decoding
Your SIP server
- Use OpenSIPS as the SIP Server
- Run sipnab beside OpenSIPS
- Use Kamailio as the SIP Server
- Run sipnab beside Kamailio
- Use Asterisk as the PBX
- Run sipnab beside Asterisk
- Use FreeSWITCH in the Voice Stack
- Run sipnab beside FreeSWITCH
Add to your voice stack
- Add a vCon Server
- Send vCons to a vCon Server
- Send vCons to vcon store
- Add TFPS
- Control TFPS from sipnab
- Ban SIP Scanners with fail2ban
- Feed fail2ban from sipnab
- Add rtpengine
- Name rtpengine Media with sipnab
- Add rtpproxy
- Name rtpproxy Media with sipnab
- Add Homer
- Connect sipnab to Homer
- Add Prometheus and Grafana
- Add sipnab Metrics to Prometheus
Development & internals
- Internals Index
- Internals Subsystem Guide
- Internals Invariants
- Internals Testing
- Internals Walkthroughs
- Internals Build CI Release
- Internals Profiling
- Internals Domain Primer
- Library API
- Benchmarks
- Fault Model
- Threat Model
- Assurance Case
- Architecture
- Internals Threading
- Internals TUI Testing
- Internals Zero Copy Payloads
- Internals Uprobe Capture
- Internals Rtpengine Control Plane
- Internals Relay Control Decoding
- Internals vCon Exporter
- Internals Operator Guide Notes
- Internals Postmortems
- Internals Code Map
- Internals Lint Rules
- Internals Encapsulation Decoding
- Internals MCP Design Notes
- Internals Metrics And Output Notes