Word Tracked Changes skill for Codex
Redlines Word opens without a repair prompt. Codex edits Word revision markup at the OOXML level: it splits runs without losing formatting, emits proper insertion and deletion elements with unique ids, author and UTC date, anchors comments with python-docx 1.2, and can accept or reject every revision type including moves and formatting changes. It then checks both the accepted and rejected text views against what you asked for.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- word-tracked-changes Installs to ~/.agents/skills/word-tracked-changes/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install word-tracked-changes The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project word-tracked-changes Installs to .agents/skills/word-tracked-changes/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/word-tracked-changes.zipunzip -o word-tracked-changes.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) word-tracked-changes PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $word-tracked-changes, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Reads, creates, accepts and rejects tracked changes (w:ins, w:del, moves, formatting changes) and comments in Word .docx files by working with the OOXML directly through python-docx and lxml, including python-docx 1.2 add_comment. Use when asked to suggest edits in Word as tracked changes, list who changed what in a contract, accept or reject all revisions, add review comments to a .docx, or produce a clean copy without markup.
Use it when
- Edits must reach a Word user as reviewable redlines, not silent rewrites
- You need an audit of revisions and comments by author and date
- A heavily reviewed document needs a clean copy with all changes accepted or rejected
- Automated review comments should be attached to specific passages
Use something else when
- You are building a new document from scratch with styles and tables: use docx-documents
- You need to compare two separate versions of a contract in plain text: use document-conversion
How to check the result
Reopen the saved file, confirm all XML parts parse and revision ids are unique, and diff the accept-all and reject-all text views against the intended final and the original text.
The mistake it prevents
Putting deleted text in w:t instead of w:delText, or reusing revision ids, makes Word show the text as live or force a document repair.
Done well means Word opens the file without a repair prompt, every revision shows the right author and date in the Review pane, accept-all and reject-all both give the intended text, and nothing outside the requested edits changed.
Know the markup first
python-docx has no tracked-changes API. Comments are supported from python-docx 1.2.0. Everything else is OOXML you edit with lxml through python-docx’s element tree.
| Construct | XML | Notes |
|---|---|---|
| Insertion | <w:ins w:id w:author w:date><w:r>…<w:t>new</w:t></w:r></w:ins> | Wraps whole runs |
| Deletion | <w:del …><w:r><w:delText>old</w:delText></w:r></w:del> | Text must be in w:delText, not w:t |
| Move | w:moveFrom / w:moveTo plus w:moveFromRangeStart/End, w:moveToRangeStart/End | Treat as delete + insert when accepting |
| Formatting change | w:rPrChange inside w:rPr, w:pPrChange inside w:pPr | Holds the old properties |
| Inserted/deleted paragraph mark | w:ins / w:del inside w:pPr/w:rPr | Accepting a deleted mark merges paragraphs |
| Table row change | w:ins / w:del inside w:trPr | Row-level revisions |
| Comment anchor | w:commentRangeStart w:id, w:commentRangeEnd w:id, run with w:commentReference w:id | Body in word/comments.xml |
| Tracking switch | <w:trackRevisions/> in word/settings.xml | Turns tracking on for the human editor |
Rules that keep Word from “repairing” the file:
w:idvalues must be unique across all revision and comment-range elements. Start above the current maximum.w:dateis ISO 8601 UTC without fractional seconds:2026-10-28T09:30:00Z.- Runs, not text, are the unit. Split a run to change part of it and copy its
w:rPrto every piece so formatting survives. - Comments need the part, its relationship and its content type. Let python-docx create them rather than writing those by hand.
Workflow
- [ ] 1. Inspect: count existing revisions/comments, authors, tracking setting
- [ ] 2. Decide the operation (report, suggest edits, comment, accept/reject, clean copy)
- [ ] 3. Make the change on a copy
- [ ] 4. Verify text in accept and reject views, and open-ability
- [ ] 5. Report what changed
1. Inspect
from docx import Document
from docx.oxml.ns import qn
doc = Document("contract.docx")
body = doc.element.body
for tag in ("w:ins", "w:del", "w:moveFrom", "w:moveTo", "w:rPrChange", "w:pPrChange"):
els = list(body.iter(qn(tag)))
print(tag, len(els), {e.get(qn("w:author")) for e in els})
print("comments:", len(doc.comments) if hasattr(doc, "comments") else "python-docx < 1.2")
Headers, footers, footnotes and comments can contain revisions too. Check
section.header._element, section.footer._element and the footnotes part when the
document uses them. A quick whole-package census without Python:
for p in $(unzip -Z1 contract.docx | grep '^word/.*\.xml$'); do
printf '%s ins=%s del=%s\n' "$p" "$(unzip -p contract.docx "$p" | grep -o '<w:ins ' | wc -l)" \
"$(unzip -p contract.docx "$p" | grep -o '<w:del ' | wc -l)"
done
2. Report revisions (who changed what)
def text_of(el):
return "".join(t.text or "" for t in el.iter(qn("w:t"), qn("w:delText")))
rows = []
for kind in ("w:ins", "w:del"):
for el in body.iter(qn(kind)):
if el.getparent().tag == qn("w:rPr"): # paragraph-mark revision, no text
continue
rows.append((kind[2:], el.get(qn("w:author")), el.get(qn("w:date")), text_of(el)))
for r in sorted(rows, key=lambda r: r[2] or ""):
print(r)
Pandoc gives a fast cross-check if installed: pandoc --track-changes=all contract.docx -t markdown
shows insertions and deletions as spans; --track-changes=accept and =reject show
the two final texts.
3a. Suggest an edit as a tracked change
Replace a phrase inside one run with a deletion plus an insertion:
import copy
from datetime import datetime, timezone
from docx.oxml import OxmlElement
AUTHOR = "Codex review"
DATE = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
_next_id = [max([int(e.get(qn("w:id"))) for e in body.iter()
if e.get(qn("w:id")) is not None and e.get(qn("w:id")).lstrip("-").isdigit()] + [0]) + 1]
def _rev(tag):
el = OxmlElement(tag)
el.set(qn("w:id"), str(_next_id[0])); _next_id[0] += 1
el.set(qn("w:author"), AUTHOR); el.set(qn("w:date"), DATE)
return el
def _run_like(run_el, text, deleted=False):
r = OxmlElement("w:r")
rpr = run_el.find(qn("w:rPr"))
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = OxmlElement("w:delText" if deleted else "w:t")
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
t.text = text
r.append(t)
return r
def track_replace(run, old, new):
"""Split `run` around `old` and emit before + w:del(old) + w:ins(new) + after."""
full = run.text
i = full.find(old)
if i < 0:
return False
r = run._r
pieces = []
if full[:i]:
pieces.append(_run_like(r, full[:i]))
d = _rev("w:del"); d.append(_run_like(r, old, deleted=True)); pieces.append(d)
if new:
ins = _rev("w:ins"); ins.append(_run_like(r, new)); pieces.append(ins)
if full[i + len(old):]:
pieces.append(_run_like(r, full[i + len(old):]))
for p in pieces:
r.addprevious(p)
r.getparent().remove(r)
return True
When the phrase spans several runs, first normalize: find the runs covering the match by
character offsets across paragraph.runs, split the first and last at the boundaries,
then wrap the whole middle sequence in one w:del (converting their w:t to w:delText).
Never merge runs with different w:rPr; that silently changes formatting.
Turn tracking on so the human’s follow-up edits are tracked as well:
settings = doc.settings.element
if settings.find(qn("w:trackRevisions")) is None:
settings.append(OxmlElement("w:trackRevisions"))
3b. Add a comment (python-docx >= 1.2)
para = next(p for p in doc.paragraphs if "indemnify" in p.text)
doc.add_comment(para.runs, text="Cap this at 12 months of fees?", author="Codex review",
initials="CR")
runs can be a single run or a contiguous sequence in one paragraph. To anchor on part of
a run, split the run first. On python-docx older than 1.2, upgrade (needs network and
approval) instead of hand-writing comments.xml.
3c. Accept or reject everything
| Element | Accept | Reject |
|---|---|---|
w:ins, w:moveTo | Unwrap: keep child runs | Remove element and content |
w:del, w:moveFrom | Remove element and content | Unwrap and rename w:delText to w:t |
w:rPrChange, w:pPrChange | Remove the change element | Replace the parent properties with the change’s content |
Range markers (w:moveFromRangeStart etc.) | Remove | Remove |
Paragraph-mark w:del in w:pPr/w:rPr | Merge the paragraph with the next one | Remove the marker |
def accept_all(root):
for tag in ("w:moveFromRangeStart", "w:moveFromRangeEnd", "w:moveToRangeStart", "w:moveToRangeEnd"):
for el in list(root.iter(qn(tag))):
el.getparent().remove(el)
for tag in ("w:del", "w:moveFrom"):
for el in list(root.iter(qn(tag))):
if el.getparent().tag != qn("w:rPr"): # paragraph marks handled separately
el.getparent().remove(el)
for tag in ("w:ins", "w:moveTo"):
for el in list(root.iter(qn(tag))):
parent = el.getparent()
if parent.tag == qn("w:rPr"):
parent.remove(el); continue
for child in list(el):
el.addprevious(child)
parent.remove(el)
for tag in ("w:rPrChange", "w:pPrChange", "w:sectPrChange", "w:tblPrChange", "w:trPrChange", "w:tcPrChange"):
for el in list(root.iter(qn(tag))):
el.getparent().remove(el)
Paragraph-mark deletions are rare in machine edits but common in human ones; if the
census found any, handle merges explicitly and check the result. Apply the same function
to headers, footers, footnotes and endnotes. For a clean copy, also delete comments
(remove range markers and w:commentReference runs) and set w:trackRevisions off.
LibreOffice can do accept-all through its UNO API if the XML route is risky for a
heavily revised document; that needs a macro and the soffice binary.
Verify
- Save to a new path and reopen with python-docx (catches broken XML).
- Unzip and validate well-formedness of every part:
python3 -c "import zipfile,sys,xml.dom.minidom as m; z=zipfile.ZipFile(sys.argv[1]); [m.parseString(z.read(n)) for n in z.namelist() if n.endswith('.xml')]; print('ok')" out.docx - Compare texts: the accept view must equal the intended final text and the reject view
must equal the original. Pandoc’s
--track-changes=accept|rejector your own accept/reject functions on copies give both views; diff them against expectations. - Confirm unique
w:ids: collect all ids onw:ins/w:del/w:moveFrom/w:moveTo/w:comment*and assert no duplicates. - If Word or LibreOffice is available, open the file once and confirm no repair prompt.
Gotchas
w:tinsidew:delmakes Word show deleted text as normal text or report corruption.- Leading/trailing spaces vanish unless
xml:space="preserve"is set onw:t/w:delText. paragraph.textandparagraph.runsin python-docx see only direct-child runs, so text insidew:insandw:delis missing from both. Read revision text from the XML (text_ofabove) or from an accepted copy.- Saving with python-docx drops nothing it does not understand, but rewriting text via
paragraph.text = ...destroys revisions and comment anchors in that paragraph. - Author names come from the user; ask which name to use instead of inventing one.
Output
## Tracked changes: msa-v3.docx -> msa-v4-suggested.docx
- Existing markup: 14 insertions, 9 deletions (2 authors), 5 comments
- Added: 6 tracked replacements, 3 comments, author "Legal review (Codex)"
- Tracking switched on in settings
- Checks: opens cleanly, ids unique, accept-view and reject-view diffs match the request What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Word Tracked Changes"
short_description: "Redlines Word opens without a repair prompt"
default_prompt: "$word-tracked-changes Mark up this NDA with my suggested wording as tracked changes so legal can accept them in Word" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.