ctx_diff
Returns the structured diff between two versions of a page. Not a line diff — a section diff.
git diff is the wrong shape for prose. It reports that lines 14–17 changed, which tells an agent
nothing about whether the paragraph it is about to rewrite is the one somebody just corrected.
Contexo’s differ splits each version into frontmatter, an optional preamble, and its ## sections,
then reports changes per field and per section. The output an agent gets back is closer to “the
Webhooks section is new, Concepts was reworded, Agent Reasoning is untouched” — which is a fact it
can act on.
When your agent reaches for it
Section titled “When your agent reaches for it”Before rewriting an existing page, and whenever the answer to “is my understanding current” depends
on a specific pair of shas. With no from / to it shows the most recent change to the page, which
is the common case. With blame: true it answers “who wrote this section?”.
Parameters
Section titled “Parameters”| Parameter | Type | Required | Description |
|---|---|---|---|
blame | boolean | no | Annotate each section with the commit that originally introduced its heading (best-effort, adds latency on long histories) |
from | string | no | Old sha (default: parent of `to`) |
slug | string | yes | Page slug, e.g. 'stripe-subscription' |
to | string | no | New sha (default: HEAD-for-this-path) |
type | concept | entity | source | analysis | no | Only needed when the same slug exists under multiple types |
Defaults resolve on the server: to becomes HEAD-for-this-path (the most recent commit touching
this page, not the repo HEAD), and from becomes the parent commit of to for this path. Both
walk the page’s own history, so unrelated pushes by teammates never shift the range.
Request and response
Section titled “Request and response”{ "method": "tools/call", "params": { "name": "ctx_diff", "arguments": { "slug": "stripe-subscription" } }}The response is a one-line framing followed by the raw SectionDiff JSON, so the agent can
introspect the structure rather than scrape text:
{ "content": [ { "type": "text", "text": "Diff for wiki/concepts/stripe-subscription.md (a4b2c1d..b7e9f20):\n\n{ …SectionDiff… }" } ]}The SectionDiff itself:
{ "from_sha": "a4b2c1d3…", "to_sha": "b7e9f204…", "frontmatter": { "changed": [ { "field": "updated", "from": "2026-07-20T10:04:00Z", "to": "2026-07-24T16:31:00Z" } ], "added": [{ "field": "tags", "to": "proration" }] }, "sections": [ { "heading": "## Concepts", "status": "modified", "from": "Trials are created with trial_end.", "to": "Trials are created with trial_end, always UTC.", "line_diff": "- Trials are created with trial_end.\n+ Trials are created with trial_end, always UTC." }, { "heading": "## Webhooks", "status": "added", "to": "invoice.paid arrives before customer.subscription.updated.\nRetries are idempotent by event id." }, { "heading": "## Agent Reasoning", "status": "unchanged" }, { "heading": "## Open Questions", "status": "removed", "from": "Do we prorate mid-cycle downgrades?" } ]}The CLI renders that same struct as a summary — useful for reading the shape of it:
diff: stripe-subscription a4b2c1d..b7e9f20
Frontmatter
~ updated
- 2026-07-20T10:04:00Z
+ 2026-07-24T16:31:00Z
+ tags: proration
Sections
~ ## Concepts (2 lines changed)
+ ## Webhooks (2 lines added)
= ## Agent Reasoning
- ## Open Questions (1 lines removed)How the differ sees a page
Section titled “How the differ sees a page”Frontmatter is diffed per field, not as text. Scalars appear in changed with from and to.
List-valued fields (tags, related, sources) use set semantics: order is ignored, and each
element that appeared or vanished becomes its own entry in added / removed carrying the field
name — which is why a single new tag renders as + tags: proration rather than a whole-list
rewrite. Fields are compared by value, with time.Time compared as instants.
A section is a ## heading at column 0 and everything under it, up to the next such heading.
### and deeper are not section boundaries: editing an h3 shows up as a modification of its
parent h2. Anything before the first ## is the preamble, reported as a pseudo-section with the
heading _preamble. Trailing blank lines inside a section are stripped before comparison, so
reformatting the whitespace between sections is not a change.
Sections pair by heading, disambiguated by occurrence index when a page repeats a heading (the
first ## Notes on the left pairs with the first ## Notes on the right). Each pair lands in one
of five statuses:
| Status | Meaning | Fields populated |
|---|---|---|
unchanged |
present on both sides, byte-identical body | — |
added |
only in to |
to |
removed |
only in from |
from |
modified |
body differs | from, to, line_diff |
renamed |
heading changed, body largely survived | old_heading, from, to, line_diff when the body also changed |
unchanged sections are reported explicitly rather than omitted — that is what lets an agent see
which parts of a page it has no business touching.
Renames are detected, not assumed. After pairing, the differ compares each unmatched removal
against each unmatched addition using word-token Jaccard similarity and folds the pair into one
renamed entry when it clears 0.7. So ## Decision → ## Final Decision with the body intact is
one rename, not a delete plus an add.
Ordering follows the new version. Sections come back in to order; anything that only exists in
from is appended at the end, which is why removals cluster at the bottom of the output.
line_diff is LCS-based and covers the whole section: - for deleted lines, + for inserted,
two spaces for context, no hunk headers. The (N lines changed) counts in the text rendering are
simply the number of +/- markers.
blame: true annotates each section with the commit that introduced its heading:
diff: stripe-subscription a4b2c1d..b7e9f20
Frontmatter
~ updated
- 2026-07-20T10:04:00Z
+ 2026-07-24T16:31:00Z
+ tags: proration
Sections
~ ## Concepts (2 lines changed) — introduced by dev at a4b2c1d
+ ## Webhooks (2 lines added) — introduced by dev at b7e9f20
= ## Agent Reasoning — introduced by dev at a4b2c1d
- ## Open Questions (1 lines removed)In JSON that is an introduced_by object on the section:
{ "heading": "## Webhooks", "status": "added", "to": "…", "introduced_by": { "sha": "b7e9f204…", "author": "dev", "email": "dev@example.com", "time": "2026-07-28T09:12:44Z", "message": "ctx push (1 pages)" }}Three things worth knowing:
- It is computed by walking the page’s history oldest→newest — up to 200 commits — reading each revision and recording the first commit each heading appeared in. That is a read per commit, which is why the schema calls it best-effort and warns about latency on long histories.
- It is genuinely best-effort: if the walk fails, blame is dropped silently and you still get the diff. Revisions that fail to read are skipped rather than aborting the annotation.
- A
renamedsection is blamed under its old heading when that heading is known, since that is where the content actually started. Removed sections carryintroduced_byin the JSON but the text renderer omits it — hence no annotation on the- ## Open Questionsline above.
What this tool does not do
Section titled “What this tool does not do”There is no local mode over MCP. ctx_diff always asks the server, so both sides of the comparison
are committed versions. Two adjacent things cover the local case:
ctx diff --localcompares your working copy against the server’s HEAD for that page — what a push would change.- Reading
ctx://wiki/<slug>prepends aDRIFT_NOTICEblock when the page moved on the server since your last pull, so an agent sees divergence without asking. See Drift and conflicts.
diff: stripe-subscription a4b2c1d..local
Frontmatter
~ updated
- 2026-07-20 10:04:00 +0000 UTC
+ 2026-07-24 16:31:00 +0000 UTC
+ tags: proration
Sections
~ ## Concepts (2 lines changed)
+ ## Webhooks (2 lines added)
= ## Agent Reasoning
- ## Open Questions (1 lines removed)Note the updated values in that local run: computed on your machine, timestamps render as Go time
values, whereas anything that comes back from the server has been through JSON and reads as RFC 3339.
Same field, same page — the formatting difference is just where the diff was computed.
Errors
Section titled “Errors”ctx_diff: sync: page diff (400): {"error":"no parent commit for this path; pass ?from explicitly"}
— the page has exactly one commit, so there is no parent to default from to. Pass an explicit
from, or use ctx_evolution, whose first entry diffs against an empty
document instead of failing.
ctx_diff: sync: page diff (400): {"error":"unknown sha: …"} — a supplied from / to does not
resolve in that repo.
ctx_diff: sync: page diff (404): {"error":"path not present in repo"} — the slug resolved
against a local file, but the path has no commits at all in this repo. This is what a
never-pushed page returns, because to defaults to HEAD-for-this-path and there is none.
ctx_diff: sync: page diff (404): {"error":"path not present at either sha"} — both shas
resolved, but neither commit’s tree contains the page. Only reachable with an explicit
from / to; if the path is missing on just one side, that side is treated as an empty
document instead and you get clean adds or removes.
ctx_diff: slug "…" not found under .contexo/ … and
ctx_diff: server not configured … — same resolution and configuration rules as
ctx_history.
See also
Section titled “See also”ctx_history— the shas to feedfrom/toctx_evolution— every commit and its diff in one callctx diff— the same diff from your shell, including--local- Page evolution — the workflow these three tools serve
