Docs vCon Export

vCon Export

Export one observed dialog as a vCon container: what the format is, how to produce one, and what an observer's record does and does not let a consumer conclude.

On this page

Something downstream wants the call rather than the packets. A conversation archive, a compliance store, an agent that reasons over calls: hand any of them a pcap and you have made the decoding their problem. vCon is the interchange container those systems already read, and sipnab writes one per observed dialog.

The short version. A vCon is a JSON object about one conversation — the parties, what passed between them, and what some tool concluded about it. sipnab fills that shape from signaling it watched go past a tap. Each container carries the SIP ladder, the parties the From and To headers named, a diagnosis of the call, and — in two places, deliberately — a statement of what the capture missed. None of them carries audio, a signature, or any claim that somebody consented to anything. A sipnab vCon records what an instrument saw, not what the parties said. The honesty section below is the one part of this page to read before you trust a container.

What a vCon is, and what sipnab is to it

vCon — “Conversation Data Container” — travels between systems as four arrays: parties (who took part), dialog (what passed between them and when), analysis (what a machine concluded about that) and attachments (documents riding alongside).

The ecosystem around the format assumes a recorder — something inside the conversation, which took the media from a party and can say what that party agreed to. A recorder can write “I received this audio from the caller.”

sipnab was never inside the conversation. It reads a mirror port, so the strongest sentence it can honestly write is “I saw packets claiming to be this call go past this tap.” Those two sentences look alike in JSON and mean entirely different things.

So sipnab emits observer vCons. The container names sipnab as a passive party contributing to a record somebody else owns, which is a role the format itself defines, and it stops there. It never claims to be the conversation. The Phase 0 decision argues that position in full, including every refusal summarized further down this page.

Produce one

The export sits behind the non-default vcon Cargo feature, so a stock build does not carry it. The full feature includes it. On its own it looks like this:

cargo build --release --features vcon

The feature is non-default for the reason every capture-side feature is: a container that leaves the machine is a publication surface, and a capture tool should not grow one unless an operator asks for it.

sipnab builds a container from one dialog, and every surface that offers the export writes the same bytes. Pick the door that suits you:

You areAsk this wayAnswers with
At a shell, reading a capture filesipnab -r call.pcap --export-vcon <call-id> --vcon-out out.jsonthe container in out.json
At a shell, wanting it on stdoutsipnab -r call.pcap --export-vcon <call-id>the container on stdout
An agent holding an MCP sessionthe export_vcon tool, with call_ida structured content block
A program over HTTPGET /v1/dialogs/{call_id}/vcon200 and the container, or 404
Rust, in-processsipnab::output::vcon::export_dialoga Vcon value

Two notes on the CLI pair. --vcon-out requires --export-vcon, because a path with nothing to write to it is a mistake sipnab would rather name than ignore. And sipnab refuses a --vcon-out that names the capture it is reading, because an export that overwrites its own evidence is not an export.

The TUI does not offer the export. It is a live view of a running capture, and it points at the doors above — its help screen names the REST route and the feature flag rather than growing a key binding that would do nothing in a build without the feature.

Read on for what the container means once you hold one.

Walk through one, end to end

The capture is tests/fixtures/sip_call.pcap, committed to this repository: one INVITE, a 100, a 180, a 200, the ACK, a BYE and its 200. Seven messages, one Call-ID, two parties.

Step 1 — check the build carries the feature

The committed integration test drives the whole export through the public API against that capture, so running it confirms both the feature and the fixture in one command:

cargo test --features vcon --test vcon_export_test
running 2 tests
test a_real_capture_exports_a_complete_signaling_only_container ... ok
test re_exporting_one_dialog_from_one_capture_keeps_its_identifier ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Step 2 — build the container

Read the capture into a DialogStore the ordinary way — the library page covers that part, and the committed test holds the whole program — then hand one dialog and the run’s own counters to export_dialog:

use sipnab::analysis::CaptureFacts;
use sipnab::output::vcon::{ExportContext, export_dialog};

let dialog = store.get(&call_id).expect("the dialog is in the store");
let vcon = export_dialog(
    dialog,
    &ExportContext {
        // Derive this from the capture's CONTENT — a file name, a frame
        // digest. Never from a per-process identifier: the container's uuid
        // hashes this value, so a rotating one mints a fresh uuid every time
        // you reopen the same file.
        capture_id: "sip_call.pcap",
        // What the RUN saw outside this one dialog: frames read, SIP a port
        // gate discarded, dialogs the store rotated away.
        facts: &facts,
        // The ranked capture analysis, when the run did one. `None` is an
        // honest answer, and the container reports it as one rather than as
        // a clean bill.
        analysis: None,
    },
);
println!("{}", vcon.to_json().expect("the container serializes"));

Step 3 — read what came out

Real output from that fixture, elided only where the message trace repeats itself. Every value below came off a run against the committed capture.

One reading convention, stated once. Every body is a JSON-encoded string on the wire — §2.3.2 of the draft allows nothing else, and a store normalizes it to one anyway. The blocks below show each body decoded, as an object, because an escaped one-line string is unreadable on a page. What sipnab actually writes for the first one is "body": "{\"messages\":[…]}", and a consumer parses it before indexing into it.

{
  "vcon": "0.4.0",
  "uuid": "018bcfe5-6800-8a6b-a667-78f1c5213800",
  "created_at": "2026-08-24T17:05:12+00:00",
  "extensions": [
    "sip-signaling"
  ],
  "parties": [
    {
      "sip": "sip:[email protected]",
      "validation": "none",
      "sip_contact": "<sip:[email protected]:5060>",
      "sip_user_agent": "sipnab-test/1.0"
    },
    {
      "sip": "sip:[email protected]",
      "validation": "none"
    },
    {
      "validation": "none",
      "role": "observer",
      "sip_user_agent": "sipnab/0.5.124 (observer; node thor-02)"
    }
  ],
  "dialog": [
    {
      "sip_call_id": "[email protected]"
    }
  ],
  "attachments": [
    {
      "purpose": "sip-message-trace",
      "party": 2,
      "mediatype": "application/json",
      "encoding": "json",
      "body": {
        "messages": [
          {
            "call_id": "[email protected]",
            "contact": "<sip:[email protected]:5060>",
            "cseq": {
              "method": "INVITE",
              "number": 1
            },
            "dscp": 0,
            "dst": "10.0.0.2",
            "dst_port": 5060,
            "frame": "…/tests/fixtures/sip_call.pcap#0@bdb6cdb98013efe4",
            "from": "<sip:[email protected]>;tag=1928301774",
            "is_request": true,
            "method": "INVITE",
            "schema_version": 1,
            "sdp": "",
            "src": "10.0.0.1",
            "src_port": 5060,
            "timestamp": "2023-11-14T22:13:20+00:00",
            "to": "<sip:[email protected]>",
            "transport": "UDP",
            "ua": "sipnab-test/1.0"
          }
          … ELIDED: the 100, the 180, the 200, the ACK, the BYE and its 200,
          six more objects of the same shape …
        ],
        "schema_version": 1,
        "sip_call_id": "[email protected]"
      }
    },
    {
      "purpose": "sipnab-capture-completeness",
      "party": 2,
      "mediatype": "application/json",
      "encoding": "json",
      "body": {
        "dialogs_refused": 0,
        "dialogs_rotated": 0,
        "frames_read": 22,
        "messages_evicted": 0,
        "node": "thor-02",
        "note": "Produced by sipnab 0.5.124 on node thor-02. sipnab OBSERVED this dialog and took no part in it: the parties below are what the From and To headers said, not identities anyone established, and nothing here is signed. This container carries SIGNALING ONLY — no media, and no reference to media held elsewhere. sipnab read 22 frame(s) for this capture. No omissions recorded: every message sipnab held for this dialog is in this container. No capture-level analysis was supplied for this export, so nothing here rules out a blind spot.",
        "sip_discarded_by_port_gate": 0,
        "sip_discarded_by_websocket_gate": 0,
        "sipnab_version": "0.5.124",
        "undecodable_frames": 0
      }
    }
  ],
  "analysis": [
    {
      "type": "report",
      "dialog": 0,
      "vendor": "sipnab",
      "product": "sipnab 0.5.124 (passive observer; signaling only)",
      "schema": "sipnab-dialog-diagnosis/1",
      "mediatype": "application/json",
      "encoding": "json",
      "body": {
        "capture_completeness": {
          … ELIDED: byte for byte the completeness body above …
        },
        "final_status_code": 200,
        "schema_version": 1,
        "sip_call_id": "[email protected]"
      }
    }
  ]
}

Four things in that output repay a second look.

Three parties for a two-party call. The last entry is sipnab. It carries no sip URI, because sipnab sent no SIP and a synthesized one would name a participant that never existed. Its role says observer, and both attachments point their party index at it — index 2 — so every document in the container has a stated origin.

The callee has fewer fields than the caller. Party 1 carries a sip and nothing else. sipnab reads a party’s Contact and User-Agent off a message that party sent, and in this capture the first message coming back is a bare 100 Trying that carries neither. An absent field means the capture never saw one, not that the endpoint has none.

The Dialog Object is nearly empty, and that is correct. One sip_call_id, no type, no disposition, no media description. The call in this fixture completed, so nothing about it failed. A signaling-only export has no media, so inventing a mediatype or a url would describe a file that does not exist.

The completeness body appears twice. Once as an attachment, once inside the report. The next section is about why.

The container cannot say “this is an incomplete record”, so sipnab says it twice

This is the point of the page.

sipnab’s central discipline is reporting what it understood rather than what the wire held. Its ranked problem list puts incompleteness findings in the list at Severity::Blind, above every call fault, so a capture that failed to decode or hit a retention cap can never render as clean.

vCon has no field for that. Not a weak one — none. The type enum admits five values and no others: recording, recording-set, text, transfer, incomplete. Four of the five promise content the object holds, and every Dialog Object must carry one of them.

So sipnab types the object by what it carries, never by what the call did:

The object holdstypedisposition
audiorecordingabsent — it is an incomplete field
no contentincompletepresent only when sipnab SAW a final failure

That leaves incomplete doing double duty, and the honest reading of it here is an incompleted record, which a signaling-only export is. The alternative is worse in a way that is not a matter of taste: an object typed recording carrying neither url nor body promises content that is not there, and a conserver chain link that selects type == "recording" reads dialog["url"] unguarded — it raises, and the conserver dead-letters the whole container rather than the one step. A label that is imprecise beats a label that destroys the record.

What the object cannot say, disposition and the caveat do. disposition names a failure only when sipnab saw the final response that caused it, so its absence never means “the call succeeded”.

The extension mechanism offers no repair either. An extension is ignorable, in which case the consumer that most needs the caveat drops it, or fatal, in which case an ordinary consumer refuses the whole container. Nothing in between means “read this before trusting the contents”.

So sipnab duplicates the caveat into two surfaces a consumer walks past, from one source value:

SurfaceWhere it sitsWho reads it
analysis[0].body.capture_completenessinside the report, beside the diagnosisanything that reads what sipnab concluded
the sipnab-capture-completeness attachmenta document in attachments, attributed to the observeranything that walks the attachments

Both hold the same value byte for byte, and a test fails if they diverge. Two caveats that disagreed would read as authoritative while contradicting themselves, which is worse than carrying none.

What the caveat looks like when the run lost something

Same capture, same dialog, a run whose parser rejected three frames and whose idle compaction discarded four messages it had already captured:

{
  "purpose": "sipnab-capture-completeness",
  "party": 2,
  "mediatype": "application/json",
  "encoding": "json",
  "body": {
    "dialogs_refused": 0,
    "dialogs_rotated": 0,
    "frames_read": 22,
    "messages_evicted": 4,
    "node": "thor-02",
    "note": "Produced by sipnab 0.5.124 on node thor-02. … sipnab read 22 frame(s) for this capture. — INCOMPLETE: 3 frame(s) reached the parser and produced nothing, so any count in this container is a floor. — INCOMPLETE: idle compaction discarded 4 message(s) sipnab had already captured, so the trace in this container may be shorter than the call was. Raise [limits] keep_messages_per_idle_dialog to hold more. No capture-level analysis was supplied for this export, so nothing here rules out a blind spot.",
    "sip_discarded_by_port_gate": 0,
    "sip_discarded_by_websocket_gate": 0,
    "sipnab_version": "0.5.124",
    "undecodable_frames": 3
  }
}

Elided at the only: the fixed preamble is identical to the clean run’s. Read the shape rather than the wording. Every clause is a measurement of this run, and none of them says the call was short, silent or broken, because none of that follows from a capture that missed something. messages_evicted: 4 and the sentence naming it move together, so a consumer that reads fields reaches the same verdict as one that reads prose.

Note the last clause in both runs: no capture analysis reached this export, so the note says so. “Nobody checked” and “checked and found nothing” are different answers, and the container keeps them apart — an analysis that ranked nothing emits blind_spots: [], while an export that skipped the analysis omits the field entirely.

Someone handed you a sipnab vCon — what may you conclude?

Read the container as evidence from an instrument, not as a record from a participant.

What you seeWhat it licenses you to sayWhat it does not
parties[].sipthe From or To header carried this URIthat anyone with that URI took part
parties[].sip_display_namethe sender wrote this string in its headerthat this names a person
validation: "none" on every partynobody checked anything about this partythat a check failed — the field never carries another value
a role: "observer" partythis container came from a tapthat the tap was in the media path
attachments[].partythe observer contributed this documentthat a participant did
the message tracethese messages reached sipnab’s parserthat they are all the messages
analysis[0].bodysipnab’s diagnosis of what it helda diagnosis of the call
an absent fieldthe capture did not carry itthat the call lacked it
an empty dialog[] media descriptionthis export carries no mediathat the call had none

Three rules follow from that table.

A missing thing is a missing observation. Every absence in a sipnab vCon describes the capture. It never describes the conversation. That distinction is the whole reason the completeness caveat exists, and a consumer that collapses it turns a configuration choice on a capture host into a claim about somebody’s call.

sipnab signs nothing, so cryptography attributes nothing here. Trust the container exactly as much as you trust whoever handed it to you.

Two containers about one call do not merge themselves. sipnab writes the parties of the dialog it observed and infers none. One tap on a proxied call sees two legs of one conversation or one leg of three, and a second tap that saw the other leg writes its own container. Reconciling them belongs to the consumer, because the consumer is the only one holding both.

What sipnab refuses to put in a container

Each refusal has an argument behind it in the design decision. The one-line version:

RefusedWhy
a JWS signaturea signature over an observation verifies as a signature over a recording
JWE encryptionit asserts a custody relationship sipnab does not have
consent attachmentssipnab obtained no consent, and an empty consent field reads as “none recorded”
lawful-basis attachmentsthe same, with a named regulatory reader on the other end
a party nameFrom and To are the caller’s claim about the caller, trivially spoofed
hosted artefacts by urlsipnab hosts nothing, so it cannot promise where a file lives

The party name refusal has a structural form worth knowing about: the Rust type has no name field at all, so no later edit can populate one by accident.

One thing sipnab does supply, narrowly: tel

A conserver indexes parties by tel, mailto and name and by nothing else, so a consumer can retrieve a container carrying none of the three by its UUID and by nothing else — a party search never surfaces it. An operator who knows only the phone number cannot find the capture.

tel is the only one of the three sipnab can supply from evidence, and it supplies it only when the SIP user part is unambiguously a telephone number: + followed by digits, an RFC 3966 global number. Everything else gets nothing, including bare digit runs — 1001 is an extension, and indexing it as a telephone number would put a wrong answer in a search index rather than no answer. The observer party never carries one at all.

The tags that tell forked legs apart

The dialog object carries sip_from_tag and sip_to_tag when the capture observed them. A Call-ID alone cannot distinguish one leg of a forked INVITE from another — every fork shares it — so a consumer correlating legs across nodes needs the tags. sip_to_tag is absent until the callee answers, which is itself a signal: nothing established a dialog.

What this does not do

Stated here rather than discovered later.

  • It does not carry audio. The container holds signaling only, and it holds no url pointing at media held elsewhere. Export audio through sipnab’s own audio path, which carries its own partial-capture caveat.
  • It does not tie the two halves of a B2BUA call together. Two Call-IDs produce two containers, and nothing in either one links them.
  • It does not assemble a call across hops. One container describes one observed dialog from one capture.
  • It does not deduplicate across taps. Two sipnab boxes that saw the same dialog mint different uuids, because the uuid mixes in the node and the capture identity along with the Call-ID.
  • It does not survive as a stable identifier across dialogs that opened in the same millisecond on one node. The uuid layout spends its entropy on the node, leaving 12 bits to separate same-millisecond dialogs. Deduplication on uuid is safe for re-exports of one dialog, which is what it exists for.
  • It does not store anything. sipnab writes a container and forgets it. No vCon store, no index, no retention.

See also

  • The Phase 0 decision — what sipnab may put in a vCon, what it refuses to, and the structural gap in the format that decides both
  • vCon internals — for anyone changing the exporter
  • Output formats — the NDJSON and report surfaces the message trace shares its projection with
  • Library API — filling a DialogStore from a capture