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.

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

The spool contract, for a bridge that consumes it

--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/vcon

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

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

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

Its 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"));

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

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

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

When a container is missing on purpose

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.

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

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

The message trace carries the headers, and strips the credentials

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.

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 link to audio held elsewhere. When the run retained the RTP payload the container carries the audio INLINE, as a recording Dialog Object with a sha512- content hash; when it did not, the container says so in words and carries none. There is never a url, 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-media sets that budget in MiB. It defaults to 5, a figure measured against a real vCon store that answered 204 for 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. 0 refuses 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 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
โš ๏ธ **GitHub.com Fallback** โš ๏ธ