Docs Look up a tool in the MCP core bundle

Look up a tool in the MCP core bundle

On this page

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.

ToolParametersReturns
capture_status–What this server captures: live or file, uptime, and whether stopping loses unsaved packets
get_capture_reportformat?Whole-capture analysis: findings, orphaned media, STUN/ICMP evidence, what the caps shed
list_dialogsfilter?, limit?, cursor?A page of dialog summaries, with the total behind it
aggregate_dialogsgroup_by, filter?, top_n?Counts dialogs grouped by ONE field, in the store rather than in the model
find_problemskinds?, filter?, limit?, cursor?A page of dialogs matching one or more diagnostic alias names
search_messagesquery, limit?, cursor?A page of substring matches across method/From/To/UA/body, with the total behind it
triage_callcall_idFirst-pass verdict: signaling problem, media problem, both, or none, with evidence
get_dialogcall_id, max_messages?, cursor?Paginated dialog with full SIP messages
rtp_statscall_id?, min_mos?, max_mos?, limit?, cursor?One call’s RTP quality and diagnosis, or a capture-wide stream sweep

Survey — what is in this capture

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”.

capture_status

Ask this first. It answers what the server is actually attached to — a live interface or a replayed file — which nothing else on this surface reveals.

No parameters. Returns:

{
  "schema_version": 2,           // 2 absorbed the counters the old stats tool returned
  "source": "live",              // "live" | "file" | "unknown"
  "name": "eth0",                // interface, or file path
  "uptime_sec": 3612,
  "dialog_count": 128,
  "stream_count": 64,
  "orphaned_stream_count": 3,    // streams no dialog claims
  "active_dialog_count": 12,     // any non-terminal state
  "active_call_count": 9,        // InCall only — narrower, hence the version bump
  "capture_quality": {
    "kernel_dropped_packets": 0,
    "interface_dropped_packets": 0,
    "invalid_timestamps": 0,
    "undecodable_frames": 0,
    "degraded": false
  },
  "caveats": {
    "media_creating_commands": 0  // relay commands seen and NOT attributed
  },
  "source_exhausted": false,     // true once a file is read to the end
  "source_stopped_early": false, // true when a read ended before the file did
  "writing_to": null,            // path packets are being saved to, if any
  "unsaved": true,               // stopping now would lose packets
  "capture_identity": {
    "node": "capture01",
    "instance": "1f4a17c8e2b91d40-1",
    "dialog_generation": 412,
    "stream_generation": 96
  },
  "unanalysed_sip_messages": 0,  // SIP that --portrange excluded
  "unanalysed_busiest_ports": [],
  "unanalysed_websocket_messages": 0,  // SIP-over-WebSocket the WS port set excluded
  "unanalysed_websocket_ports": [],    // pass these to --ws-portrange
  "load": null                   // an open_capture load in flight, if any
}

unsaved is the field that matters. It is true only for a live capture with no output file — packets held in memory and nowhere else. A file replay is already on disk, so it is never unsaved.

source: "unknown" means nobody gave the server capture context. It reports that rather than guessing, because a wrong "live" would be worse than an admission of ignorance.

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-portrange covering the ports listed. Widening --portrange recovers 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, where BPF filtered before the pipeline saw anything and there is nothing to under-report.

ami_cleartext — a manager password on the wire

Asterisk’s Manager Interface speaks a line protocol on TCP/5038, and a login is four lines of plain text: Action: login, Username:, Secret:. AMI can originate calls, read configuration and run shell commands, so the credential is worth strictly more than the SIP digest --digest-leak already reports.

Reported here because this is where the misleading zero was. unanalysed_sip_messages: 0 was the complete answer for a capture full of AMI, and the zero is honest — AMI is not SIP, so there was no unparsed SIP message to count. Every detector sipnab had ran on parsed SIP messages, and this traffic never became one.

FieldTypeDescription
observationobjectbanner (the server greeted a client) or cleartext-login
observation.usernamestring?The account named, bounded to MAX_AMI_VALUE_CHARS (64). Echoed on purpose: an operator can act on a finding that names the exposed account, and cannot act on one saying “an account”
observation.secret_presentboolWhether a Secret: line was there. A boolean, never the value
src / dst / dst_portstring / string / u16Who, to whom, on what port
default_portboolWhether dst_port is 5038
countu64Payloads carrying this observation between this pair

The secret is never recorded. sipnab writes findings to logs, exports them in containers and hands them to an agent’s context, so the Secret: line becomes the fact that a credential crossed the wire and nothing more.

Not a decoder. It matches a banner and a login line and records where they were. No session tracking, no response following, nothing else the protocol carries.

The port is an observation, not a filter. An interface moved off 5038 is still reported, with default_port: false — a deployment that moved the service is the one most likely to think nobody can find it, and a rule keyed on the default would agree.

No arming needed, unlike the detectors behind security_findings. It costs two literal prefix tests on payloads that have already failed the SIP check, so there is no cost to gate against. A login with an AuthType: MD5 challenge and no Secret: line reports secret_present: false, so it never counts as a cleartext password.

Sightings aggregate per address pair, so one session reconnecting in a loop is one row with a count. The account is deliberately outside that key: this path runs before authentication, so the field is attacker-chosen.

Always present, and empty when there is nothing to report. sipnab omits every other optional block on this surface because it may never have run the check at all. It runs this one on every capture, so [] means “looked, found none” — a stronger answer than a missing key.

What the capture DECLINED — caveats

capture_quality above counts what the capture lost. caveats counts what sipnab chose not to do, which is a different thing and is not a defect.

KeyWhat it counts
media_creating_commandsrtpengine subscribe, publish and start recording commands seen and deliberately not attributed
tlsTLS decryption state — absent unless a decryptor ran

Those commands create media belonging to a call without being one of its two legs. Decoding one as an ordinary leg would make a two-party call report three streams, after which the analysis that judges one-way audio and asymmetry answers a question nobody asked — so sipnab counts them instead.

Read this before reasoning about a stream count. A call with a recording fork has media on the wire that no rtp_stats row explains, and this number is how you know that is a decision rather than a gap. Zero is a real answer and the key is always present.

caveats.tls — is decryption working?

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, never on the arithmetic. A TLS handshake carries records sipnab never loads keys for, so app_data_records > decrypted_records is not by itself a failure — an agent deriving a verdict from the two counts reports a working capture as broken and sends an operator after keys that already work. read_nothing is true only when application data arrived and none of it opened.

That case is why the block exists. A capture holding ciphertext nobody can open produces a dialog listing identical to one from a quiet network. If you are about to answer “there was no SIP on this capture”, check this field first: server_capabilities.can_decrypt says whether the BUILD can decrypt, and this says whether this RUN did.

GET /v1/stats carries the same block under the same name.

get_capture_report

The whole-capture analysis, the one --analyze prints. Backed by analysis::analyze and rendered by output::analysis_report.

get_dialog_report and render_ladder both answer for ONE Call-ID, so everything the report says about the capture as a whole had no MCP path: orphaned media, STUN, ICMP errors quoting SIP or RTP, and what the retention caps shed. capture_status could hand an agent a count, and no tool could expand it.

NameTypeLegal valuesIf omitted
formatstring?"json", "yang-json", "markdown" or "text". Anything else fails with unknown format 'x', expected json|yang-json|markdown|text."json".

Frames read comes from the same process-global counter the Prometheus scrape reports (sipnab_capture_packets_total), so the denominator here is the one every other number in the run measures against.

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 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.

A capture with no findings still answers with the clean line rather than an empty body, because silence is indistinguishable from the tool not having run:

// get_capture_report { "format": "text" }
// A capture with findings names each one; a clean capture says so outright.
"Capture analysis: 2 finding(s) across 1 dialog(s), 2 stream(s), 535000 frame(s) read."

json is the default and returns an OBJECT, serialized from the analysis itself — schema_version, findings, dialogs_examined, streams_examined, frames_read and complete. 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:

FieldTypeDescription
kindstringWhich problem this is, such as one_way_audio.
severitystringHow bad it is, carried on the row so a consumer sorts without a second table.
occurrencesu64How many times sipnab saw it, in units. Exact, and not the length of evidence, which a cap bounds.
unitstringWhat one occurrence is — call, stream, frame.
evidenceobject[]Up to a fixed cap of verifiable instances.
evidence_omittedu64Evidence rows the cap kept out. sipnab counts these while accumulating rather than subtracting, because one row often stands for many occurrences and the subtraction reported omissions that never existed.

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 load runs, and for a source whose read stopped before its end. It answers “did sipnab read all of its input”, so a file still arriving cannot satisfy it.

Until this gate landed it ignored the load entirely and read backwards during one: on a 100 MB capture the same tool in the same session answered complete: true at frames_read: 312 and complete: false at frames_read: 365747 — true over 0.09% of the file, false once the whole file had arrived.

The field keeps its name and its meaning. What changed is that the two facts under it, source_exhausted and source_stopped_early, now gate it and travel beside it.

markdown and text answer with a rendered document, which has no envelope to carry either flag, so the document states the fact itself.

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, and for the same reason: the reader of a rendered report has no $? and no JSON in front of them, and a partial report looks exactly like a whole one.

A capture read in full adds nothing and the report ends where it always did. Ask for json when you want the two booleans as fields.

Before 0.5.125 this default returned the TEXT rendering. format chooses between markdown headings and plain text inside the renderer and never had a JSON arm, so the tool asked for JSON, got prose, and fell back to a text block. An agent that called it with no argument received prose and nothing to say the structure it asked for was never there.

GET /v1/report answers the same question over REST.

list_dialogs

Returns one page of dialog summaries from the live capture store.

NameTypeLegal valuesIf omitted
filterstring?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.
limitu32?Ceiling is --mcp-max-rows (1000 unless the operator changed it). Higher clamps to it, 0 means the default.50 rows.
cursorstring?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.
fieldsstring[]?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_bytesu64?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:

FieldTypeDescription
dialogsDialogSummary[]This page, oldest first (ties broken by Call-ID).
returnedusizeRows in dialogs, so counting the array is never necessary.
total_matchedusizeDialogs 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.
truncatedbooltrue when matches remain after this page. Absent while the answer is not whole — see Rules every tool follows.
truncated_bystring?rows when the row limit cut the page, bytes when the byte budget did. Absent when neither bound cut the page.
source_exhaustedbooltrue once sipnab has read the capture source to its end.
source_stopped_earlybooltrue when a source’s read ended before the source did.
next_cursorstring?Pass back to continue. null on the final page.
schema_versionu321 for this shape.
capture_identityobjectWhich 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. state collapses every release cause into one word, so 403, 404, 408, 486, 503 and 603 all read as Failed, and this is the field that tells them apart. Absent while a call is still in progress, never 0.
  • input_origin — which capture source delivered the message that OPENED the dialog: wire, hep or uprobe. It is what keeps frame honest, because a uprobe pointer (uprobe:opensips/954#3) has the same shape as a capture offset and only one of the two leads to bytes on disk.

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
  }
}

by_method is why this fixture is a fair example rather than a flattering one: the page shows two dialogs and the breakdown shows that all 1334 are REGISTER. Nothing in the rows could have told a caller that, and a client that reasoned about “the calls in this capture” from the page would have been reasoning about a registration flood.

Two fields in there catch people writing a client from this page. 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. And capture_identity.node names the box that answered, which decides whose capture a fact came from once an agent holds several servers.

Why the page object, and why the cursor is compound

A bare array hides its own size. This tool returned 50 of 2311 dialogs on a production capture with nothing in the reply to mark the cut, and limit alone could not close the gap: requests above 1000 clamp to the hard cap, leaving 1311 dialogs no call could reach.

An agent asked “how many calls failed?” counts the rows it holds and answers with that number, so a short list does not read as an incomplete answer — it reads as a confident wrong one. total_matched and truncated name the shortfall. cursor closes it.

next_cursor pairs a timestamp with a Call-ID for the same reason tail_dialogs does. Dialogs share a created_at routinely — a burst of registrations lands on one millisecond — and a bare timestamp forces a choice between dropping the rest of that group and serving it twice. Resuming after the (created_at, Call-ID) pair splits the group exactly where the page ended. Pass the value back unmodified. A bare RFC 3339 timestamp still parses, so rebuilding one by hand fails silently rather than erroring.

The two tools page on different clocks, deliberately. tail_dialogs follows updated_at, because reporting change is its job. list_dialogs pages on created_at, which never moves: a dialog that gains one more message mid-sweep would jump forward in an updated_at ordering, past a cursor that had already gone by, and vanish from the listing.

aggregate_dialogs

Counts dialogs grouped by one field, inside the store.

Counting is the operation a language model gets wrong most reliably, and this page documents that failure elsewhere: an agent asked “how many calls failed?” counts the rows it is holding and answers with that number. The page object fixed exactly one count — total_matched, for one filter. Every other count still meant fetching pages and tallying them in the model’s head, or issuing N filtered queries with the buckets guessed in advance.

NameTypeLegal valuesIf omitted
group_bystringOne 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.
filterstring?Alias or DSL, applied before grouping.No filter — the whole store.
top_ninteger?Bounded by --mcp-max-rows, the same knob that bounds dialog rows — not a second ceiling of sipnab’s own. Anything past it lands in other_count rather than disappearing.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 truncated aggregate that does not say what it left out is a wrong total rather than a partial one, so nothing is silently dropped — including nulls, which become the literal (none): “how many dialogs carry no User-Agent” is a real question.

One dimension, and no time bucketing. That is a deliberate cap, not an unfinished feature. Two dimensions is a pivot table, a pivot table wants a UI, and positioning puts that outside what sipnab is. Narrow the window with filter instead.

Grouping by from.user, to.user or ua returns fenced values. Those are text the packet’s sender wrote, and they reach a model here exactly as they would in a row. A state name, a status code, an IP or a codec is sipnab’s own derivation and comes back verbatim — fencing those would tell the agent to distrust the analysis.

Find — narrow to the calls that matter

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.

find_problems

Convenience wrapper over list_dialogs that ORs each named alias, then ANDs the optional filter.

NameTypeLegal valuesIf omitted
kindsstring[]?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"].
filterstring?An alias name or a raw DSL expression, ANDed with the alias match.The alias match alone decides the page.
limitu32?Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default.50 rows.
cursorstring?The previous response’s next_cursor, verbatim.Starts at the oldest match.
fieldsstring[]?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_bytesu64?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 on this tool especially. Whatever the deployment does most dominates a problems page, and that is rarely the calls: on one real capture 98 of 110 rows carried OPTIONS, and 89 of those tripped an alias solely on retransmits > 3 — dead qualify peers, not call faults. Without the breakdown an agent had to already suspect the keepalive plane to exclude it.

filter is what makes this the triage entry point rather than a firehose. The aliases answer “is this call interesting”. The filter answers “is it one of mine”, so {"kinds": ["problems"], "filter": "dst.ip == '203.0.113.9'"} asks a question that previously needed a client-side join. The two AND together — ORing them would widen the sweep instead of narrowing it.

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.

search_messages

Case-insensitive substring search over method, status, From, To, User-Agent, and body across all dialogs.

NameTypeLegal valuesIf omitted
querystringAny substring. Matching ignores case.Required — the call fails.
limitu32?Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default.50 hits.
cursorstring?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_bytesu64?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:

FieldTypeDescription
hitsobject[]This page of { call_id, message_index, snippet }, ordered by the dialog’s created_at, then Call-ID, then message index.
returnedusizeRows in hits.
total_matchedusizeMessages matching the query across the whole store, whatever limit and cursor say. This is the number that answers “how many”.
truncatedbooltrue when matches remain after this page. Absent while the answer is not whole — see Rules every tool follows.
truncated_bystring?rows when the row limit cut the page, bytes when the byte budget did. Absent when neither bound cut the page.
source_exhaustedbooltrue once sipnab has read the capture source to its end.
source_stopped_earlybooltrue when a source’s read ended before the source did.
next_cursorstring?Pass back to continue. null on the final page.
schema_versionu321 for this shape.
capture_identityobjectWhich 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 and limit: 1000 returns 1000, while 2668 messages match. total_matched is the number that answers “how many are there”, truncated says whether you are seeing all of them, and next_cursor reaches 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\r\n…⟦/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 index half of the cursor carries leading zeros because the server compares the cursor as text — rebuild one by hand and #10 sorts before #2, which silently skips eight messages of a dialog. Pass it back exactly as it arrived.

Diagnose one call

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.

triage_call

Start here. The first question in VoIP triage is which half of the stack failed. Signaling decides whether a call connects. RTP decides whether you can hear it. They have different causes and different fixes, and confusing them is the most common wrong turn — so ask this before anything else.

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": 38,
    "cause_text": "Network out of order",
    "protocol": "Q.850",
    "source_header": "Reason",
    "frame_ref": 7
  }
}

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:

VerdictNext tool
signalingexplain_response_code on the final code, then get_dialog
mediartp_stats, and check_codec_negotiation if the call failed
bothSignaling first — media symptoms are often downstream of a failed negotiation
noneThe call is fine. Check you have the right Call-ID

Why the call ended

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 answers the question the verdict cannot: “the call ended” and “the call ended because the far end was out of order” are different answers, and only the second closes a ticket.

FieldTypeDescription
cause_codeu16?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_textstring?The human-readable cause. sipnab strips control characters and keeps the first MAX_CAUSE_TEXT_CHARS (200) — far past every value a gateway produces, and short enough that a crafted header cannot spend a context window
protocolstring?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_headerstringWhich header carried the cause: Reason (RFC 3326) or X-Asterisk-HangupCauseCode
frame_refusizeIndex into the dialog’s message list of the message that carried it

sipnab reads the last message that names a cause — a call that went through a challenge, a retry and a clear carries more than one, and the one that says why it ended is the last thing said. Reason on a BYE or a CANCEL counts, which is the case this exists for: a call that clears normally has no failure response to hang a cause on.

Where one message carries both protocols, sipnab picks the non-SIP one, because a SIP cause restates final_status_code and that is already its own field. A Q.850 cause is the gateway’s, and the fact nothing else recovers.

The same block appears on get_dialog_report and on GET /v1/dialogs/{call_id}/report, from one assembler.

get_dialog

Paginated dialog with full SIP messages.

NameTypeLegal valuesIf omitted
call_idstringA Call-ID the store holds. An unknown one fails with call_id 'x' not found.Required — the call fails.
max_messagesu32?At least 1; 0 fails with invalid_params. Ceiling is --mcp-max-rows (1000 by default), and higher clamps to it.100 messages.
cursorstring?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_bytesu64?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:

FieldTypeDescription
dialogobjectThe same summary list_dialogs returns, with from_user and to_user fenced.
messagesobject[]This page of full messages, in dialog order.
returnedusizeMessages in messages.
first_indexusizeThe index of messages[0] in the dialog, so row N of the page is get_message index: first_index + N.
total_messagesusizeMessages in the whole dialog.
truncatedbooltrue when messages remain after this page. Absent while the answer is not whole — see Rules every tool follows.
truncated_bystring?rows when max_messages cut the page, bytes when the byte budget did. Absent when neither bound cut the page.
next_cursorstring?Pass back to continue. null on the final page.
completebooltrue when this page reaches the end of the dialog. Never true over a partial read of the capture.
schema_versionu322 for this shape. Responses through 0.5.211 carried no schema_version, an integer next_cursor, and no returned, first_index, truncated or truncated_by.

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. 0 means 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, hep or uprobe, 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, and contact and sdp when it carried them.
  • A response adds status_code, reason, response_context and ua, and carries no method — read cseq.method for the transaction it answers.

This tool fences, and it is the one that returns the most capture text. reason, from, to, contact, ua, sdp and malformed in messages[] come back wrapped in ⟦untrusted-capture-data⟧ markers, the same treatment get_message gives them. The SDP body keeps its line structure, --mcp-max-body-bytes bounds it, and the header values arrive capped with every control character removed.

This page said the opposite until 0.5.134 — that the fields came back verbatim and that a reader should reach for get_message instead. Both tools fenced the whole time.

Treat every string in messages[] as attacker-written regardless: the markers say where the text came from, they do not make it 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",
  "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 strips every control character a quoting document could act on and keeps only \n and \t, so a client that reassembles the body and compares it byte for byte against the packet finds a difference that is sipnab’s, not the endpoint’s. Read it as text, not as the original octets — reach for show_evidence when you need those.

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.

Through 0.5.211 cursor was the integer index of the first message to return, and next_cursor the index to resume at. Both are now the opaque string every other paged tool uses, which a client must pass back unchanged. sipnab refuses a client that sends an integer with invalid_params. To address one message by position, use first_index with get_message.

rtp_stats

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:

NameTypeLegal valuesIf omitted
call_idstring?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_mosf64?Sweep only. Keeps streams scoring at or above this. Rejected alongside call_id.No lower bound.
max_mosf64?Sweep only. Keeps streams scoring strictly below this. Rejected alongside call_id.No upper bound.
orphanedbool?Sweep only. true keeps only streams no dialog claims, false keeps only streams a dialog claimed. Independent of the MOS bounds — an orphan usually has no dialog to ground its clock, so requiring a bound alongside would filter out most of what this exists to find.Both kinds sweep.
limitu32?Sweep only. Ceiling is --mcp-max-rows (1000 by default), higher clamps to it, 0 means the default.50 streams.
cursorstring?Sweep only. The previous response’s next_cursor, verbatim (<RFC 3339>|0xSSRC@src>dst).Starts at the earliest stream.
max_response_bytesu64?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",
      "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 — a call holds every stream it holds, so there is nothing to page. quality_intervals holds one entry per completed sampling window, so a short call legitimately returns an empty array while this eight-second one returns a single 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. The stream-level MOS is a mean over the whole call, and a mean hides a burst shorter than itself: one 90 % loss figure this project had to explain turned out to be three bursts of half a second. sipnab scores an interval on its own jitter and loss and on the STREAM’s resolved path delay, so a number in the trend and the headline beside it rest on one basis.

verdict has three states and the third is the point:

verdictWhat it means
acceptableR at or above 70, where ITU-T G.107’s worst user-satisfaction category is still “some users dissatisfied”.
degradedR below 70, where that category becomes “many users dissatisfied”.
not_scorableThe 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 is the only answer for an ungrounded codec, and it is not a shade of good or bad. Anything that COUNTS degraded intervals has to read the verdict first: a refusal to judge is not the absence of a problem, and reading it as one reports a healthy call on a codec nobody can score.

Each stream carries mos_grounded and mos_grounding. estimate_mos returns the same number — 4.216 at 10 ms jitter — for AMR, AMR-WB, EVS, G.722 and for a stream whose codec was never identified, because sipnab only has published ITU-T G.113 impairment values for G.711, G.729 and Opus. When mos_grounded is false the MOS means unknown, not “about 4.2”, and a mos_note says so.

mos_grounding names which basis the number rests on, because “grounded” now covers two of them:

mos_groundingmos_groundedWhat the number rests on
publishedtrueAn ITU-T G.113 impairment value sipnab implements.
operator_declaredtrueAn 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.
unpublishedfalseNothing 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:

FieldMeaning
mos_widebandMOS_CQEW on the ITU-T G.107.1 wideband scale
mos_wideband_contextThe listening context sipnab scored the stream in: monotic or diotic
mos_wideband_unavailableWhy 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.

Who named the dialog — dialog_assertion

Each stream also says who asserted the SDP media endpoint that tied it to its Call-ID:

dialog_assertionWho said itWhat the address is
signaleda negotiating party, in its own SDPthat party’s endpoint
media-relayan rtpengine relay, about a port it allocatedthe 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.

Capture-wide sweep — omit call_id

“Every stream with a MOS below 3.5” is one call rather than a listing plus one rtp_stats per dialog, which costs thousands of round trips on a real capture. The sweep also reaches streams the per-call mode cannot: a stream that never linked to a dialog has no Call-ID to ask about, and an orphan is not an oddity — it is what a NAT or one-way-audio fault looks like from the media side.

FieldTypeDescription
streamsobject[]This page, oldest first_seen first.
returnedusizeRows in streams.
total_matchedusizeStreams matching across the whole store.
ungrounded_excludedusizeStreams a MOS bound could not judge.
truncatedbooltrue when matches remain after this page.
truncated_bystring?rows when the row limit cut the page, bytes when the byte budget did. Absent when neither bound cut the page.
next_cursorstring?Pass back to continue. null on the final page.
capture_identityobjectWhich capture answered — see capture_status. A changed instance voids every cursor you hold.
schema_versionu321 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, because a bound on a placeholder picks calls out of a guess — and it goes wrong in both directions. A healthy AMR-WB stream never appears in a max_mos sweep, while a degraded one turns up on a figure that never described it.

Reporting the skipped count keeps the difference visible: “2 streams below 3.5” and “2 streams below 3.5, plus 200 I cannot score” describe different captures, and on any network carrying AMR-WB, EVS or G.722 the second one is the truth.

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 associated_dialog.is_none(), which sipnab computes while building the response, so the two fields can never disagree: every stream above reports orphaned: true, which is the truth about a capture holding four streams and no dialogs at all.

sipnab computes orphaned per response as associated_dialog.is_none(), so a stream reports it from its FIRST packet. That matters because a short unclaimed stream is exactly what a NAT or one-way-audio fault looks like from the media side: an agent filtering for orphans to find one must not have to wait for a sweep to notice. Reading associated_dialog directly means the same thing.

total_matched: 2 and ungrounded_excluded: 2 account for all four streams. The two G722 streams score 4.22 from the placeholder arm, which would have put them under a 4.5 bound on a number that means nothing.