vCon Export - NormB/sipnab GitHub Wiki
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. When the run retained the RTP payload, the container also
carries the audio, inline. None of them carries 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.
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.
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 vconThe 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 -N -I call.pcap --export-vcon 'CALL-ID' --vcon-out out.json |
the container in out.json
|
| At a shell, wanting it on stdout | sipnab -N -I call.pcap --export-vcon 'CALL-ID' |
the container on stdout |
| An agent holding an MCP session, one call | the export_vcon tool, with call_id
|
one container, its SHA-256, and what the capture missed |
| An agent holding an MCP session, a set | the export_vcon tool, with filter
|
one entry per matching dialog, bounded by --mcp-max-rows
|
| An agent checking a container before sending it | the validate_vcon tool |
a verdict against the vendored schema, with the one documented deviation named |
| 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.
--export-vcon-dir is a queue, and it never runs alone. It pairs with
--export-vcon-when, which names the dialogs to emit in the same expression
language --filter speaks. Each flag requires the other, and
--export-vcon-when conflicts with the single-call --export-vcon, because one
writes a container per matching dialog and the other writes exactly one.
sipnab -N -d eth0 --export-vcon-when "state == 'Failed'" --export-vcon-dir /var/spool/vconNothing in sipnab ships the containers anywhere โ the export path writes files and makes no outbound connection โ so whatever forwards them to a store is a separate program watching that directory. These are the guarantees it may rely on.
A name resolves to a whole container, or to nothing. Every write stages the
bytes under a dot-prefixed sibling, flushes them to the filesystem, and renames
into place. A reader polling the directory therefore never sees a truncated
container, and a write that fails leaves the previous one intact. Staging is
deliberately in the DESTINATION directory rather than in the system temp dir:
rename is atomic only within one filesystem, and across a mount boundary it
either fails outright or degrades into a copy, which puts the partial file
back.
A staging file you can see is litter. sipnab writes it as
.<container>.partial, so an ordinary listing and a *.json glob both skip
it. A failed write removes it. One left behind means sipnab died mid-write, and
it is safe to delete.
Names are stable, and sipnab reuses them. A container's file name comes
from its Call-ID, with an underscore replacing every character outside
[A-Za-z0-9._-]. Re-exporting
the same dialog to the same directory overwrites its file rather than
accumulating a second one, which is what makes the directory a queue and not a
log. Two dialogs whose Call-IDs differ only outside that character set land on
one name. If that matters to you, consume the directory rather than trusting
the name to be unique.
A container is complete when it appears. There is no partial state, no lock file and no sentinel to wait for. Read it, forward it, delete it.
Deleting is the consumer's job. sipnab never removes a container it wrote, so a spool nobody drains grows without bound.
--vcon-digest gives you something to reconcile against. It prints a
SHA-256 of every container written, in sha256sum format, so
sipnab ... --vcon-digest > SHA256SUMS and a later sha256sum -c SHA256SUMS
both work with no glue. Deliberately not a signature and deliberately outside
the container: a store adds fields on ingest, so a signature over sipnab's bytes
would fail against the object the store holds and tell an operator nothing.
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.
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_testrunning 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
The shortest path is the committed example, which does everything below against that same capture and prints the container:
cargo run --features vcon --example export_vcon -- tests/fixtures/sip_call.pcapIts source is examples/export_vcon.rs and it
compiles as part of the build, so it cannot drift from the API the way a
fragment on a page can.
In your own program: read the capture into a DialogStore the ordinary way โ
the library page covers that part โ 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"));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 โ
section 2.3.2 of the core 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",
"CC"
],
"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 capture-01)"
}
],
"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": "192.0.2.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": "192.0.2.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": "capture-01",
"note": "Produced by sipnab 0.5.127 on node capture-01. 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.160 (passive observer; not a recording system)",
"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 describes no media, and that is correct here. This run
retained no RTP payload, so there is nothing to describe and inventing a
mediatype or a url would name a file that does not exist. The object names
no type at all โ see the type rule below โ and no disposition, because the
call in this fixture completed and nothing about it failed.
The completeness body appears twice. Once as an attachment, once inside the report. The next section is about why.
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 the fifth
names a call that "failed to be setup" โ a claim about the conversation rather
than about the object.
So sipnab types the object by what it carries, never by what the call did, and where no value is true it names none:
| The object holds | type |
disposition |
|---|---|---|
| audio | recording |
absent โ it is an incomplete field |
| no content, no observed failure | absent | absent |
| no content, an observed final failure | incomplete |
the reason, always |
The empty row is the one section 4.3 of the core draft provides for: "it is possible
to have a Dialog Object with no parameters in it". Reaching for incomplete
there is the mistake sipnab shipped until 0.5.128, and it is not a matter of
taste โ it made every container for a call that answered assert a setup failure
that never happened. Reaching for recording instead is worse still: 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.
The last two rows move together. Section 4.3.1 of the core draft
makes disposition a MUST on an incomplete object, and no value in the closed
set of section 4.3.11 means "not
observed", so one decision fixes both fields. disposition names a failure
only when sipnab saw the final response that caused it, and 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 |
|---|---|---|
capture_completeness, inside analysis[0].body
|
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.
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": "capture-01",
"note": "Produced by sipnab 0.5.127 on node capture-01. โฆ 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.
Two of the fields in that block report a decision rather than a capture fault, and the note says so in words. Every other clause describes something the run failed to see. These two describe something an operator chose, and a reader who cannot tell them apart goes looking for a fault that does not exist.
| Field | What it means |
|---|---|
gate_closed_during_run |
An operator stopped this run writing content partway through, over POST /v1/persistence. Containers are absent on one side of a hole this capture does not otherwise record. |
dialogs_suppressed_by_deny |
How many dialogs carried the --content-deny-header name and produced no container. |
--content-deny-header suppresses the whole dialog, not merely its content, and
the default is the conservative reading: a denied dialog leaves this process
entirely. --content-deny-tombstone makes the narrower reading available โ an
identity-only container carrying a redacted object
(section 4.1.8 of the core draft), with no message
trace, no media and no bodies. The trade is explicit, because a tombstone
reveals that the call existed. Leave it off when the header means "this call
must leave no trace".
Both are always present, including as false and 0. A missing key and
"nothing happened" are the same fact here, and there is no reason to make a
consumer distinguish them.
The absence is what makes them necessary. A container written after recording stopped looks exactly like one from a run where those calls never happened, so a reader comparing the set against a switch's records concludes sipnab missed them. The same goes for a deny flag: the containers that DID get written read as the complete set for their predicate. Neither fact survives in the capture itself, so the container carries it.
Suppression narrows and never widens. A header asking sipnab to RECORD is an
assertion by whoever sent the request, and this tool refuses that class of
claim โ there is no permit flag, and no REST call can enable persistence a
command line did not authorize. See
docs/rest-api.md
for the runtime gate.
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 |
a dialog[] object with no media fields |
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.
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 that sipnab vouches for
|
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 |
sipnab EMITS a party name when the wire carried one. This page previously
said the Rust type has no name field, which was wrong: Party::name exists
and carries the display name from From or To, alongside โ never instead of
โ sip_display_name. It travels under the declared key so a generic vCon
reader shows a named party rather than an anonymous one.
What sipnab refuses is not the field but the claim: validation is
unconditionally "none", which says a header asserted this name and nobody
checked it.
Treat it as personal data. A display name is the caller's own words about
who they are, and a container is a thing operators forward. If you are writing
a redaction step, key it on name AND sip_display_name โ an earlier version
of this page would have led you to redact only the second.
Each message in the sip-message-trace attachment carries a headers object:
the header name, and an array of its values so a repeated Via or
Record-Route keeps the path the message actually took.
Four headers never travel โ Authorization, Proxy-Authorization,
WWW-Authenticate and Proxy-Authenticate โ because a digest challenge and
its response are credential material and a container is a publication surface.
That filter is worth one honest sentence: until 0.5.125 it did nothing,
because the trace carried no raw headers for it to find. The test guarding it
passed for that reason rather than because the filter worked. Headers and the
filter that makes them safe to publish landed together, deliberately, and the
same test now fails if anyone drops either half.
Everything else on the wire is in the container. Decide whether that is
acceptable for your traffic before you hand one to anybody. P-Asserted-Identity,
Diversion, Remote-Party-ID and any custom header your platform sets all
travel, exactly as the endpoint wrote them.
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 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.
Stated here rather than discovered later.
-
It does not link to audio held elsewhere. When the run retained the RTP
payload the container carries the audio INLINE, as a
recordingDialog Object with asha512-content hash; when it did not, the container says so in words and carries none. There is never aurl, because sipnab hosts nothing and cannot promise where a file lives tomorrow. Audio over the inline budget draws an out-loud refusal rather than a silent truncation, and--vcon-max-inline-mediasets that budget in MiB. It defaults to 5, a figure measured against a real vCon store that answered204for a container of roughly 12 MB, wrote it to its database, and had its own file spool refuse the payload with neither side reporting the partial write.0refuses every inline body without turning the exporter off. Every door โ batch export, REST and MCP โ reads the one value, so the same call cannot come back carrying audio through one and a refusal through another. - 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.
- 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