Convert AI-agent session transcripts (Claude Code, Anthropic Messages API, OpenAI Responses, OpenAI Agents SDK) into vCons with the
agent_sessionextension, embedding a Verifiable Agent Conversations (VAC) record inanalysis[].
Spec targets:
- vCon core:
draft-ietf-vcon-vcon-core-04(syntax"0.4.0") - Agent session:
draft-howe-vcon-agent-session-00 - VAC record:
draft-birkholz-verifiable-agent-conversations
For each AI-agent session, it produces a single vCon that carries:
- Parties — the user (party 0) and each agent (party 1+) with
meta.agent_session(model_id, provider, recording_agent, cwd, vcs_branch, vcs_commit, parent_agent_id for sub-agents).- Party
type(§4.2.11): every agent party — the primary model, a Claude Code sub-agent detected from aTasktool call, or a bridged tool/service — is always automated, so it always getstype: "bot". The user party getstype: "person"only when the source makes that unambiguous: a Claude Code session's own"user"JSONL events, or a direct Anthropic Messages API / OpenAI Responses APIrole: "user"turn. It is left off for OpenAI Agents SDK sessions (a multi-agent orchestration runtime where the top-level "user" turn can itself be another agent or an upstream service) and for OTel GenAI spans (no role guarantee at all) rather than guessed. - Party
org(§4.2.12): set on a bot party toAgentRef.providerwhen that provider is source-derived and not the"unknown"fallback — an OTelgen_ai.provider.name/gen_ai.systemattribute, or which vendor-specific API/SDK a source module talked to (Claude Code and the Anthropic Messages API bridge →anthropic; the OpenAI Responses/Agents bridges →openai). Never invented.dept(§4.2.13) is not populated anywhere — no source here carries a department identifier.
- Party
- Dialog turns — user prompts and assistant replies as ordinary
dialog[]entries. - Internal trace — tool calls, tool results, reasoning, and system events embedded as a JSON-encoded VAC
verifiable-agent-recordinanalysis[]withtype: "agent_trace"andschemapointing at the VAC datatracker URL. - File-edit provenance —
attachments[]entries withpurpose: "agent_file_change"whenever the agent's tool calls touched a file (Claude Code:Write,Edit,MultiEdit,NotebookEdit, and file-touchingBashcommands). - Environment metadata —
purpose: "agent_environment"per agent. - Optional lawful basis —
purpose: "lawful_basis"attachment (never the legacytypefield) driven byLAWFUL_BASIS/LAWFUL_BASIS_*env vars or avcon.lawful_basis:config block (see "Lawful basis" below). Never defaulted: an unset basis means no attachment, and a one-time warning is logged.
Per draft-ietf-vcon-vcon-core-04 §4.5.3, an analysis entry's attachment field is an
index (or list of indices) into attachments[] naming which attachments it was derived
from; it's optional only when the analysis wasn't derived from any attachment.
Every agent_trace analysis entry (both granularity=session and granularity=per_tool_call)
embeds the full session.agents list, environments included, so it's always derived from
every agent_environment attachment. At per_tool_call granularity it additionally covers
exactly one tool-call entry, so it's also derived from any agent_file_change attachment(s)
produced by that same entry (e.g. a Write/Edit/MultiEdit call); at session granularity
it covers every entry, so it's derived from every agent_file_change attachment present.
agent_trace is never derived from lawful_basis — that attachment, when present, is always
appended last and carries no attachment back-reference from any analysis.
Indices are resolved against the attachments actually appended to the vCon at analysis-build
time (session_vcon.build_vcon() appends agent_file_change/agent_environment before
emitting agent_trace), never hardcoded. Because lawful_basis is always the last attachment
appended — whether via include_lawful_basis=True inside build_vcon() or by a caller invoking
add_lawful_basis() itself afterward (e.g. a CLI finalize step) — appending it can never shift
an already-resolved attachment index.
| Platform | Mode | Notes |
|---|---|---|
| Claude Code | file (~/.claude/projects/**/*.jsonl) |
Native parser; sub-agents via Task tool |
| Anthropic Messages API | request/response logs | via vcon-mcp-adapters bridge |
| OpenAI Responses API | request/response logs | via vcon-mcp-adapters bridge |
| OpenAI Agents SDK | live TracingProcessor |
enqueue-only callback; daemon consumes |
| OpenTelemetry | OTLP/JSON spans | GenAI semantic conventions; no vendor SDK, no extra deps |
Anthropic/OpenAI platforms require pip install vcon-mcp-adapters alongside this adapter.
uv pip install -e .
# Optional extras:
uv pip install -e ".[cbor,signing,dev]"
# For Anthropic / OpenAI source bridges:
uv pip install vcon-mcp-adaptersvac-adapter convert \
~/.claude/projects/-Users-x-proj/2026-05-22-abc.jsonl \
--platform claude_code \
--out /tmp/session.vcon.jsonPoint it at anything that already emits the OpenTelemetry GenAI semantic conventions — an OTLP/JSON export body, or a bare JSON array of spans:
vac-adapter convert trace.json --platform otel --sign-key private.pem --out session.vcon.jsonSpans map by gen_ai.operation.name: chat / text_completion /
generate_content / invoke_agent become dialog[] turns (from
gen_ai.input.messages and gen_ai.output.messages, or the legacy
gen_ai.user.message / gen_ai.choice span events); execute_tool becomes a
tool_call + tool_result pair inside the VAC record; everything else under
the trace becomes a VAC event. Model, provider, and agent identity come from
gen_ai.provider.name / gen_ai.request.model / gen_ai.agent.*; host and OS
from resource attributes. A tool span with no model attributes inherits its
parent span's agent.
--sign-key JWS-signs the finished vCon (RSA private key, PEM) so the output
is a verifiable artifact, not just a JSON blob.
Options:
--granularity {session,per_tool_call}— singleagent_traceper session (default) vs one per tool call (for selective redaction).--vac-encoding {json,cbor}— CBOR mode emits a base64url-encoded CBOR record withmediatype: application/cbor.--critical-agent-session— also addagent_sessionto vConcritical[].--no-lawful-basis— skip the lawful_basis attachment outright, regardless ofLAWFUL_BASIS.--post— also deliver the finished vCon. Needs either--webhook-url(+ optional--webhook-secretfor HMAC signing) or--conserver-url(+ optional--conserver-token, repeatable--ingress-list) to POST directly to a vcon-server/vconendpoint.
LAWFUL_BASIS=consent LAWFUL_BASIS_PURPOSE=recording \
vac-adapter convert session.jsonl --platform claude_code --out session.vcon.json \
--post --conserver-url https://conserver.example.com --conserver-token "$CONSERVER_TOKEN"Never defaulted in code. Set via env vars (checked by vcon_builder.LawfulBasisConfig.from_env(),
used by convert and by daemon mode when no YAML vcon.lawful_basis: block overrides it):
LAWFUL_BASIS— one ofconsent,contract,legal_obligation,vital_interests,public_task,legitimate_interests. Unset means nolawful_basisattachment is added, and a warning is logged once per process.LAWFUL_BASIS_PURPOSE— comma-separated purpose grants (defaultrecording).LAWFUL_BASIS_JURISDICTION,LAWFUL_BASIS_EXPIRATION(ISO 8601),LAWFUL_BASIS_PROOF_MECHANISM,LAWFUL_BASIS_PROOF_DESCRIPTION— optional.
Or, in config.yaml, under vcon.lawful_basis: (env vars still win per-field when both are set — see LawfulBasisConfig.resolve()):
vcon:
lawful_basis:
lawful_basis: consent
purposes: [recording, transcription]
jurisdiction: US-MAThe resulting attachment uses purpose: "lawful_basis" (never the legacy type field), a string
JSON body, and mediatype: "application/json"; "lawful_basis" is added to the vCon's top-level
extensions[].
cp config.example.yaml config.yaml
# edit config.yaml: adapter.source_platform, source.claude_code.watch_dir, webhook.endpoints, ...
vac-adapter daemonWhen adapter.source_platform: claude_code and source.claude_code.watch_dir are both set, daemon
mode watches that directory (via watchfiles) for Claude Code session .jsonl files, builds a vCon
per new-or-changed session, and delivers it (webhook, HMAC-SHA256-signed, or conserver-direct per
delivery.mode) with exponential backoff and a dead-letter queue on full failure. Delivery is
idempotent per file content hash, tracked in .vac-adapter-watch-state.json inside the watched
directory, so restarting the daemon doesn't redeliver unchanged sessions.
source.claude_code.watch_dir is never defaulted to the real ~/.claude/projects. It must be
set explicitly (typically via ${SOME_ENV_VAR} substitution) — an operator who wants to watch their
own Claude Code projects directory opts in by pointing this at it themselves. With no watch_dir
configured, or a source_platform other than claude_code, the daemon just serves /healthz and
/metrics until stopped; other platforms (Anthropic, OpenAI Responses, OpenAI Agents SDK, OTel) have
no watcher yet — use one-shot convert --post for those, or watch their log/trace output yourself
and shell out to convert.
/healthz and Prometheus /metrics are exposed on server.host:server.port.
The adapter inherits the 14 spec-compliance smoke tests from vcon-adapter-template plus 14 additional agent_session / VAC assertions:
vcon: "0.4.0"agent_sessioninextensions[]- Every agent party carries
meta.agent_session.{model_id, provider, recording_agent} analysis.type == "agent_trace"with requiredvendor,schema, valid JSONbodyparsable as VAC- Deterministic VAC entry IDs (UUIDv5 from session namespace)
agent_file_changeattachments includepurpose,party,dialog,content_hash- Per-tool-call granularity emits one trace per
tool_call - CBOR mode round-trips canonically
Run the suite:
pytestThis repo's sources/claude_code.py and vcon-mcp-adapters' adapters/claude_code.py both parse
the same Claude Code JSONL session format, and are not interchangeable — they target different
output schemas for different purposes, so this adapter keeps its own parser canonical rather than
delegating to the sibling repo's:
- This repo's parser builds the local
ir.SessionIR: multi-agent parties (sub-agents detected viaTasktool calls with asubagent_type), a parent/child entry tree, fullreasoningblock text, and derivedagent_file_changerecords.session_vcon.build_vconneeds all of that to emit theagent_sessionextension's multi-party structure and the VACagent_tracerecord — the two things this adapter exists to produce. vcon-mcp-adapters' parser builds anMCPSession: single fixed user/assistant party pair (MCP tool providers get their own party by naming convention only), no sub-agent detection, and only athinking_block_countin place of reasoning text — right-sized for its own single-agent redaction and analytics use cases, not for this adapter's multi-agent VAC record.
Recommendation: keep both. Neither should delegate to the other — collapsing them would either
strip multi-agent/reasoning fidelity from this adapter's vCons, or add IR-specific complexity
(sub-agent tracking, entry trees) that vcon-mcp-adapters' other consumers don't need. What's worth
sharing instead is the low-level JSONL event-shape knowledge (block types, tool_use/tool_result
pairing, sub-agent Task-call detection) if it ever drifts between the two — currently duplicated by
necessity, not by oversight.
- Reasoning fidelity — when ingesting via
vcon-mcp-adaptersthe upstream Claude Code parser records onlythinking_block_count; our native Claude Code parser preserves full thinking text. See "Claude Code parser" above for why the two parsers aren't merged. - Edit
content_hashis post-hoc lossy unless--git-blame-mode(planned) reads the file at the recorded commit viagit show. - Sub-agent detection for Claude Code relies on the Task tool naming; structural changes upstream will silently degrade to single-agent emission.
- Daemon mode only watches Claude Code session directories so far (see "Daemon" above); other source platforms need
convert --postdriven externally.
vcon-adapter-template— scaffold this adapter was forked fromvcon-mcp-adapters— sibling repo providing Anthropic / OpenAI / Agents SDK parsersdraft-kuehlewind-audit-architecture-00— wider agent auditing architecture this adapter participates in
MIT