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.

  • Documents & Office
  • $word-tracked-changes
  • 242 lines
  • 2 files
  • ~2,550 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- word-tracked-changes

Installs to ~/.agents/skills/word-tracked-changes/ for every project on this machine.

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.

~/.agents/skills/word-tracked-changes/SKILL.md

--- name: word-tracked-changes description: Reads, creates, accepts and rejects tracked changes (w:ins, w:del, moves, formatting changes) and comments in Word .docx files by working with the… license: MIT ---

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.

ConstructXMLNotes
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
Movew:moveFrom / w:moveTo plus w:moveFromRangeStart/End, w:moveToRangeStart/EndTreat as delete + insert when accepting
Formatting changew:rPrChange inside w:rPr, w:pPrChange inside w:pPrHolds the old properties
Inserted/deleted paragraph markw:ins / w:del inside w:pPr/w:rPrAccepting a deleted mark merges paragraphs
Table row changew:ins / w:del inside w:trPrRow-level revisions
Comment anchorw:commentRangeStart w:id, w:commentRangeEnd w:id, run with w:commentReference w:idBody in word/comments.xml
Tracking switch<w:trackRevisions/> in word/settings.xmlTurns tracking on for the human editor

Rules that keep Word from “repairing” the file:

  • w:id values must be unique across all revision and comment-range elements. Start above the current maximum.
  • w:date is 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:rPr to 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

ElementAcceptReject
w:ins, w:moveToUnwrap: keep child runsRemove element and content
w:del, w:moveFromRemove element and contentUnwrap and rename w:delText to w:t
w:rPrChange, w:pPrChangeRemove the change elementReplace the parent properties with the change’s content
Range markers (w:moveFromRangeStart etc.)RemoveRemove
Paragraph-mark w:del in w:pPr/w:rPrMerge the paragraph with the next oneRemove 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

  1. Save to a new path and reopen with python-docx (catches broken XML).
  2. 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
  3. Compare texts: the accept view must equal the intended final text and the reject view must equal the original. Pandoc’s --track-changes=accept|reject or your own accept/reject functions on copies give both views; diff them against expectations.
  4. Confirm unique w:ids: collect all ids on w:ins/w:del/w:moveFrom/w:moveTo/w:comment* and assert no duplicates.
  5. If Word or LibreOffice is available, open the file once and confirm no repair prompt.

Gotchas

  • w:t inside w:del makes Word show deleted text as normal text or report corruption.
  • Leading/trailing spaces vanish unless xml:space="preserve" is set on w:t/w:delText.
  • paragraph.text and paragraph.runs in python-docx see only direct-child runs, so text inside w:ins and w:del is missing from both. Read revision text from the XML (text_of above) 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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem