Loss-aware, vendor-neutral transcript format conversion for AI agents.
Convert agent session logs between Claude Code JSONL, OpenAI messages, Codex traces, Gemini contents, LangSmith traces, Langfuse traces, Ollama chat, and a canonical JSONL — without silently dropping metadata. Run with Claude, replay with OpenAI, analyze with Codex, prompt Gemini, debug LangSmith, observe Langfuse, and replay Ollama — while knowing exactly what survived the trip.
- The problem it solves
- The methodology
- How it works (mechanism)
- The canonical format
- Install
- Usage
- Supported formats
- Loss model
- Project layout
- Security
- Scope (v2)
- Self-check
- License
Agent stacks are multiplying, and each one records conversations differently. Switching formats for debugging, replay, or analysis usually means losing information silently.
| Without transcript-bridge | With transcript-bridge |
|---|---|
Convert Claude → OpenAI and lose cache_control silently |
Every lost field is reported to stderr |
Replay a Codex trace in Claude and drop usage/checkpoint metadata |
Extra fields are stashed in _meta.source and re-emitted when possible |
| Round-trip a transcript and find the output no longer matches the input | selfcheck.py proves round-trip honesty |
| Audit which format dropped what | Loss report gives path, reason, source format, target format, and value |
The key idea: you don't change the agent. transcript-bridge normalizes the transcript.
Three design choices keep the tool small, honest, and composable:
-
Canonical-first, not format-first.
Every input is normalized into one vendor-neutral envelope. Writers translate the canonical model, not each pairwise format difference. -
Block arrays as the source of truth.
Anthropic-style content blocks (text,tool_use,tool_result) preserve ordering of text, tools, and cache hints in a single list. OpenAI'stool_calls/role: toolshape is derived from that list. -
Loss is a first-class output.
--strictturns any loss into a non-zero exit. Loss entries are stored in_meta.lossso the canonical record remembers what could not be represented.
flowchart LR
A["Claude Code JSONL"] -- reader --> C["Canonical turns"]
B["OpenAI messages"] -- reader --> C
D["Codex traces"] -- reader --> C
I["Gemini contents"] -- reader --> C
J["LangSmith traces"] -- reader --> C
K["Langfuse traces"] -- reader --> C
L["Ollama chat"] -- reader --> C
C -- writer --> E["Claude Code JSONL"]
C -- writer --> F["OpenAI messages"]
C -- writer --> G["Codex traces"]
C -- writer --> M["Gemini contents"]
C -- writer --> N["LangSmith traces"]
C -- writer --> O["Langfuse traces"]
C -- writer --> P["Ollama chat"]
C -- analyzer --> H["Loss report"]
sequenceDiagram
participant User
participant CLI
participant Reader
participant Canonical
participant Writer
participant LossReport
User->>CLI: transcript-bridge in.jsonl --from A --to B
CLI->>Reader: read source text
Reader->>Canonical: list[CanonicalTurn]
CLI->>Writer: write turns to target format
Writer->>LossReport: emit loss entries
Writer-->>CLI: output text
LossReport-->>CLI: human-readable report
CLI-->>User: stdout: target text
CLI-->>User: stderr: loss report
The pipeline has three stages:
- Reader parses source text into canonical turns.
- Canonical envelope stores each turn as a JSONL line with
role,content, derivedtool_calls/tool_results, provider/model hints, timestamp, and_meta. - Writer re-serializes turns into the target format and reports any field with no native home.
Nothing is silently dropped. Unknown fields go into _meta.source; unsupported fields become loss entries.
One JSONL line per conversation turn:
{"role":"assistant","content":[{"type":"text","text":"hello"},{"type":"tool_use","id":"call_1","name":"Read","input":{"file_path":"/x"}}],"tool_calls":[{"type":"tool_use","id":"call_1","name":"Read","input":{"file_path":"/x"}}],"tool_results":null,"provider":"anthropic","model":null,"ts":"2026-07-22T12:00:00+00:00","_meta":{"loss":[],"source":{}}}
{"role":"tool","content":[{"type":"tool_result","tool_use_id":"call_1","content":"contents"}],"tool_calls":null,"tool_results":[{"tool_use_id":"call_1","content":"contents"}],"provider":"openai","model":null,"ts":"2026-07-22T12:00:01+00:00","_meta":{"loss":[],"source":{"role":"tool","tool_call_id":"call_1","content":"contents","name":"Read"}}}| Field | Meaning |
|---|---|
role |
user, assistant, system, tool |
content |
Canonical truth: string or array of Anthropic-style blocks |
tool_calls |
Derived view of tool_use blocks inside content |
tool_results |
Derived view of tool_result blocks inside content |
provider |
Source provider hint: anthropic, openai, codex, ... |
model |
Model name if known |
ts |
ISO-8601 timestamp |
_meta |
Always present. Holds loss entries and source record. |
pipx install .
# or install directly from GitHub:
pipx install git+https://github.com/Victorchatter/transcript-bridge.gitRequires Python 3.10+. Zero runtime dependencies.
To run from source:
python -m transcript_bridge.cli formatstranscript-bridge session.jsonl --from claude_code_jsonl --to openai_messages -o session-openai.jsonThe file output:
[
{
"role": "assistant",
"content": "I'll read that.",
"tool_calls": [
{
"id": "tu_1",
"type": "function",
"function": {
"name": "Read",
"arguments": "{\"file_path\":\"/x\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "tu_1",
"content": "file contents"
}
]The stderr report:
loss report: 1 field(s) could not be represented
- content text cache_control: OpenAI messages have no slot for Anthropic cache_control blocks
transcript-bridge session.jsonl --from claude_code_jsonl --to openai_messages --strict
# exits with code 2 if any loss occurredcat session.jsonl | transcript-bridge - --from claude_code_jsonl --to codextranscript-bridge session.jsonl --from claude_code_jsonl --to gemini -o session-gemini.jsonThe tool calls become functionCall parts and tool results become functionResponse parts. System messages are flattened into systemInstruction. Any cache_control, image blocks, or provider metadata are reported as loss.
transcript-bridge session.jsonl --from claude_code_jsonl --to ollama -o session-ollama.jsonTool calls are written as Ollama tool_calls[].function blocks. Because Ollama function calls have no id field, the canonical id is reported as loss.
transcript-bridge trace-export.jsonl --from langsmith --to claude_code_jsonl -o session-claude.jsonlLangSmith LLM runs are extracted from inputs.messages and outputs.message; child tool runs become canonical role=tool turns. Metadata, tags, latency, scores, and other spans are reported as loss.
transcript-bridge trace.json --from langfuse --to openai_messages -o session-openai.jsonLangfuse GENERATION observations become messages, and nested SPAN tool observations become tool-result turns. modelParameters, token usage, scores, release/version, and metadata are reported as loss.
transcript-bridge formats| Format | Read | Write | Notes |
|---|---|---|---|
claude_code_jsonl |
✅ | ✅ | Anthropic-style content blocks (text, tool_use, tool_result) |
openai_messages |
✅ | ✅ | JSON array of {role, content, tool_calls} messages |
codex |
✅ | ✅ | Codex CLI traces with extra metadata (usage, checkpoint, etc.) |
gemini |
✅ | ✅ | Google Gemini contents[] with functionCall/functionResponse parts |
langsmith |
✅ | ✅ | LangSmith trace export of LLM runs with child tool runs |
langfuse |
✅ | ✅ | Langfuse trace export of GENERATION and SPAN observations |
ollama |
✅ | ✅ | Ollama /api/chat request/response message array |
tape |
✅ | ❌ | agent-vcr wire-level tape (read-only by design) |
Adding a new format means adding a reader + writer module and one registry line.
Derived from the actual readers/writers in transcript_bridge/formats/*.py and canonical.py / loss.py. Rows are canonical fields; columns are target formats. A field is kept when it appears in the target output, loss (reported) when a loss entry is emitted to stderr / _meta.loss, or stashed in _meta when it has no native slot and is preserved only on the canonical turn's _meta (no loss entry, silently dropped from the serialized target text).
| Canonical field | → Claude Code JSONL | → OpenAI messages | → Codex | → Gemini | → LangSmith | → Langfuse | → Ollama | → canonical |
|---|---|---|---|---|---|---|---|---|
cache_control |
kept | loss (reported) | loss (reported)¹ | loss (reported) | loss (reported) | loss (reported) | loss (reported) | kept |
tool_use / tool_calls |
kept | kept | kept | kept⁴ | kept | kept | kept⁵ | kept |
tool_result |
kept | kept | kept | kept | kept | kept | kept | kept |
usage |
stashed in _meta | stashed in _meta | kept (re-emitted)² | loss (reported) | loss (reported) | loss (reported) | loss (reported) | stashed in _meta |
checkpoint / metadata |
stashed in _meta | stashed in _meta | kept (re-emitted)² | loss (reported) | loss (reported) | loss (reported) | stashed in _meta | stashed in _meta |
OpenAI tool name |
loss (reported)³ | kept | kept | stashed in _meta | stashed in _meta | stashed in _meta | stashed in _meta | stashed in _meta |
timestamp |
kept | stashed in _meta | stashed in _meta | stashed in _meta | loss (reported) | loss (reported) | stashed in _meta | kept |
model |
kept | stashed in _meta | stashed in _meta | stashed in _meta | stashed in _meta | stashed in _meta | stashed in _meta | kept |
tool_use.id |
kept | kept | kept | loss (reported) | kept | kept | loss (reported) | kept |
| system turns | kept | kept | kept | loss (flattened)⁶ | kept | kept | kept | kept |
| multimodal images | kept | loss (reported) | loss (reported) | loss (reported) | loss (reported) | loss (reported) | loss (reported) | kept |
| safety / grounding / citations | N/A | N/A | N/A | loss (reported) | N/A | N/A | N/A | N/A |
¹ Codex writing delegates to the OpenAI writer, so cache_control is reported as loss on the OpenAI sub-hop and propagates.
² Codex-only fields ride in _meta.source._codex_extra; the Codex writer re-injects them. They survive a Codex→Codex round-trip but are dropped when the turn is serialized through OpenAI text (the OpenAI writer does not report them as loss — they are stashed, not reported).
³ role itself is always kept; only the OpenAI-specific tool-message name field is reported as loss by the Claude Code writer.
⁴ Gemini functionCall parts carry no id, so the writer reports the canonical tool-use id as loss.
⁵ Ollama tool_calls[].function objects carry no id, so the writer reports the canonical tool-use id as loss.
⁶ Gemini has no system role inside contents[]; system turns are flattened into the top-level systemInstruction and reported as loss.
--strictfails (exit code 2) on any cell marked loss (reported).
A loss entry is created whenever a canonical field has no native slot in the target format:
{
"path": "content[0].cache_control",
"source_format": "claude_code_jsonl",
"target_format": "openai_messages",
"reason": "OpenAI messages have no slot for Anthropic cache_control blocks",
"value": {"type": "ephemeral"}
}| Source field | Target | Outcome |
|---|---|---|
Claude cache_control |
OpenAI / Codex / Gemini / LangSmith / Langfuse / Ollama | Reported as loss |
OpenAI tool name |
Claude Code JSONL | Reported as loss |
Codex usage, checkpoint |
OpenAI messages | Reported as loss (re-emitted when writing back to Codex) |
Gemini inlineData / fileData |
canonical | Reported as loss |
Gemini systemInstruction flattening |
Gemini | Reported as loss |
Gemini safetySettings / grounding / citations |
Gemini | Reported as loss |
| LangSmith metadata / tags / latency / scores / spans | LangSmith | Reported as loss |
Langfuse modelParameters / usage / scores / release / metadata |
Langfuse | Reported as loss |
Ollama images / streaming done metadata |
Ollama | Reported as loss |
Loss entries are printed to stderr and stored in _meta.loss on each canonical turn.
transcript-bridge/
├── transcript_bridge/
│ ├── __init__.py # version + FORMATS registry
│ ├── canonical.py # canonical envelope + JSONL helpers
│ ├── loss.py # loss entry + report formatting
│ ├── cli.py # argparse CLI
│ └── formats/
│ ├── __init__.py
│ ├── claude_code.py # Claude Code JSONL reader/writer
│ ├── openai.py # OpenAI messages reader/writer
│ ├── codex.py # Codex trace reader/writer
│ ├── gemini.py # Gemini contents[] reader/writer
│ ├── langsmith.py # LangSmith trace export reader/writer
│ ├── langfuse.py # Langfuse trace export reader/writer
│ ├── ollama.py # Ollama /api/chat reader/writer
│ └── tape.py # agent-vcr tape reader (write refused)
├── selfcheck.py # round-trip + loss-report verification
├── pyproject.toml # pipx-installable, stdlib only
├── LICENSE # MIT
└── README.md
Source comments marked with # ponytail: are deliberate simplifications.
- Read-only on inputs. Source files are never modified.
- No network calls. The tool works fully offline.
- No telemetry. No hosted backend, no upload, no logging service.
- Local only. All processing happens on the machine that runs the CLI.
In:
- Claude Code JSONL, OpenAI messages, Codex traces, Gemini contents, LangSmith traces, Langfuse traces, Ollama chat
- Canonical JSONL envelope
- Loss reporting with
--strict - CLI:
transcript-bridge <file> --from X --to Y [-o out] [--strict] transcript-bridge formats- One
selfcheck.py, no external test framework - Stdlib only; Python 3.10+
Out:
- Streaming/incremental conversion
- Binary attachment extraction
- Merging multiple runs
- Web UI or hosted service
- Tape compression or rotation
A single runnable check proves round-trip honesty:
python selfcheck.pyIt builds a canonical transcript with one Claude-specific field (cache_control) and one OpenAI-specific field (name on a tool message), then asserts:
- Claude → Claude is lossless.
- Claude → OpenAI reports exactly the
cache_controlloss. - OpenAI → Claude reports the
namefield as loss. - Claude → OpenAI → Claude preserves content except the expected lossy fields.
- Gemini → Gemini round-trips text and function parts, reporting id and flattening loss.
- Ollama → Ollama round-trips messages and tool calls, reporting id loss.
- LangSmith read + write reports metadata, tags, latency, scores, and span loss.
- Langfuse read + write reports
modelParameters, usage, scores, release/version, and metadata loss.
MIT. See LICENSE.
Built to make agent transcripts portable. Convert once, know exactly what made it across.