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 are | Ask this way | Answers with |
|---|---|---|
| At a shell, reading a capture file | sipnab -r call.pcap --export-vcon <call-id> --vcon-out out.json | the container in out.json |
| At a shell, wanting it on stdout | sipnab -r call.pcap --export-vcon <call-id> | the container on stdout |
| An agent holding an MCP session | the export_vcon tool, with call_id | a structured content block |
| A program over HTTP | GET /v1/dialogs/{call_id}/vcon | 200 and the container, or 404 |
| Rust, in-process | sipnab::output::vcon::export_dialog | a 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 holds | type | disposition |
|---|---|---|
| audio | recording | absent — it is an incomplete field |
| no content | incomplete | present 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:
| Surface | Where it sits | Who reads it |
|---|---|---|
analysis[0].body.capture_completeness | inside the report, beside the diagnosis | anything that reads what sipnab concluded |
the sipnab-capture-completeness attachment | a document in attachments, attributed to the observer | anything 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 see | What it licenses you to say | What it does not |
|---|---|---|
parties[].sip | the From or To header carried this URI | that anyone with that URI took part |
parties[].sip_display_name | the sender wrote this string in its header | that this names a person |
validation: "none" on every party | nobody checked anything about this party | that a check failed — the field never carries another value |
a role: "observer" party | this container came from a tap | that the tap was in the media path |
attachments[].party | the observer contributed this document | that a participant did |
| the message trace | these messages reached sipnab’s parser | that they are all the messages |
analysis[0].body | sipnab’s diagnosis of what it held | a diagnosis of the call |
| an absent field | the capture did not carry it | that the call lacked it |
an empty dialog[] media description | this export carries no media | that 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:
| Refused | Why |
|---|---|
| a JWS signature | a signature over an observation verifies as a signature over a recording |
| JWE encryption | it asserts a custody relationship sipnab does not have |
| consent attachments | sipnab obtained no consent, and an empty consent field reads as “none recorded” |
| lawful-basis attachments | the same, with a named regulatory reader on the other end |
a party name | From and To are the caller’s claim about the caller, trivially spoofed |
hosted artefacts by url | sipnab 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
urlpointing 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
uuidis 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
DialogStorefrom a capture