Skip to main content

Find what breaks your LLM agent's prompt cache and wastes its context — git diff for the context window

Project description

ctxdiff

PyPI npm CI License: Apache 2.0 Python 3.10+ Node 22+ PRs welcome

Find the character that's breaking your agent's prompt cache on every turn — and the tool schemas you pay for on every call but never invoke.

git diff for your agent's context window: see exactly what your LLM saw, turn by turn, block by block.

Pick your language: 🐍 Python · 🟨 JavaScript / TypeScript — same .ctrace format, same CLI, cross-compatible.

ctxdiff is a local-first debugger for the context window of LLM agents. Wrap your OpenAI, Anthropic, Gemini (incl. Vertex AI), or Bedrock client in one line — or hand a callback handler to LangChain/LangGraph — run your agent, and every call's context is recorded — as content-hashed, deduplicated blocks — into a single-file SQLite trace you can inspect, diff, and share. Nothing leaves your machine.

Prompt wording is ~10% of the battle. The other 90% is context engineering — what the model sees, in what order, at what cost. When an agent misbehaves at turn 8, ctxdiff answers the three questions a raw JSON log can't: what exactly did the model see, what changed since turn 7, and what did it cost?

📦 Two SDKs, one format. ctxdiff ships for Python (pip install ctxdiff) and JavaScript/TypeScript (npm i ctxdiff). Both write the same .ctrace file and share the same CLI — a trace captured in one language opens in the other's viewer. Code samples below are tabbed by language (click to switch).

🐍 Python
from ctxdiff import trace
from openai import OpenAI

tracer = trace.init("customer-support-agent")
client = tracer.wrap(OpenAI())          # ← the only line you add

client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "What's your refund window?"}],
)

tracer.close()                          # writes ./customer-support-agent-<id>.ctrace
🟨 JavaScript / TypeScript
import { trace } from "ctxdiff";
import OpenAI from "openai";

const tracer = trace.init("customer-support-agent");
const client = tracer.wrap(new OpenAI());   // ← the only line you add

await client.chat.completions.create({
  model: "gpt-4o",
  messages: [{ role: "user", content: "What's your refund window?" }],
});

tracer.close();                             // writes ./customer-support-agent-<id>.ctrace

Then ctxdiff view opens the self-contained dashboard — here debugging a multi-agent run (researcher + writer), with agent filtering, turn-by-turn diffs, an agent handoff, and light/dark themes:

ctxdiff dashboard — a multi-agent run: agent chips and filtering, git-style turn diffs, token allocation, cache-break attribution, light and dark themes

See it in 30 seconds — no API key, no setup:

pip install ctxdiff && ctxdiff demo        # Python
npm  i       ctxdiff && npx ctxdiff demo    # JavaScript / TypeScript

demo builds a sample multi-agent trace and opens this dashboard — a realistic research-pipeline run (two agents, real SDK shapes, zero network) already showing turn diffs, token/schema-bloat, a cache-prefix break, and an agent hand-off. Both SDKs produce a byte-identical dashboard.

Jump to: How it's different · Features · Install · Quickstart · The CLI · HTML dashboard · Storage backends · Supported providers · How it works · Usage guide · Provider recipes · The .ctrace format · Design principles · Roadmap · Development


How it's different

ctxdiff isn't a tracing platform. Tools like LangSmith, Langfuse, and Phoenix show you a list of calls — a snapshot of each request, aggregated across your fleet, for monitoring cost and health over time. That's a different job.

ctxdiff shows you the diff between calls — what got added, evicted, or modified in the context window, turn by turn, down to the character that broke your prompt-cache prefix. It's the debugger you reach for when one specific run went wrong at turn 8, not the dashboard you watch in production.

Observability platforms ctxdiff
Question Is my fleet healthy & cheap over time? Why did this run break at turn 8?
Shows you A list of calls (snapshot) The diff between calls (delta)
Scope Many runs, aggregated One run, turn by turn
Runs Hosted service / self-hosted server Local — one pip install, one file
Your data Sent to a platform Never leaves your machine

It's built to sit alongside your observability stack, not replace it. Use them for "is production okay"; use ctxdiff for "what exactly did the model see, and what changed."


Features

What ctxdiff does (each link jumps to the details):

  • 🔌 One-line capturetracer.wrap(client) records every LLM call's full context, verbatim, into a single-file SQLite .ctrace. Fail-open by design: a ctxdiff error can never break your app.

  • 🧬 Content-hashed block storage — every message, content part, and tool schema is a deduplicated block; a stable system prompt across 40 turns is stored once.

  • 🌐 Eight provider surfaces — OpenAI, Azure OpenAI, Anthropic, Google Gemini (AI Studio and Vertex AI), AWS Bedrock (Converse, streaming included), any OpenAI-compatible OSS endpoint (Ollama/vLLM/…), and LangChain/LangGraph via a callback handler.

  • 🟩🟥🟨 Git-style turn diffingctxdiff diff --turn 7 --turn 8: exactly which blocks were added, evicted, or modified (with char-level inline diffs) between any two turns.

  • 📊 Token attributionctxdiff tokens: where the budget goes per turn (system / rag / history / schemas…), reconciled against provider-reported usage, plus schema-bloat detection — tools you registered but never call, taxing every request.

  • 💸 Prompt-cache profilingctxdiff cache: finds exactly what breaks your cache prefix (down to the changed characters), counts re-billed tokens, and suggests the fix.

  • 📐 Percent of the context window18,400 / 200,000 tok · 9.2%, with a past 80%. Proximity to the limit is what causes the silent truncation you are debugging; the window is yours to state, because ctxdiff ships no model→window table it could get wrong.

  • 🧠 Evicted tagged blocks"the block you tagged rag at turn 3 was evicted at turn 6". The single most common root cause of "the agent forgot the thing I told it", named outright.

  • 🚦 Context budgets in CIctxdiff check --max-context 8000 --require-stable-prefix --no-dead-schemas: assert the budget, exit non-zero when it regresses. Ships as a GitHub Action that posts the PASS/FAIL table to the job summary — so context size becomes a tracked metric on every pull request, not something you remember to look at.

  • 🖥️ Self-contained HTML dashboardctxdiff view / ctxdiff export: a one-file, zero-external-request, three-level dashboard — every agent in the project, then that agent's sessions (in your local timezone), then its turn-by-turn scrubber, diff panel, token heatmap, cache findings and block inspector — safe to attach to a bug ticket.

  • 🤖 MCP serverctxdiff mcp: the coding agent in Claude Code / Cursor reads your traces itself. Six tools over stdio, ctxdiff_explain(run, turn) answering "why did my agent break at turn 8" in one call — the same analyzers the CLI uses, returning compact JSON under a hard size cap. Ships with a --redact mode, because this is the one ctxdiff feature that hands your prompts to someone else's model.

  • 🦜 LangChain & LangGraph, nativelycallbacks=[tracer.langchain_handler()], and every chat-model call in a graph is captured, whatever the provider. The blocks it records are hash-identical to wrapping that provider's SDK directly, so a LangChain trace and a direct trace of the same prompt dedup against each other instead of looking like two unrelated contexts — images included, and across SDKs except for a tool call's arguments (why).

  • 🏷️ Semantic taggingtracer.tag("rag", chunks) for exact provenance labels; a cheap heuristic covers the rest.

  • 🤝 Multi-agent runstracer.wrap(client, agent="researcher") and tracer.mark("step") attribute every call to the agent (and step) that made it; --agent filters on diff/tokens/cache/check, and the dashboard colors each agent's turns. Cross-agent hand-offs are never miscounted as cache breaks.

  • 🗄️ Pluggable storage — local-first .ctrace by default; ctxdiff.configure(store=PostgresStore(dsn=...)) (or CTXDIFF_STORE=…) once, and every run lands in your PostgreSQL/MySQL instead. Tables auto-create, drivers are optional extras, and a dead database degrades capture without ever touching your agent.

  • 🔒 Privacy first — local-first (no network, no telemetry), a redaction hook that runs before anything touches disk, and HTML exports that strip request params down to the model name.

  • Honest numbers — exact tiktoken counts for OpenAI; estimates are always marked as estimates, never passed off as precise.

  • 🧵 Concurrency-safe — parallel tool calls, asyncio.gather fan-outs and thread pools each keep their own tags and step labels, so nothing is mislabelled; writes leave the call path on a single background writer, so capture costs your agent almost nothing.

  • 🗄️ Your database, optionally — a local single-file .ctrace by default; point it at Postgres or MySQL with one configure() call and it creates its own tables and writes there instead. Local-first stays the default.

What it doesn't do (yet):

  • Live tail — the dashboard is post-run; it doesn't update while the agent is still running.

See The CLI below for every subcommand, with real sample output.


Install

🐍 Python (≥ 3.10)
pip install ctxdiff

The only runtime dependency is tiktoken (for exact OpenAI token counts). The provider SDKs (openai, anthropic, …) are not dependencies — ctxdiff wraps whatever client you already use.

Install from source, or with the real-SDK eval extra:

git clone https://github.com/salmanzafar949/ctxdiff && cd ctxdiff && pip install -e .
pip install -e ".[eval]"   # openai, anthropic, google-genai, boto3, langchain, langgraph, respx — for tests only

Storage is a local .ctrace file by default, with nothing extra to install. To keep traces in a database you already run, add the matching extra — see Storage backends:

pip install 'ctxdiff[postgres]'   # psycopg 3
pip install 'ctxdiff[mysql]'      # PyMySQL

To let a coding agent read your traces over MCP — see MCP server:

pip install 'ctxdiff[mcp]'        # the official MCP Python SDK
🟨 JavaScript / TypeScript (Node ≥ 22)
npm i ctxdiff

Requires Node ≥ 22 (uses the built-in node:sqlite). The only runtime dependency is a pure-JS tokenizer (gpt-tokenizer) for exact OpenAI token counts. The provider SDKs (openai, @anthropic-ai/sdk, @google/genai) are optional peer dependenciesctxdiff wraps whatever client you already use and never imports them itself. The .ctrace it writes opens in the Python ctxdiff view, and vice-versa.


Quickstart

Wrap a client, use it exactly as you normally would, then read the trace back.

🐍 Python
from ctxdiff import trace
from ctxdiff.store.ctrace import CTrace
from openai import OpenAI

# 1. Start a trace and wrap your client
tracer = trace.init("support-agent")
client = tracer.wrap(OpenAI())

# 2. Use the client normally — every call is recorded
client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "You are a support agent. Be precise."},
        {"role": "user", "content": "What's your refund window?"},
    ],
)
tracer.close()

# 3. Read the trace back
ct = CTrace.open(tracer.path)
for call in ct.get_calls():
    print(f"turn {call.seq}  usage={call.usage}")
    for cb in ct.get_call_blocks(call.id):
        b = cb.block
        print(f"  [{cb.label:<11}] {b.role:<9} {b.token_count:>4} tok  {b.text[:60]!r}")
ct.close()
🟨 JavaScript / TypeScript
import { trace, CTrace } from "ctxdiff";
import OpenAI from "openai";

// 1. Start a trace and wrap your client
const tracer = trace.init("support-agent");
const client = tracer.wrap(new OpenAI());

// 2. Use the client normally — every call is recorded
await client.chat.completions.create({
  model: "gpt-4o",
  messages: [
    { role: "system", content: "You are a support agent. Be precise." },
    { role: "user", content: "What's your refund window?" },
  ],
});
tracer.close();

// 3. Read the trace back
const ct = CTrace.open(tracer.path);
for (const call of ct.getCalls()) {
  console.log(`turn ${call.seq}  usage=`, call.usage);
  for (const cb of ct.getCallBlocks(call.id)) {
    const b = cb.block;
    console.log(`  [${cb.label}] ${b.role} ${b.tokenCount} tok  ${JSON.stringify(b.text.slice(0, 60))}`);
  }
}
ct.close();

Either way, turn 1 records the same blocks (.ctrace files are cross-compatible):

turn 1  usage={'prompt_tokens': 24, 'completion_tokens': 8, 'total_tokens': 32}
  [system     ] system       9 tok  'You are a support agent. Be precise.'
  [user       ] user         5 tok  "What's your refund window?"

A .ctrace is just a SQLite file — you can also inspect it with any SQLite tool:

sqlite3 support-agent-*.ctrace "SELECT seq, usage FROM call ORDER BY seq;"

The CLI

🐍 Python: ctxdiff <command>  ·  🟨 JavaScript: npx ctxdiff <command> — same commands, same flags, byte-identical output. The examples below use the Python form; prefix with npx for the JS SDK. Either CLI reads any .ctrace, no matter which SDK wrote it.

What "byte-identical" covers: every command's output — stdout, the operational errors on stderr, the selector errors (no session …, no agent …, the ambiguity listings) and the exit code — for the same trace and the same TZ. It does not cover argparse's help/usage chrome: --help text and the usage: … block Python prints above a bad-flag message have no JS equivalent, so those errors match on exit code 2 and on the error: … line's substance rather than byte for byte. The JS CLI also accepts a leading positional .ctrace path as an alias for --project, which the Python CLI does not — an addition, never a difference in what either prints.

Every subcommand reads one session out of one project. Four selectors say which, and every analysis command takes the same four:

Selector Means Default
--project PATH|DSN which project DB — a .ctrace path or a database DSN the configured store, else the most recently modified *.ctrace in the cwd
--session ID which session in it (an id, or any unambiguous prefix of one) the only session — required when the project holds several (except export/view, see below)
--agent NAME scope the analysis to one agent all agents
--turn N a specific turn (call seq) every turn

--run is still accepted everywhere as an alias for --project, so existing scripts keep working.

Ambiguity is never guessed at. One session in the project and you need no flag; several, and the analysis command stops with a usage error (exit 2) that lists the sessions so you can pick one — quietly analyzing "the newest" would give you a confidently wrong answer about a run you weren't asking about. The same rule applies to --agent: a name that matches nobody is a bad flag, not an empty report.

export and view are the one exception, because the dashboard covers the whole project rather than analyzing one session: they never require --session, and use the selectors to pick which of the dashboard's three levels to open on.

$ ctxdiff tokens
ctxdiff: this project holds 2 sessions — pass --session to pick one:
  4f3a2b1c9d8e  2026-07-20 13:15:00 +04:00  turns=8   agents=researcher, writer
  9e8d7c6b5a4f  2026-07-21 22:42:30 +04:00  turns=11  agents=researcher, writer

Timestamps are stored UTC and displayed in your local timezone, with the offset shown — identical in both SDKs for the same stored value.

Color is automatic (git-style ANSI) and turns off whenever stdout isn't a real terminal, or when NO_COLOR is set — the output below has NO_COLOR=1 so it pastes cleanly.

ctxdiff diff --turn N --turn M

Git-style block diff between two turns: added (+, green), evicted (, red), and modified (~, yellow, with an inline char-level diff) blocks, with unchanged blocks folded into one summary line.

$ ctxdiff diff --turn 1 --turn 2
── turn 1 → turn 2 · 3 blocks changed · +56 −26 tokens ──
~ [system·system] You are a support agent. Be precise. Current time: 2026-07-24T10:00:0[-0-]{+4+}Z
+ [history·assistant] 'Checking that for you.'  +5 tok
+ [rag·user] 'Context: Refund policy: 30 days from delivery, unworn items only. Also…'  +25 tok
= 3 unchanged blocks · 138 tok

ctxdiff tokens [--turn N] [--context-window N]

Token allocation per turn as a proportional bar chart, one label slice per row, biggest spender first; reconciles against provider-reported usage when available (Δ line); shows each turn's share of the context window when you state one; names any tagged block that was evicted; appends a schema-bloat warning when a registered tool schema is never invoked anywhere in the run.

$ ctxdiff tokens
turn 1 · 164 tokens
  ████████████████████████       tool_schema       133 tok   81.1%
  █████                          system             26 tok   15.9%
  █                              user                5 tok    3.0%
  provider reports 55 prompt tokens · Δ -109

turn 3 · 255 tokens
  ████████████████               tool_schema       133 tok   52.2%
  ██████                         history            50 tok   19.6%
  ████                           user               32 tok   12.5%
  ███                            system             26 tok   10.2%
  ██                             tool_output        14 tok    5.5%
  provider reports 85 prompt tokens · Δ -170

⚠ schema bloat: issue_refund — 1 of 2 registered tools never used this run — 77 tok (37.7% of avg context) spent on dead schemas every call

A call whose total mixes any estimate-method blocks in with exact ones is marked (~approx) next to its token total — never presented as exact when it isn't.

Percent of the context window

18,400 tok means nothing on its own. 18,400 / 200,000 — 9% means you have room; 156,000 / 200,000 — 78% means the next tool result is going to push something out of the window and your agent is about to "forget" it. So state the window and every turn header becomes a share of it:

$ ctxdiff tokens --context-window 200000
turn 12 · 18,400 / 200,000 tok · 9.2%
  ...
turn 31 · 164,000 / 200,000 tok · ⚠ 82.0%
  ...

The appears at 80% and above. Not because 80% is a failure — the provider would error out if you actually exceeded the window, and you would know — but because that is where the silent failures start: a framework's sliding-window trimmer, a max_tokens reservation for the response, one large tool result arriving next turn. Past 80% the headroom is smaller than a typical tool output, so the next turn is where content begins disappearing. The marker is compared against the percentage as printed, so a turn shown as 80.0% is always marked and one shown as 79.9% never is.

Why you state the window. ctxdiff ships no model→context-window table, deliberately — the same reason it ships no price table. Those numbers differ per model, per provider and per deployment, and they change under you; a stale one baked into a released version does not degrade, it lies. So the window comes from you, one of two ways:

ctxdiff tokens --context-window 200000        # this invocation
export CTXDIFF_CONTEXT_WINDOW=200000          # every invocation, including the dashboard

The flag wins when both are set. ctxdiff tokens, ctxdiff check, ctxdiff view and ctxdiff export all resolve the window through the same path, so a CI gate and the report a human reads beside it can never be scored against two different windows — and all four refuse a window of zero or less (ctxdiff: --context-window must be greater than 0, exit 2) rather than render a percentage of something that is not a window. With neither set, nothing changes: every command prints exactly the bytes it printed before percentages existed. (~approx) still applies — a share computed from a partly-estimated total is exactly as approximate as the total.

Evicted tagged blocks

"The agent forgot the thing I told it" almost always has one mechanical cause: a block that was in the context is not in it any more. ctxdiff already classifies that as an eviction, and tracer.tag() already records which blocks you considered load-bearing — so ctxdiff tokens names the join outright:

⚠ the block you tagged 'rag' at turn 3 was evicted at turn 6
  'Context: Refund policy: 30 days from delivery, unworn items only. Also…'
  [rag·user] 1,240 tok · entered at turn 3 · last present at turn 5 · never returned

You tag it once. tracer.tag() applies to the next recorded call only — so a block you tagged on turn 3 is stored tagged on turn 3 and heuristically labeled on every later turn that still carries the same text. The report follows the content, not the call: if you ever vouched for that text anywhere in an agent's timeline, it stays vouched-for until it leaves. Without that, nothing would ever be reported except a block evicted the very turn after it was tagged. The headline quotes the turn the tag was applied; the facts line quotes the turn the content entered, which is the same turn unless you tagged something that was already there.

Four things it will not say, each on purpose:

Not reported Why
heuristically-labeled blocks Only tracer.tag()ed blocks count. Every multi-turn agent evicts ordinary history — that is what a context window is — so including it would bury the one line worth reading under the intended behaviour of every framework there is.
a hand-off between agents Pairing is per agent, the same rule the cache profiler uses. The researcher's block is "missing" from the writer's next call because it was never in the writer's context.
a block that comes back Absent for one turn and back the next was crowded out, not forgotten. A block that leaves twice is reported once, for the departure it did not return from.
a block whose text was edited A same-slot content change is modified, not evicted — whatever ctxdiff diff calls it, this report calls it. (This is the one blind spot: a tagged block swapped in place for different text of the same role reads as an edit.)

The same finding is available as a CI assertion — ctxdiff check --no-tagged-eviction — and as a panel in the dashboard.

ctxdiff cache

Prefix-stability report across every consecutive turn pair: finds where the provider's byte-for-byte cache prefix breaks, attributes it to the responsible block, and estimates the wasted re-billed spend — price-free, since per-token discounts vary by provider and change over time.

$ ctxdiff cache
⚠ warning: [system·modified] breaks the prefix on every turn (2/2 pairs)
  'You are a support agent. Be precise. Current time: 2026-07-24T10:00:04Z'
  modified system block — first difference at char 69: '0' → '4'

stable prefix (min): 133 tokens
re-billed: 183 tokens
183 tokens re-billed across 2 turns that a stable prefix would have served from cache (cached input is typically billed at a fraction of the full input price — check your provider's current rates)
hint: a dynamic value inside an early system block breaks the prefix every turn — move volatile content below the stable blocks

A run with a stable prefix throughout prints a single green ✓ prefix stable across all N turn pairs line instead.

ctxdiff check

The CI gate. Everything above is something you run and read; check is the same analysis with a threshold attached and a non-zero exit when it's crossed — so a context regression fails the build instead of waiting to be noticed.

ctxdiff check --max-context 8000 --require-stable-prefix --no-dead-schemas
npx ctxdiff check --max-context 8000 --require-stable-prefix --no-dead-schemas
$ ctxdiff check --max-context 8000 --require-stable-prefix --no-dead-schemas
ctxdiff check · 6 turns · my-agent.ctrace (session 4f3a2b1c9d8e)
PASS  max-context            peak 7,214 tok at turn 4 · limit 8,000
FAIL  require-stable-prefix  2 breaks across 5 turn pairs · 1,482 tok re-billed
  turn 1 → turn 2 [agent:researcher] [system·modified] breaks 2/2 pairs — modified system block — first difference at char 39: '31' → '48'
FAIL  no-dead-schemas        1 of 2 registered tools never used · 220 tok/call (35.2% of avg context)
  tool schema 'deploy_to_production' registered but never invoked

check FAILED · 2 of 3 assertions failed
$ echo $?
1

Every assertion is opt-in, and check with none is a usage error (exit 2) rather than a pass — a gate that verified nothing must never be the thing keeping a build green.

Assertion Fails when
--max-context N any turn's context exceeds N tokens
--max-context-pct P + --context-window N any turn exceeds P% of a context window you state (see below)
--require-stable-prefix the prompt-cache prefix breaks anywhere in the run
--no-dead-schemas a tool schema is registered but never invoked
--no-tagged-eviction a block you tagged entered an agent's context and later left it for good
--max-growth N the context grows by more than N tokens between two consecutive turns of the same agent
--max-growth-pct P …or by more than P%

Plus the same --project / --session / --agent selectors as every other command, so one workflow can hold each agent to its own budget.

Exit codes: 0 every requested assertion passed · 1 at least one was violated (or there was no trace to read) · 2 a usage error. The same convention the other subcommands follow.

No trace is a failure, not a pass. ctxdiff check in a directory with no .ctrace exits 1. The day capture silently breaks, a gate that greened on an absent trace would keep the build green forever.

Why you supply the context window. ctxdiff ships no model→window table, on purpose: windows differ per model and per provider and change under you, and a stale table baked into a released version would silently move everybody's threshold. So --max-context-pct needs a window from you — --context-window N, or CTXDIFF_CONTEXT_WINDOW in the job's environment, resolved by the same path ctxdiff tokens uses so a red build and the report you read next to it are never scored against two different windows. (A --context-window you typed and nothing reads is still a usage error; an inherited environment variable is not — failing a --max-context check because the shell happens to know the window would be absurd.) The check reports both the percentage and the token budget it works out to:

$ ctxdiff check --context-window 128000 --max-context-pct 25
ctxdiff check · 3 turns · session 4f3a2b1c9d8e
FAIL  max-context-pct        1 turn over limit · peak 27.0% of 128,000 tok window at turn 3 · limit 25.0% (32,000 tok)
  turn 3 [agent:solo] · 34,560 tok · 27.0% of 128,000 tok window · limit 25.0% (32,000 tok)

check FAILED · 1 of 1 assertion failed

Tagged evictions in CI. --no-tagged-eviction is the eviction report with a threshold of zero — same analyzer, same sentence, same per-agent scoping, so a red build and a hand-run ctxdiff tokens always tell one story:

$ ctxdiff check --no-tagged-eviction
ctxdiff check · 5 turns · session 4f3a2b1c9d8e
FAIL  no-tagged-eviction     1 tagged block evicted of 3 across 4 turn pairs
  the block you tagged 'rag' at turn 3 [agent:researcher] was evicted at turn 6 · 1,240 tok

check FAILED · 1 of 1 assertion failed

It reports three different PASSes, because "PASS" here has three meanings and only one is reassuring: no tagged blocks in this run — nothing to lose (go add a tracer.tag(); the assertion measured nothing), fewer than 2 turns — no pairs to check, and all 3 tagged blocks survived 4 turn pairs.

Which number is "the turn's context". --max-context compares against the same total ctxdiff tokens prints as turn N · X tokens — the sum of that call's stored block tokens — not the provider's reported prompt count. It's present for every call (provider usage is optional, and a threshold that silently skips unreported turns is a check that passes by not looking), and it's the number you'll compare the failure against. A turn whose total mixes in estimated blocks is marked (~approx) — on the PASS lines too, since a high-water mark quoted without it reads as a measurement.

A floor is never certified. Some blocks cost real tokens ctxdiff cannot know: an image passed as a remote URL (never fetched) or a file_id, or in a format the sniffer doesn't recognize. Those are stored as zero tokens rather than a fabricated guess, which makes that turn's total a lower bound. Comparing a lower bound to a budget has exactly one possible wrong answer — a silent PASS — so check refuses the comparison and fails instead:

ctxdiff check · 2 turns · my-agent.ctrace (session 4f3a2b1c9d8e)
FAIL  max-context            2 turns unmeasured · peak 8 tok (~approx) at turn 2 · limit 500
  turn 1 · 4 tok (~approx) · 1 block of unknown token cost — a floor, not a measurement · limit 500
  turn 2 · 8 tok (~approx) · 2 blocks of unknown token cost — a floor, not a measurement · limit 500

(Those two turns really cost 800 and 1,600 tokens — the provider said so in usage, which ctxdiff tokens shows as a Δ.) The fix is on the capture side: send the image as data rather than a URL, and the cost becomes knowable.

The report says what it read. With no --project, ctxdiff reads the most recently modified *.ctrace in the working directory — so the header names that file and the session, and a green check can be audited rather than taken on trust.

Growth is measured within an agent, never across a hand-off — the same grouping rule the cache profiler uses. On an interleaved multi-agent timeline, researcher → writer is not a 300-token explosion; it's two independent contexts.

check is a threshold layer over the analyzers you already read — it calls the same tokens and cache code paths — so a red build and a hand-run report can never tell two different stories. (Pinned by the golden corpus: both SDKs must reproduce the same report and the same exit code.)

Next: wire it into a GitHub workflow.

Two more diff shapes fall out of the selectors, both reusing the exact same differ:

Cross-session — the regression case. Same agent, same turn, two runs:

$ ctxdiff diff --session 4f3a2b1c9d8e:8 --session 9e8d7c6b5a4f:8 --agent researcher
── 4f3a2b1c9d8e · researcher · turn 8  →  9e8d7c6b5a4f · researcher · turn 8 ──
── turn 8 → turn 8 · 1 blocks changed · +17 −18 tokens ──
~ [user·user] More detail ([-goo-]{+ba+}d)?
= 3 unchanged blocks · 45 tok

(--turn 8 once, instead of the :8 suffixes, means turn 8 on both sides.)

Cross-agent — two agents inside one session, when you want to know why the writer sees a different context than the researcher:

$ ctxdiff diff --session 4f3a2b1c9d8e --agent researcher:1 --agent writer:2
── researcher · turn 1  →  writer · turn 2 ──
── turn 1 → turn 2 · 2 blocks changed · +30 −33 tokens ──

The scope header appears only for those two shapes — an ordinary same-session diff is unchanged, since turn 7 → turn 8 already says everything.

ctxdiff sessions

The session picker's data source: what --session can be set to. One row per session with its local start time, project, provider, turn count and agents. With no --project it scans every *.ctrace in the working directory (discovery is the one job where narrowing to the newest file would defeat the purpose); with a database configured it lists that store's sessions.

$ ctxdiff sessions
support-agent.ctrace          2026-07-19 11:02:14 +04:00  project=support-agent  provider=openai  turns=4   agents=-
pipeline.ctrace#4f3a2b1c9d8e  2026-07-20 13:15:00 +04:00  project=pipeline       provider=openai  turns=8   agents=researcher, writer
pipeline.ctrace#9e8d7c6b5a4f  2026-07-21 22:42:30 +04:00  project=pipeline       provider=openai  turns=11  agents=researcher, writer

A file holding one session is labeled by its bare filename; one holding several gets a #<session id> suffix per row, so every row names something you can select. ctxdiff runs is kept as a hidden alias and behaves identically.

ctxdiff agents

Every agent in the project, aggregated across all its sessions — because "how much does the researcher cost" is a question about the project, not about whichever run happens to be newest. tokens is the provider-reported total (input + output), or - when none of that agent's calls reported usage.

$ ctxdiff agents --project pipeline.ctrace
researcher  sessions=2  calls=12  tokens=48,210
writer      sessions=2  calls=7   tokens=19,884

ctxdiff export [--out FILE.html] / ctxdiff view [--no-open]

Write (export) or write-and-open (view) the self-contained HTML dashboard — see HTML dashboard below.

Both cover the whole project — every agent, every session — so neither needs --session even when the project holds many runs. The two selectors instead preselect which level the page opens on:

ctxdiff view                                  # all agents (level 1)
ctxdiff view --agent researcher               # that agent's sessions (level 2)
ctxdiff view --session 4f3a2b1c9d8e           # that session's turns (level 3)
ctxdiff view --agent researcher --session 4f3a2b1c  # that agent's turns in that run

ctxdiff mcp [--runs-dir DIR] [--redact]

Python only, needs pip install 'ctxdiff[mcp]'. Serve the analyzers to a coding agent over the Model Context Protocol on stdio — you don't run this by hand, your MCP client does. See MCP server for the client config, the six tools, and the two security notes. Without the extra installed it prints a one-line install hint and exits 1.


GitHub Action

Context budget as a tracked metric. Your agent's tests already run on every pull request. Point ctxdiff at that run and the context window becomes something CI watches — the same way it watches coverage — instead of something you profile after a bill arrives.

The shape is two steps: your tests produce a .ctrace, then the action asserts the budget.

# .github/workflows/context-budget.yml
name: context budget

on: [pull_request]

jobs:
  budget:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      # 1. Run your agent's tests. Anything that exercises the agent works —
      #    the point is that `tracer.wrap(client)` is already in the code path,
      #    so the run leaves a .ctrace behind. Replay/VCR fixtures mean no API
      #    keys and no flakiness; a nightly job can run it live instead.
      - name: Run the agent's tests (writes ./my-agent.ctrace)
        run: |
          pip install -e .
          pytest tests/test_agent.py

      # 2. Assert the budget. Non-zero exit fails the job.
      - name: Assert the context budget
        uses: salmanzafar949/ctxdiff@v1
        with:
          project: my-agent.ctrace
          max-context: 8000
          require-stable-prefix: true
          no-dead-schemas: true
          no-tagged-eviction: true

That's the whole thing. The action installs ctxdiff, runs ctxdiff check, writes the PASS/FAIL table to the job summary, and exits with the check's own status.

Inputs

Input Maps to Default
project --project — a .ctrace path or a database DSN newest *.ctrace in working-directory
session --session the only session
agent --agent all agents
max-context --max-context N (off)
context-window --context-window N (off)
max-context-pct --max-context-pct P (off)
require-stable-prefix --require-stable-prefix false
no-dead-schemas --no-dead-schemas false
no-tagged-eviction --no-tagged-eviction false
max-growth --max-growth N (off)
max-growth-pct --max-growth-pct P (off)
runtime python (pip) or node (npx) — byte-identical output either way python
version exact ctxdiff version to install latest
working-directory where the .ctrace was written .
summary write the table to $GITHUB_STEP_SUMMARY true

Outputs: passed ("true"/"false"), exit-code, and report (the full text).

Every assertion input defaults to off, so uses: salmanzafar949/ctxdiff@v1 with no with: block fails with a usage error rather than silently enforcing a budget nobody configured.

The two boolean inputs accept true or false and nothing else. YAML has many spellings of yes, but only an unquoted true/false is resolved to a boolean by the workflow parser — require-stable-prefix: yes arrives at the action as the string "yes". Rather than treat that as "not true" and quietly drop the assertion (a check that asserts less than you wrote, exits 0, and stays green forever), the action fails the step and names the value it could not read.

Gating each agent separately

--agent is a real selector, so one job can hold a cheap writer and an expensive researcher to different budgets:

      - name: Researcher budget
        uses: salmanzafar949/ctxdiff@v1
        with:
          project: pipeline.ctrace
          agent: researcher
          max-context: 24000
          require-stable-prefix: true

      - name: Writer budget
        uses: salmanzafar949/ctxdiff@v1
        with:
          project: pipeline.ctrace
          agent: writer
          max-context: 4000
          max-growth: 800

Job summary, not a PR comment

The check table goes to $GITHUB_STEP_SUMMARY, and the action requests no token and no permissions: block at all. That is a deliberate choice over posting a PR comment:

  • A comment needs pull-requests: write. Many organizations set the default GITHUB_TOKEN to read-only, and on pull requests from forks it is read-only regardless of repository settings — so comment-based reporting silently stops working on exactly the PRs an outside contributor opens. A gate whose reporting depends on repo settings you may not control is not unattended CI.
  • The build is already red, and GitHub already links the failing job. The summary is one click from the PR's checks list, renders on every run including forks, needs no API call (no rate limit, no third-party action in your supply chain), and doesn't add a notification to every push.

A failure additionally emits a ::error:: annotation, so it shows inline in the Actions UI rather than only inside the log.

If you do want a comment, the report output is there and you can post it with your own token — which keeps the write permission in your workflow, where you can see it, instead of inside a third-party action:

      - name: Assert the context budget
        id: budget
        uses: salmanzafar949/ctxdiff@v1
        continue-on-error: true
        with: { project: my-agent.ctrace, max-context: 8000 }

      - if: steps.budget.outputs.passed == 'false'
        uses: peter-evans/create-or-update-comment@v4
        with:
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            ~~~~
            ${{ steps.budget.outputs.report }}
            ~~~~

The fence is tildes, not backticks, and that is not a style choice. The report quotes captured text — a tool schema's name, a snippet from a prompt — which on a fork pull request is written by an outside contributor. A backtick fence is closed by any line inside it that starts a long-enough run of backticks, and everything after that renders as markdown in your comment, including a forged "✅ check passed". A tilde fence can only be closed by tildes, so nothing the report can contain will break out of it. (The job summary does the same thing by measuring the longest backtick run and opening a longer fence; a static YAML template cannot measure, so it uses a delimiter the content does not use.)

Not on GitHub?

ctxdiff check is just a CLI with an exit code — the action adds installation and a summary, nothing else. Any CI runs it in one line:

ctxdiff check --project my-agent.ctrace --max-context 8000 --require-stable-prefix

HTML dashboard

ctxdiff view opens a local time-travel dashboard in your browser; ctxdiff export --out run.html writes the same dashboard to a path you choose, without opening anything — the one you attach to a bug ticket. Both call the same exporter, so they're always in sync.

The output is one self-contained .html file: the page, styles, script, and the run data are embedded in a single JSON island — no CDN, no font, no image, no external request of any kind (asserted in tests: the file contains no http:///https:// substring anywhere). It opens from a file:// URL, works offline, and is safe to email or attach to an issue tracker.

Three levels: agents → sessions → turns

The dashboard is agent-first, because "which agent" is the question you actually have when you open a project that holds many runs.

Level What it lists Click a row to
1 — Agents every agent in the project, aggregated across all its sessions: how many sessions it ran in, how many calls it made, its provider-reported token spend, and when it was first and last seen drill to that agent's sessions
2 — Sessions every session that agent appeared in, newest first, with the session's local start time, that agent's turns in it (2 / 3), its spend, and the model drill to that session's turns
3 — Turns the turn-by-turn detail, scoped to the chosen agent within the chosen session inspect a turn

A breadcrumb (all agents › researcher › 2026-07-21 22:42:30 +04:00) walks back up, and the agent chips at level 3 change or clear the scope, so a session's full multi-agent timeline is always one click away.

A single-agent, single-session project opens straight on level 3 — the common case never clicks twice, and the single-run dashboard is exactly what it always was.

Level 3 is the original seven panels, all reading from the same precomputed analyzer output the CLI uses (one source of truth — the dashboard never re-implements diff/token/cache logic in JavaScript):

  • Scrubber — a turn-by-turn strip across the top; click a bar or use ← → to jump between turns. Scoped to the selected agent, so the arrows walk that agent's timeline.
  • Turn diff — the selected turn's added/evicted/modified blocks vs. the previous turn (an agent hand-off diffs against that agent's own previous turn instead).
  • Token allocation — the selected turn's label breakdown, same data as ctxdiff tokens; its heading shows the turn's share of the context window when one is known, and any tagged block that was evicted is called out beneath it.
  • Cache alignment — every prefix break found across the run, same data as ctxdiff cache.
  • Blocks — the full block list for the selected turn (role, kind, label, token count, an 8-char content-hash prefix).
  • Growth — context size across turns, so a run that balloons is visible at a glance.
  • Header stats — project, provider, session start time, distinct-vs-total block counts (the dedup story), and — with a context window supplied — the session's peak turn as a share of it (the peak, not the sum: no window ever had to hold ten turns at once, but the biggest single turn is the one that gets truncated).

Timestamps are converted in your browser

Every timestamp is stored in UTC and rendered in the viewer's local timezone with the offset shown (2026-07-21 22:42:30 +04:00), at render time — never baked in at export. The file is meant to be shared, so two people in two timezones see the same bytes describe their own wall clock. It matches ctxdiff sessions' local-time column exactly.

What gets embedded (and the cap)

A project database can hold thousands of sessions, and the artifact has to stay one file with no network — so the two halves are treated differently:

  • Levels 1 and 2 are never capped. They are aggregates computed from the session list and each session's call rows alone (no block reads), so every session and every agent in the project is always listed, however large the database.
  • Level 3 detail is embedded for the 25 most recent sessions, plus whichever session --session names. That is the part with real size. Older sessions still appear at level 2 with all their totals, marked detail not embedded, and the page names the cap. Re-export with --session <id> to drill into one of them.

Security

All trace-derived text — block text, agent names, session labels, provider and model strings — is written into the JSON island and rendered with textContent at view time, never innerHTML, so a captured block (or an agent named </script><img onerror=...>) can never execute or break out of the tag, even though it's shown verbatim. The one deliberate redaction on export: each call's stored params is reduced to {"model": ...} — sampling settings, API keys, or anything else that might have ridden along in params never makes it into the shareable file (block text redaction is still governed by your own redact() hook, applied earlier at capture time).


MCP server

Python only for now — pip install 'ctxdiff[mcp]'.

The agent debugging the agent. ctxdiff mcp speaks the Model Context Protocol over stdio, so the coding agent already sitting in Claude Code, Cursor or any other MCP client can read your .ctrace files itself:

you: my research agent starts hallucinating citations around turn 8 — have a look

the agent: (calls ctxdiff_runs, then ctxdiff_explain(run="research.ctrace", turn=8)) the block you tagged rag at turn 3 was evicted at turn 8 — 1,240 tokens of retrieved sources left the context window one turn before the hallucination. Turn 8 also grew 3,100 tokens from tool output…

Setup

// Claude Code: .mcp.json  ·  Cursor: .cursor/mcp.json  ·  (same shape elsewhere)
{
  "mcpServers": {
    "ctxdiff": {
      "command": "python",
      "args": ["-m", "ctxdiff", "mcp", "--runs-dir", "/abs/path/to/your/traces"]
    }
  }
}

--runs-dir matters more than it looks: an MCP server's working directory is whatever the client launched it from — an editor's install root, /, your home directory — so ctxdiff's usual "the newest *.ctrace here" default is meaningless there. Point it at the directory your traces land in. If you use a database backend, set CTXDIFF_STORE in the server's env instead and drop the flag; resolution is the same explicit-beats-ambient rule the CLI uses.

The six tools

Tool Reach for it when
ctxdiff_runs start here — lists the traces the server can see, and the run handle every other tool takes
ctxdiff_explain(run, turn) start here for a bad turn — one call, all three analyses, one compact summary of the likely context-level cause
ctxdiff_diff(run, turn_a, turn_b) exactly which blocks were added, evicted or modified between two turns
ctxdiff_tokens(run, [turn]) where the budget went, per label; dead tool schemas; evicted tagged blocks
ctxdiff_cache(run) where the prompt-cache prefix broke, and what broke it
ctxdiff_block(run, content_hash) the full text of one block, paged

Everything is stateless and read-only: each call opens the store, reads, and closes it. No daemon, no lock held against a live writer — you can inspect a trace while your agent is still appending to it.

It is a renderer, not a re-implementation

The MCP tools call the same pure analyzers as ctxdiff diff, ctxdiff tokens, ctxdiff cache, the HTML dashboard and ctxdiff check. ctxdiff_tokens returning a different total than ctxdiff tokens prints for the same turn is a test failure, not a possibility. A debugger that gives two answers to one question is worse than no debugger.

Results are compact JSON, not the terminal's bar charts: content-hash prefixes, labels, token counts, and only the changed hunk of a modified block. ctxdiff exists to keep context windows small, so an MCP server for it that pasted a 50 KB retrieved chunk into your agent's context would refute the whole product. Every result is hard-capped at ~15 KB and marks itself "truncated": true when it had to drop something — ctxdiff_block is the deliberate, paged way to read one block in full.

Two things to know before you enable it 🔒

1. This is the one ctxdiff feature that sends your prompts somewhere. Everything else in ctxdiff is local: no network, no telemetry. MCP is different by construction — whatever a tool returns is handed to your MCP client's model, which for most people is a cloud model. If your traces contain customer messages, retrieved internal documents or anything else you would not paste into a chat window, that is a real boundary and you should know where it is before you cross it.

Start the server with --redact to never return captured text at all:

"args": ["-m", "ctxdiff", "mcp", "--runs-dir", "/abs/path", "--redact"]

In that mode every tool still answers with labels, content hashes, token counts, positions and structure — enough to find which block broke a turn, how big it was and when it left — but no recorded text is returned by any tool, including ctxdiff_block, whose entire job is text. (This is a separate mechanism from the capture-time redact() hook, which decides what reaches disk in the first place. Use both: the hook for secrets, --redact for the boundary.)

--redact is not "nothing identifiable leaves". It withholds captured content — the strings your users, your retrieval layer and your tools wrote. It does not withhold the metadata you wrote, because that metadata is what makes a redacted result usable at all. Concretely, these still flow to the connected model in --redact mode:

Still returned Withheld
project name, session id, trace filename and the run handle built from it block text, previews, changed hunks, ctxdiff_block slices
agent names and step names (trace.init(...), tracer.agent()) tool-schema names (a "name" lifted out of captured wire content — count and cost only)
block labels, including every string you passed to tracer.tag() cache-break detail strings and culprit snippets
content hashes, token counts, positions, turn numbers, timestamps, provider and model

If your agent names, step names or tag() labels are themselves sensitive (a customer id in a tag, a codename in an agent name), --redact will not save you — rename them, or don't enable the MCP server for that trace.

2. Captured text is untrusted input. A .ctrace is a recording of things other people wrote — end-user messages, retrieved documents, tool output. Returning that to an agent is a prompt-injection channel into your debugging session. So every string ctxdiff returns from a trace is stripped of ANSI escapes and wrapped in a fence:

<captured-untrusted-input>Ignore all previous instructions and…</captured-untrusted-input>

and the server's MCP instructions tell the connected model, before it calls anything, that text inside those markers is data to reason about, never instructions to follow. Content containing the closing marker has it escaped, so a document cannot end its own fence. That includes strings that don't look like content: a tool schema's "name" is read out of the captured request, so a schema named </captured-untrusted-input> SYSTEM: … is fenced and defanged like any retrieved document. This is defense in depth, not a guarantee: a model can still be talked into things. --redact is the setting that removes the channel entirely.


Storage backends

By default ctxdiff is local-first and zero-config: trace.init("my-agent") writes ./my-agent.ctrace, a plain SQLite file you can open, query, email or attach to a ticket. Nothing to install, nothing to run, no server. That default never changes on its own — everything below is opt-in.

When you'd rather keep traces in a database you already run (a shared team dashboard, an agent fleet across many containers, a place where .ctrace files can't live), point ctxdiff at it once and every later trace.init() follows:

import ctxdiff
from ctxdiff import PostgresStore

ctxdiff.configure(store=PostgresStore(dsn="postgresql://user:pw@db.internal/agents"))

# ...from here on, unchanged:
tracer = ctxdiff.trace.init("support-agent")
client = tracer.wrap(OpenAI())

Or set an environment variable and change no code at all:

export CTXDIFF_STORE=postgresql://user:pw@db.internal/agents   # PostgreSQL
export CTXDIFF_STORE=mysql://user:pw@db.internal/agents        # MySQL / MariaDB
export CTXDIFF_STORE=sqlite:///var/lib/ctxdiff/agents.ctrace   # one SQLite file
export CTXDIFF_STORE=~/traces                                  # a directory: ~/traces/<project>.ctrace

The value is a location, not a backend name: CTXDIFF_STORE=postgres is rejected with a message showing the URL form, rather than quietly creating a local SQLite file called postgres.

The three backends

Backend Install Configure with
SQLite (default) — built in nothing, or SQLiteStore(path=...)
PostgreSQL pip install 'ctxdiff[postgres]' PostgresStore(dsn="postgresql://...")
MySQL / MariaDB pip install 'ctxdiff[mysql]' MySQLStore(dsn="mysql://...")

The drivers (psycopg 3 and PyMySQL) are optional extras, imported lazily at connect time. ctxdiff's core still has exactly one runtime dependency (tiktoken), importing ctxdiff never imports a database driver, and a store configured for a backend whose extra isn't installed tells you what to install instead of crashing.

Tables are created for you

On first connect the adapter runs CREATE TABLE IF NOT EXISTS for its four tables — ctxdiff_run, ctxdiff_call, ctxdiff_block, ctxdiff_call_block — in whatever database the DSN points at. There is no migration step. The tables are prefixed because they live in a database you share with your own application, and connecting again is a harmless no-op.

It's the same logical model as a .ctrace file, in every backend: sessions, calls, content-hashed blocks stored once and referenced by position, and call→block membership. Analyzers can't tell the difference — the same conformance suite runs against all three, against real PostgreSQL and MySQL servers as well as SQLite.

Same semantics, too, not just the same shape:

  • Sessions are ordered by write order, never by started_at. Several containers writing into one database will disagree about the clock; ordering by an insert-order column (SQLite's rowid, BIGSERIAL, AUTO_INCREMENT) means "the newest session" is the one written last on every backend, and stays stable between reads.
  • Keys compare byte-exactly. MySQL's default collation is case-insensitive, which would make two content hashes differing only in case the same block; ctxdiff pins ascii_bin on its id/hash columns so content-addressed dedup means what it says.
  • Free-text columns are unbounded on every backend, so a 400-character provider error or tag label stores rather than rejecting the call.

Reading from a database

The CLI and the dashboard read from the configured store too. With CTXDIFF_STORE set (or after configure()), the read commands analyze a session in the database rather than looking for a .ctrace in the working directory:

ctxdiff sessions                       # every session in the store, oldest first
ctxdiff agents                         # every agent, aggregated across all of them
ctxdiff tokens --session 4f3a2b1c9d8e  # ...one of them (no flag needed if there's one)
ctxdiff diff --session 4f3a2b1c9d8e --turn 7 --turn 8
ctxdiff export --session 4f3a2b1c9d8e --out run.html

A shared database fills up with sessions fast, so the same ambiguity rule applies to the analysis commands: with more than one session, --session is required and the error lists them. ctxdiff sessions is how you get that list on purpose — and ctxdiff export/view need no session at all, since the dashboard lists every agent and session in the store for you.

--project PATH (or its --run alias) always wins: a path names a file, so it reads that .ctrace even when a database is configured — and --project <dsn> reads that database whatever is configured. The same rule applies on the write side — trace.init(project, path=...) is always a local file.

A database is never allowed to break your agent

The fail-open guarantee covers a networked store completely, and matters more there than for a local file:

  • No database I/O ever happens on your call path. Not the writes, and not the connect either: the session is opened by the run's single background writer thread, so tracer.wrap() returns immediately even against a database that is slow, wedged, or not there at all.
  • Connects and statements are bounded (5s connect, 10s statement, by default — both configurable), with TCP keepalives and tcp_user_timeout so a server that completes its handshake and then stops answering can't wedge the writer either. tracer.close() is bounded by the same statement timeout.
  • A dead database degrades capture, never the run. If the store can't be reached or created, ctxdiff logs one warning, records nothing, and your calls run exactly as if ctxdiff weren't there. It never silently falls back to writing a local file you didn't ask for.
  • A connection killed mid-run is reopened. A pooler recycle, a failover or a database restart costs the write that was in flight, not the rest of the run.
  • A typo'd DSN behaves the same way — a misconfigured trace destination is a tracing problem, and tracing problems must not take down the program being traced.

Writing your own backend

Store and StoreBackend (ctxdiff.store.base) are small runtime-checkable protocols — seven methods and two, respectively. Anything satisfying them can be passed to configure(store=...); no base class, no registration.


Supported providers

ctxdiff detects the provider from the client you pass to wrap() and applies the matching adapter. Detection keys off the client's module, so anything built on the OpenAI or Anthropic SDK works — including Azure and OpenAI-compatible OSS endpoints.

Provider Client Notes
OpenAI openai.OpenAI(...) Chat Completions and Responses API
Azure OpenAI openai.AzureOpenAI(...) Same adapter, zero config
Anthropic / Claude anthropic.Anthropic(...) Messages API
Google Gemini google.genai.Client(...) Generate Content API — models.generate_content and models.generate_content_stream (streaming usage captured)
Google Vertex AI google.genai.Client(vertexai=True, project=…, location=…) Same client class, same adapter — sync, async and streaming; see Vertex AI
AWS Bedrock boto3.client("bedrock-runtime") Converse API — client.converse(...) and client.converse_stream(...) (streaming usage captured)
Open-source models openai.OpenAI(base_url="http://localhost:11434/v1", ...) Any OpenAI-compatible endpoint — Ollama, vLLM, LM Studio, Together, Groq, …
LangChain / LangGraph any chat model — ChatOpenAI, ChatAnthropic, ChatVertexAI, … callbacks=[tracer.langchain_handler()] — see LangChain & LangGraph

Passing an unrecognized client raises immediately, so misconfiguration fails loudly at setup rather than silently at record time:

tracer.wrap(some_unknown_client)
# ValueError: ctxdiff: unrecognized client module '...'; supported providers: ['anthropic', 'bedrock', 'gemini', 'openai']

How it works

[ your agent ]
     │  client = tracer.wrap(OpenAI())
     ▼
[ CAPTURE ]   a transparent proxy intercepts the completion call
     │        · calls the real method first (host is never delayed or altered)
     │        · fail-open: a ctxdiff error can never break your app
     ▼
[ STORE ]     one SQLite .ctrace file per run
     │        · every message / content part / tool schema is a content-hashed "block"
     │        · identical blocks are stored once, referenced per call (dedup)
     ▼
[ READ ]      CTrace.open(path) → runs, calls, blocks
     ▼
[ ANALYZE ]   diff_turns / analyze_run / analyze_cache — pure functions the
     │        CLI and HTML viewer both call, so every number agrees
     ▼
[ RENDER ]    the CLI (colored text) or `ctxdiff view`/`export` (HTML)

Capture is deliberately dumb; interpretation lives downstream. The proxy records what was actually sent on the wire and nothing more. Whether a block is "a RAG chunk" or "history" is decided by labels, not baked into capture — so the recorder has no opinions to get wrong, and re-analysis of an old trace never needs a re-run.

The block model

The smallest independently-diffable unit of context is a block: one message, one content part, one tool schema, or one image. Each block's identity is sha256(role + kind + text), so a stable system prompt reused across 40 turns is stored once and referenced 40 times. Diffing two turns then reduces to comparing two ordered lists of hashes. (An image block is the one case where the hashed value is not the stored text — see Images and multimodal content.)


Usage guide

The snippets in this section and in Provider recipes are shown in Python. The JavaScript/TypeScript API mirrors them one-to-one (trace.inittracer.wrap(new Client())tracer.close(), CTrace.open(...) to read back) — see the JS SDK README for JS-native recipes (async, streaming, .stream() helpers, tagging, multi-agent).

Wrapping a client

trace.init() starts a run; wrap() returns a transparent proxy. The proxy behaves exactly like the original client — same attributes, same return values — it just records the completion call as a side effect.

tracer = trace.init(
    "my-agent",                     # project name (labels the run)
    path="runs/session-42.ctrace",  # optional; defaults to ./my-agent-<id>.ctrace
)
client = tracer.wrap(OpenAI())
# ... use client ...
tracer.close()

tracer.path tells you where the trace was written. Call tracer.close() when the run is done to close the store cleanly.

Async clients

wrap() transparently intercepts async clients too — AsyncOpenAI, AsyncAnthropic, and genai.Client(...).aio — via call-time awaitable detection, so awaited calls are captured exactly like sync ones, no extra config needed:

client = tracer.wrap(AsyncOpenAI())
resp = await client.chat.completions.create(model="gpt-4o", messages=[...])

(Bedrock stays sync-only — boto3 has no first-party async client.)

Streaming usage

stream=True calls (sync or async) are captured too, including token usage — the interceptor wraps the returned stream so every chunk still reaches your code unchanged and immediately, and records the call once the stream completes (exhausted, closed, or its with/async with block exited):

stream = client.chat.completions.create(
    model="gpt-4o", messages=[...],
    stream=True, stream_options={"include_usage": True},  # OpenAI chat: opt in for usage
)
for chunk in stream:
    ...  # your code sees every chunk, unmodified

Whether usage is actually captured depends on what the provider puts on the wire:

  • Anthropic and OpenAI Responses streams report usage unconditionally — no caller action needed.
  • OpenAI Chat Completions streams only report usage on a final chunk when the caller passes stream_options={"include_usage": True}. ctxdiff never injects this for you (it would alter your own request) — without it, the call is still captured but usage is honestly None.
  • Bedrock (converse_stream) reports usage unconditionally, on a single trailing metadata event — no caller action needed. See the Bedrock note below for the one shape difference this method has.

A stream you never fully consume, close, or use as a context manager still gets recorded, best-effort, on garbage collection — with whatever usage was accumulated before you moved on (possibly none).

Gemini has no stream=True kwarg — streaming is its own method, generate_content_stream (sync) / client.aio.models.generate_content_stream (async), returning a direct iterator rather than a kwarg-toggled stream or a .stream() manager. It's captured the same way, with usage reported unconditionally on every chunk (cumulative — ctxdiff keeps the latest chunk's totals, not a running sum):

for chunk in client.models.generate_content_stream(model="gemini-2.0-flash", contents="..."):
    ...  # your code sees every chunk, unmodified

The .stream() convenience-manager helpers — the style each provider's own docs actually recommend — are captured the same way, sync and async:

with client.messages.stream(model="claude-opus-4-8", max_tokens=1024, messages=[...]) as stream:
    for event in stream:
        ...  # your code sees every event, unmodified

with client.chat.completions.stream(model="gpt-4o", messages=[...]) as stream:
    ...

with client.responses.stream(model="gpt-4o", input="...") as stream:
    ...

Anthropic.messages.stream, OpenAI's chat.completions.stream, and OpenAI's responses.stream all work this way — with/async with client.messages.stream(...) as stream: — nothing is recorded until you actually enter the block (that's when the provider request fires), and the same usage rules above apply (Anthropic/Responses unconditional, Chat Completions needs your own stream_options={"include_usage": True}). Gemini has no equivalent .stream() manager — see generate_content_stream above. Bedrock has no .stream() manager either — see converse_stream below.

Bedrock streams through its own method too, client.converse_stream(...), and is the one provider that does not hand back the stream directly: it returns a response envelope carrying it, {"ResponseMetadata": …, "stream": <EventStream>}. ctxdiff proxies the stream inside that envelope and passes the rest of the dict through untouched, so your code is written exactly as it would be unwrapped:

response = client.converse_stream(modelId="…", messages=[...])
for event in response["stream"]:
    ...  # your code sees every event, unmodified

Usage arrives on the trailing metadata event (inputTokens/outputTokens/totalTokens — the same shape a non-streaming converse reports), so a streamed Bedrock turn stores the same usage dict a non-streamed one would.

Semantic tagging

Blocks are auto-labeled by a cheap heuristic (system, user, history, tool_schema, tool_output). For exact provenance — especially distinguishing retrieved RAG chunks from ordinary user text — tag the content before the call it belongs to:

chunks = retriever.search("refund policy")          # your RAG retrieval
tracer.tag("rag", [c.text for c in chunks])         # applies to the NEXT call

client.chat.completions.create(
    model="gpt-4o",
    messages=[
        system_prompt,
        {"role": "user", "content": f"Context:\n{joined_chunks}\n\nAnswer: ..."},
    ],
)

Any block whose text contains a tagged string is stored with label="rag" and label_source="tagged". Untagged apps lose nothing but label precision — capture, dedup, and token counting all work regardless. tag() accepts a list of strings or dicts (it reads a text/content field from dicts).

Multi-agent runs

A single codebase often drives several agents — a researcher, a writer, a critic — sometimes across different providers, all within one run. Name each client's agent at wrap time, and optionally mark() the current step; every call is then attributed to the agent (and step) that made it, on one shared, monotonic global timeline.

tracer = trace.init("research-pipeline")
researcher = tracer.wrap(OpenAI(), agent="researcher")     # per-agent adapter + recorder
writer     = tracer.wrap(Anthropic(), agent="writer")      # a DIFFERENT provider, same run

tracer.mark("gather")                                       # sticky: labels every later call…
researcher.chat.completions.create(model="gpt-4o", messages=[...])
tracer.mark("draft")                                        # …until you change or clear it
writer.messages.create(model="claude-sonnet-4-5", messages=[...])
tracer.close()
  • wrap(client, agent=...) — each wrap builds its own provider adapter and recorder, so two agents on two providers each record correctly (no cross-contamination). agent is optional; unlabeled calls are grouped as (unlabeled).
  • mark(step) — sets a sticky step label applied to every subsequent call in the current execution context until the next mark(); mark(None) clears it. (Contrast tag(), which is next-call-only.) Stickiness is per context: asyncio.gather/to_thread copy the context per task, so a task's mark() never relabels a sibling's calls. Caveat — raw thread pools: a ThreadPoolExecutor reuses workers without resetting their context, so a mark() lingers on that worker — a later task on the same worker that does not call mark() inherits the previous step. Under a raw pool, call mark() at the start of every task, or use the scoped form below.
  • with tracer.step("phase"): ... (recommended under concurrency) — scopes the step label to the block and resets it on exit, so it can't leak across logical tasks even in a reused thread pool (and stays correct under asyncio). A task that opens no step() block records step=None, never a sibling's leftover label.
  • --agent NAME — filters ctxdiff diff, tokens, cache and check to one agent's calls. Turn numbers stay global seq values everywhere; diff --agent validates that both --turn values belong to that agent.
  • Agent-aware analysis — cache-prefix stability is computed within each agent's own timeline, so an adjacent cross-agent hand-off is never mistaken for a cache break. ctxdiff tokens prints a per-agent token summary, ctxdiff agents rolls every agent up across all of a project's sessions, ctxdiff sessions lists each session's agents, and the HTML dashboard shows a colored chip per agent, an agent-colored underline on each turn bar, and an "agent hand-off" marker (diffing against that agent's own previous turn).

ctxdiff tokens also opens with a run-level rollup of provider-reported usage (input/output tokens, normalized across all four provider key shapes), with an honest coverage fraction and a per-agent breakdown:

run total · in 18,400 tok · out 640 tok (5/6 calls reported usage)
  researcher · in 12,900 · out 410
  writer     · in  5,500 · out 230

Reading a trace back

Traces are read through the CTrace API. A run has calls (one per LLM request, in seq order); each call has ordered blocks. The same calls work against any backend — CTrace is just the SQLite implementation of the Store protocol, so a PostgresStore(...).open_reader() handle reads identically (see Storage backends).

from ctxdiff.store.ctrace import CTrace

ct = CTrace.open("runs/session-42.ctrace")

run = ct.get_run()
# Run(id, project, started_at, provider, models, ctxdiff_version)

for call in ct.get_calls():
    # Call(id, run_id, seq, params, usage, latency_ms, error, agent, step, provider)
    print(call.seq, call.params.get("model"), call.usage, call.latency_ms, call.agent)

    for cb in ct.get_call_blocks(call.id):
        # CallBlock(block, position, label, label_source)
        b = cb.block
        # Block(content_hash, role, kind, text, token_count, token_method)
        print(cb.position, cb.label, b.role, b.token_count, b.token_method)

ct.close()

Because a .ctrace is plain SQLite, ad-hoc queries work too:

# how many DISTINCT blocks vs total references? (shows dedup at work)
sqlite3 session-42.ctrace "SELECT COUNT(*) FROM block;"
sqlite3 session-42.ctrace "SELECT COUNT(*) FROM call_block;"

Redaction

Context payloads are the most sensitive data in an AI stack — system prompts, retrieved customer documents, tool arguments — and .ctrace files get attached to bug tickets. A redact hook runs on every block before it is written to disk:

import dataclasses
from ctxdiff.models import Block

def scrub(block: Block) -> Block:
    """Return a redacted copy of the block (Block is a frozen dataclass)."""
    return dataclasses.replace(block, text=my_pii_scrubber(block.text))

tracer = trace.init("my-agent", redact=scrub)

The block's content_hash is computed from the original text (so dedup stays stable), and only the stored text is replaced. A redactor that raises does not break the run — the block's text falls back to "[redaction-error]" and capture continues.

Scope: redaction applies to block text. Request params, provider usage, and error names are stored as-is — keep that in mind if you pass sensitive values as sampling params.

Fail-open guarantee

A debugging tool must never break the program it debugs. Every capture path is wrapped so that any error inside ctxdiff — an adapter bug, a full disk, a tokenizer failure — is caught, logged once, and swallowed; your original call proceeds and returns its real result. The only exception that propagates is your own LLM call's error, re-raised unchanged:

try:
    client.chat.completions.create(...)   # raises RateLimitError from the provider
except RateLimitError:
    ...   # you still get YOUR error — and the failed call is recorded with error set

Images and multimodal content

An image never reaches the store as its bytes. A vision content part — an OpenAI image_url or input_image, an Anthropic {"type": "image", "source": …}, a Gemini inline_data/file_data, a Bedrock Converse image — becomes a block with kind="image" that looks like this:

[image 1024×768 · ~765 tok]
  • The base64 is not stored and not tokenized. Before this, a data:image/png;base64,… part was JSON-serialized into the block text, so a 100 KB screenshot bloated the .ctrace and was counted by tiktoken as prose — tens of thousands of phantom tokens for an image that really costs a few hundred. Token attribution was wrong for exactly the vision and computer-use agents that most need it.
  • Identity is the pixels, plus what changes their cost. The block's hash is taken over a sha256 of the image bytes, so the same screenshot re-sent on every turn is one block referenced many times — diff reports it unchanged, the cache profiler sees a stable prefix, and two different images that happen to share a size are still two blocks. The bytes are not the whole request, so the hash also covers OpenAI's detail (the same screenshot at low and at high costs 85 vs 765 tokens, and is two blocks) and anything else the content part carried — notably Anthropic's cache_control, so moving a cache breakpoint onto an image is a change the cache profiler reports rather than a silent one.
  • The count is the provider's own published vision formula applied to dimensions read from the image header — OpenAI's 512px tiling (85 + 170/tile, or a flat 85 at detail: "low"), Anthropic's w×h/750, Gemini's 258-per-tile. It is always marked token_method="estimate", so a turn containing an image is reported as approximate. A vision estimate is never presented as an exact tiktoken count.
  • Nothing is ever fetched. A remote https:// image URL or a provider-side file id is recorded as a reference and degrades to [image] with no estimate, rather than ctxdiff reaching out to measure it. Same for an unrecognized format. ctxdiff stays local-first, and a trace's numbers never depend on whether the host was online.

Dimensions come from a header sniffer covering PNG, JPEG, GIF and WebP — no image library is a dependency. Non-image multimodal parts (audio, video, PDFs, opaque file ids) are untouched and keep their previous representation. The full contract, including the per-provider shapes and the exact formulas, is in spec/ctrace-schema.md.

Token counting

Every block records a token_count and an honest token_method:

  • OpenAI-family → exact counts via tiktoken (token_method="tiktoken").
  • Anthropic, Gemini, Bedrock → a documented estimate, since none publishes a local tokenizer (token_method="estimate").
  • Images → the provider's published vision-token formula applied to the image's real dimensions, always token_method="estimate" (see Images and multimodal content).

Estimates are always labeled as such — never presented as exact. If tiktoken is unavailable for any reason, counting degrades to an estimate rather than dropping the capture, and never reaches the network at record time.

The tokenizer is pinned to an exact version. tiktoken and the JS SDK's gpt-tokenizer are two independent reimplementations of the same o200k_base table on independent release cadences. A .ctrace is safe either way — a block's hash is sha256(role ‖ kind ‖ text) and token counts are not hashed — but every rendered number is downstream of a token count, so a floating dependency could change published numbers with no ctxdiff commit behind it. Both SDKs pin exactly, and a committed golden corpus makes each SDK reproduce a frozen set of CLI outputs and dashboard hashes on every CI run. Re-pinning is deliberate: bump both, run python spec/golden/regenerate.py, review the diff. See spec/golden/README.md.


Provider recipes

Python below. For JavaScript/TypeScript — OpenAI (chat + Responses), Anthropic, Gemini, AWS Bedrock (Converse, streaming included), and LangChain/LangGraph via the same callback handler — see the JS SDK README → Provider recipes. Every provider is now captured by both SDKs, and the same request hashes identically in either.

OpenAI

from openai import OpenAI
client = tracer.wrap(OpenAI())
client.chat.completions.create(model="gpt-4o", messages=[...])

OpenAI Responses API

The same wrap() also captures client.responses.create(...) — the Responses API the OpenAI Agents SDK builds on — off the same wrapped client, no separate call. instructions is captured as the leading system block, flat tools schemas next, then input (string or a list of message/tool items); usage is read from input_tokens/output_tokens instead of prompt_tokens/completion_tokens, and previous_response_id is kept in params since it's chain linkage, not content.

from openai import OpenAI
client = tracer.wrap(OpenAI())
client.responses.create(
    model="gpt-4o",
    instructions="You are a support agent.",
    input="What's your refund window?",
)

Works async too, exactly like the chat path:

resp = await tracer.wrap(AsyncOpenAI()).responses.create(model="gpt-4o", input="hi")

Azure OpenAI

No special configuration — Azure clients live in the openai package, so the OpenAI adapter applies automatically.

from openai import AzureOpenAI
client = tracer.wrap(AzureOpenAI(
    azure_endpoint="https://<resource>.openai.azure.com",
    api_version="2024-02-01",
))
client.chat.completions.create(model="<deployment-name>", messages=[...])

Anthropic / Claude

The Anthropic adapter handles the top-level system field and input_tokens/output_tokens usage shape.

from anthropic import Anthropic
client = tracer.wrap(Anthropic())
client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    system="You are a support agent.",
    messages=[{"role": "user", "content": "What's your refund window?"}],
)

Google Gemini

The Gemini adapter handles google-genai's shape: config.system_instruction/config.tools (a single bag that also carries sampling params like temperature) and contents (a string, or a list of role/parts entries).

from google import genai
client = tracer.wrap(genai.Client(api_key="..."))
client.models.generate_content(
    model="gemini-2.0-flash",
    contents="What's your refund window?",
    config={"system_instruction": "You are a support agent."},
)

Google Vertex AI

Vertex is the same client class as AI Studio — google.genai.Client — constructed in Vertex mode, so it is captured by the same adapter with nothing extra to configure. Only the endpoint differs, and ctxdiff never looks at endpoints: detection keys off the google.genai module, and the adapter reads your request kwargs.

from google import genai
client = tracer.wrap(genai.Client(vertexai=True, project="my-project", location="us-central1"))
client.models.generate_content(
    model="gemini-2.0-flash",
    contents="What's your refund window?",
    config={"system_instruction": "You are a support agent."},
)

Sync, client.aio async, and generate_content_stream all work exactly as they do in AI Studio mode, and a prompt sent through Vertex produces the same block hashes as the same prompt sent through the AI Studio endpoint — so moving a project between the two doesn't light up as a whole new context.

The legacy vertexai.generative_models.GenerativeModel from google-cloud-aiplatform (which Google itself has deprecated in favour of google-genai) is not supported: it is a model object rather than a client, and it sends proto objects rather than the dicts the adapter reads. wrap() on one raises rather than recording a run with zero blocks.

AWS Bedrock

The Bedrock adapter handles boto3's bedrock-runtime Converse API: system (a list of {"text": ...} blocks, never a bare string), messages, toolConfig.tools, and inferenceConfig's sampling fields — detection keys off the client's class name (BedrockRuntime), since every boto3 service client shares the same botocore.client module.

import boto3
client = tracer.wrap(boto3.client("bedrock-runtime", region_name="us-east-1"))
client.converse(
    modelId="anthropic.claude-3-haiku-20240307-v1:0",
    system=[{"text": "You are a support agent."}],
    messages=[{"role": "user", "content": [{"text": "What's your refund window?"}]}],
    inferenceConfig={"maxTokens": 256},
)

client.converse_stream(...) takes the identical request shape and is captured the same way — see Streaming usage for the one difference (it returns an envelope containing the event stream, not the stream itself).

In JavaScript, the same Converse request is captured through @aws-sdk/client-bedrock-runtime — see the JS SDK README → AWS Bedrock. The AWS SDK v3 has one method (client.send(new ConverseCommand(...))) instead of one per operation, so the JS SDK hooks send and dispatches on the command; the request shape underneath is the same, so the same logical call hashes identically in both SDKs (pinned by a cross-SDK conformance test that runs the real Python adapter against the JS capture).

Open-source models

Any model served behind an OpenAI-compatible endpoint (Ollama, vLLM, LM Studio, Together, Groq, …) is just an openai.OpenAI client with a custom base_url — it captures identically:

from openai import OpenAI
client = tracer.wrap(OpenAI(base_url="http://localhost:11434/v1", api_key="ollama"))
client.chat.completions.create(model="llama3", messages=[...])

The same holds for aggregators and proxies that speak the OpenAI API — no extra code:

  • OpenRouter — one endpoint in front of hundreds of models: OpenAI(base_url="https://openrouter.ai/api/v1", api_key=OPENROUTER_KEY), then wrap() it. Every model you route through it is captured.
  • LiteLLM proxy — a self-hosted OpenAI-compatible gateway over 100+ providers: point OpenAI(base_url="http://localhost:4000") at it and wrap(). Captures whatever the proxy fronts.

Because detection keys off the openai client module, both are captured by the OpenAI adapter with zero ctxdiff-specific configuration.

LangChain & LangGraph

LangChain hands you a ChatOpenAI, not an OpenAI, so there is nothing for wrap() to take. Use the callback handler instead — LangChain's own extension point:

from langchain_openai import ChatOpenAI

handler = tracer.langchain_handler()

llm = ChatOpenAI(model="gpt-4o", callbacks=[handler])
llm.invoke("What's your refund window?")          # captured, with usage

For LangGraph — which propagates callbacks through the whole graph — attach it once, at invoke time, and every model call in every node is captured:

graph.invoke(state, config={"callbacks": [tracer.langchain_handler(agent="researcher")]})

What this gives you that client injection never could:

  • Every provider. The handler sees provider-agnostic messages plus LangChain's own model description, so ChatOpenAI, ChatAnthropic, ChatVertexAI, ChatBedrockConverse and anything else following the interface are all captured — each normalized by its own provider's adapter. One handler can serve several models in one run.
  • Streaming, for free. LangChain reports the finished result to the callback either way, so a streamed call records once, with usage — including the stream_options opt-in that the injection path can't get at.
  • Tool calls and multi-turn graphs. Tool schemas, the assistant's tool call, and the tool result all become blocks in the turn that actually sent them.
  • Errors. A failed call is recorded as a failed call, with the context that produced it.

Hash identity is the point. The handler does not invent a second way to read messages: it rebuilds the request in the provider's own wire shape and hands it to the same adapter the direct path uses. So the blocks are identical — same hashes — to what tracer.wrap() records for the same request, and a LangChain trace dedups against a direct one instead of looking like an unrelated context. That is checked two ways for every provider branch — against a direct wrap() capture, and against the actual JSON body LangChain put on the wire (OpenAI, Anthropic, Gemini and Bedrock Converse each against their real integration, with the HTTP stubbed).

A multimodal turn keeps every part: two text parts and an image are three blocks, and the image is hashed over its bytes, so the same screenshot is one block however it was wrapped and its vision-token cost lands in ctxdiff tokens instead of vanishing.

Across SDKs, the JS handler normalizes to the same shapes and the two suites pin the same literal hashes — with one documented exception. LangChain re-serializes a tool call's arguments with the host language's own JSON serializer, and json.dumps emits {"city": "Dubai"} where JSON.stringify emits {"city":"Dubai"}. Each handler reproduces its own framework's real request byte for byte, so a tool-call block hashes differently in the two SDKs — exactly as two direct captures of those same two requests would, with no ctxdiff in the picture. Both hashes are pinned by both test suites so the divergence can never drift silently. Everything else — messages, system prompts, tool schemas, images — is cross-SDK identical.

agent= attributes the handler's calls exactly as wrap(client, agent=...) does, and tracer.tag() / tracer.step() work unchanged.

Legacy: client injection (still supported)

Before the handler existed, ctxdiff captured LangChain by wrapping the underlying SDK client and injecting the proxy into ChatOpenAI:

from openai import OpenAI
from langchain_openai import ChatOpenAI

wrapped = tracer.wrap(OpenAI())
llm = ChatOpenAI(
    client=wrapped.chat.completions,   # inject the wrapped resource
    root_client=wrapped,
    model="gpt-4o",
)
llm.invoke("What's your refund window?")   # captured, with usage

This still works and is still tested — but it reaches into LangChain's internals, so it only covers integrations whose SDK client you can reach, and a LangChain refactor can break it silently. It also can't see stream usage: with streaming=True the call is captured, but LangChain's ChatOpenAI doesn't pass stream_options={"include_usage": True} on the raw client, so usage comes back None. Prefer the handler.

Wrapping a ChatOpenAI object directly still raises — it isn't an SDK client.


The .ctrace format

One run = one SQLite file. The schema is small and stable, versioned by schema_version so an old or foreign file is rejected with a clear error rather than misread.

Table Row Key columns
run one per file project, provider, started_at, ctxdiff_version, schema_version
call one per LLM request seq, params (JSON), usage (JSON), latency_ms, error
block one per distinct context unit content_hash (PK), role, kind, text, token_count, token_method
call_block membership of a block in a call call_id, block_id, position, label, label_source

Block kinds: message · content_part · tool_schema · image Block labels: system · user · history · rag · tool_schema · tool_output Label source: heuristic (auto) · tagged (via tracer.tag) Token method: tiktoken (exact) · estimate (heuristic, or a vision estimate for an image block)

Because blocks are content-addressed and stored once, a long run with a stable prefix stays compact, and future diffing is a hash comparison.


Design principles

  • Local-first. No network calls, no telemetry, no external services. Your context never leaves your machine.
  • Fail-open, always. Capture can never break your application.
  • Wire-level truth. The proxy records what was actually sent, verbatim; interpretation is a separate, re-runnable layer.
  • Honest numbers. Estimated token counts are always labeled as estimates.
  • One file per run. The .ctrace is the shareable artifact — no bundle, no server.

Roadmap

Everything under What it doesn't do (yet) is the roadmap, in rough priority order: live tail (a dashboard that updates while the agent is still running) — plus smaller items tracked in the issues (e.g. rolling per-call model ids up onto run.models, and an opt-in exact pre-flight token count via Anthropic's and Gemini's count_tokens endpoints).

Anything else you need is worth opening an issue for — the roadmap is short on purpose, and what real users hit beats what the maintainer guessed.


Development

pip install -e ".[dev]"     # ctxdiff + pytest + the mcp extra (tests/test_mcp.py spawns a real server)
pytest                      # unit suite

pip install -e ".[eval]"    # + real provider SDKs and respx
pytest tests/eval           # real-SDK integration tests (HTTP stubbed, no network, no keys)

The eval suite drives the real openai, anthropic, google-genai (AI Studio and Vertex), boto3, langchain and langgraph SDKs with their HTTP transport stubbed (respx for httpx-based SDKs; for boto3, botocore.stub.Stubber for converse and a before-send hook returning real event-stream frames for converse_stream), so it needs no API keys and makes no network calls. It skips cleanly if the eval extra isn't installed.

The cross-SDK golden corpus

pytest also runs tests/test_golden.py, which rebuilds every fixture in spec/golden/corpus/ with this SDK's tokenizer and compares the result to committed CLI output and dashboard hashes — the same files the JS suite compares against. It is what keeps "the two SDKs render identical numbers" true as tiktoken and gpt-tokenizer release independently. Nothing in it skips: a missing expectation or an unpinned tokenizer is an error.

python spec/golden/regenerate.py           # rewrite the goldens, then verify the JS SDK agrees
python spec/golden/regenerate.py --check   # verify only (what CI runs)

Regenerate whenever a change is supposed to move a number, and put the resulting diff in the PR — it is the evidence that the new numbers are correct. Full rationale, corpus contents and the re-pinning procedure: spec/golden/README.md.


License

Apache-2.0.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ctxdiff-0.5.0.tar.gz (806.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ctxdiff-0.5.0-py3-none-any.whl (323.1 kB view details)

Uploaded Python 3

File details

Details for the file ctxdiff-0.5.0.tar.gz.

File metadata

  • Download URL: ctxdiff-0.5.0.tar.gz
  • Upload date:
  • Size: 806.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for ctxdiff-0.5.0.tar.gz
Algorithm Hash digest
SHA256 c41b15e3ff1b0b7b0e43ad985232da8d9dcfd9e86e2f4a0241909df75430149f
MD5 aa63c69266365cf94b96f9644071fe59
BLAKE2b-256 4d7f271c0df903e276f7ef5fdaf00811e14f87a33ed72906597f82628ceea174

See more details on using hashes here.

Provenance

The following attestation bundles were made for ctxdiff-0.5.0.tar.gz:

Publisher: publish.yml on salmanzafar949/ctxdiff

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ctxdiff-0.5.0-py3-none-any.whl.

File metadata

  • Download URL: ctxdiff-0.5.0-py3-none-any.whl
  • Upload date:
  • Size: 323.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for ctxdiff-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e593170e8f814fe7b31bbaa82999c2b58704edca5b116c6aeb82af5ab211ab11
MD5 65f1fbfb33f7ff167c5bb21c9492e937
BLAKE2b-256 c3a2971f4ce96f3368a0432d56aacd66e8dc4c044e9719bc78b856ad14ae84ae

See more details on using hashes here.

Provenance

The following attestation bundles were made for ctxdiff-0.5.0-py3-none-any.whl:

Publisher: publish.yml on salmanzafar949/ctxdiff

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page