Last issue gave you the shortlist of office skills. This one opens the scripts/ directories of the docx, pptx and xlsx skills. What is actually valuable in there is not the few hundred words of prompt in SKILL.md — it is the dozen Python scripts you can lift out and run without the skill at all.
When a .docx refuses to open, nobody says "the model hallucinated." They just conclude your tooling does not work. Office deliverables are the easiest class in the whole skill ecosystem precisely because they have an objective oracle: the file either matches the schema or it does not, and no human judgement is involved. This issue traces how that oracle gets wired back into the loop: unzip → merge_runs → edit XML → zip -Xr → validate.py. Every step names a real script path you can take away one at a time.
# Claude Code: the four live in the document-skills plugin
/plugin marketplace add anthropics/skills
/plugin install document-skills@anthropic-agent-skills
# Want only the scripts, not the skill? Clone it — scripts/ stands alone
git clone https://github.com/anthropics/skills
# System dependencies, as docx/SKILL.md lists them:
# docx (npm, preinstalled) · pandoc · LibreOffice (soffice) · pdftoppm (Poppler)
unzip -q doc.docx -d unpacked/
find unpacked -type l -delete # strip symlinks
python scripts/merge_runs.py unpacked/ # coalesce fragmented runs first
# edit unpacked/word/document.xml by hand — do NOT reformat
(cd unpacked && rm -f ../out.docx && zip -Xr ../out.docx .)
python scripts/office/validate.py out.docx --original doc.docx
Not one of those six lines is decoration. find -type l -delete keeps symlinks out of the archive. merge_runs.py decides whether you can find a whole sentence in the XML at all. zip -Xr run from inside the directory matters because zipping the folder itself adds a directory level and Word rejects the result. --original gives the validator a baseline.
zip -r unpacked/ from outside (extra directory level) · letting an editor "format XML" before saving (whitespace lands in w:t and the text silently changes) · skipping merge_runs.py and then concluding the sentence you searched for is not in the document.validate.py takes either an unpacked directory or a packed file, plus four switches: --original to baseline against the source, --auto-repair to fix in place, --author NAME to turn on redline validation, and -v. Exit codes: 0 all checks passed, 1 validation failed, 2 argument or file error. That is the signal you can drop straight into a loop.
schemas/. This is the closest machine-readable proxy for "will this file still open."paraId / durableId values that exceed the range OOXML allows, and w:t elements carrying whitespace without xml:space="preserve". Both are high-frequency side effects of editing XML by hand.--author and it diffs against the original, flagging every text difference that was not recorded as <w:ins> or <w:del>. "Pretending to leave a trail" finally has a checker.Word splits a single sentence into a dozen <w:r> elements — spell-check state, language marks and rsids all cut in mid-phrase. So the string "the quick brown fox" does not exist contiguously in the XML, a literal search misses, and the model starts guessing at positions. That guessing is where corruption actually begins. Coalescing first kills a whole class of hallucination at the source.
validate.py explicitly skips xlsx and performs no schema validation on it. You think you "ran the validator" and nothing was checked — the xlsx oracle is recalc.py in the next section.python scripts/accept_changes.py in.docx out.docx # accept every tracked change
python scripts/comment.py unpacked/ "Numbers disagree here" # directory mode, for XML edits
python scripts/comment.py unpacked/ "Agreed" --parent 0 # reply to comment 0
python scripts/comment.py contract.docx "One question" -o annotated.docx
python scripts/office/validate.py out.docx --original doc.docx --author "BigCat"
In directory mode comment.py prints the <w:commentRangeStart> / <w:commentRangeEnd> / <w:commentReference> snippet for you to place inside word/document.xml: the anchor point is a judgement call left to you, while the numbering and relationship bookkeeping is done by the script. That split is the whole design in miniature.
python scripts/thumbnail.py template.pptx template-thumbs # see layouts; always pass arg 2
python3 -c "import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall('unpacked')" deck.pptx
python scripts/add_slide.py unpacked/ slide2.xml --after slide2.xml # duplicate, never by hand
# reorder / delete = edit <p:sldIdLst> in ppt/presentation.xml
python scripts/clean.py unpacked/ # drop orphaned slides/media/rels
(cd unpacked && rm -f ../out.pptx && zip -Xr ../out.pptx .)
python scripts/office/validate.py out.pptx --original deck.pptx
Two hard rules: running add_slide.py against a .pptx directly requires -o or it rewrites your input, and a template-derived deck requires --original or the template's own inherited errors will bury yours.
python scripts/recalc.py output.xlsx [timeout_seconds] # default 30
# LibreOffice evaluates every formula, rewrites the file in place, returns JSON:
# status(success|errors_found) · total_formulas · total_errors · error_summary
errors_found exits 0. Only a JSON body carrying an error key — meaning nothing was recalculated at all — exits non-zero. So a clean exit code is not a clean workbook; you have to read the JSON. And a green recalc only proves the formulas evaluate, never that they are right — write two or three formulas and check the values they pull before you build out the whole sheet.# 1. File QA
python scripts/office/validate.py out.pptx --original src.pptx
# 2. Content QA: read it back, hunt placeholders
markitdown out.pptx | grep -iE "\bx{3,}\b|lorem|ipsum|\bTODO|\[insert"
# 3. Visual QA: to PDF, then to images, then actually look
python scripts/office/soffice.py --headless --convert-to pdf out.pptx
pdftoppm -jpeg -r 150 out.pdf slide && ls -1 slide-*.jpg
Each layer catches something the others cannot: schema catches "will not open," grep catches "xxx / TODO / insert title here" surviving into the final file, images catch overflowing text boxes and white-on-white. Drop one and you lose that class entirely — the three do not overlap.
pip install 'markitdown[all]'; per the GitHub repo page, 2026-09: ~184k stars, MIT)data_only=True and then saving replaces every formula with a literal permanently, and an .xlsm loses its macros unless you pass keep_vba=True. This is where "not corrupting the file" most often fails — quietly.npm install docx, docs at docx.js.org) only constructs documents; it cannot read an existing .docx. Editing via unpack is not laziness, it is the only road.markitdown file.xlsx or pandoc -t markdown file.docx. Unpacking is for editing, not for looking.npm install pptxgenjs; per the GitHub repo page, 2026-09: ~6.2k stars, MIT) for slides. Still run validate and visual QA afterwards — you just never touch unpacking.skill_id values, zero setup (see Skill 13). This pipeline is for Claude Code and self-hosted environments.Take these three scripts even if you never install the skills: scripts/office/validate.py (exit code is the verdict; docx and pptx) · scripts/recalc.py (the only xlsx oracle) · scripts/merge_runs.py (mandatory before editing docx).
By format: docx = unzip → merge_runs.py → edit word/document.xml → zip -Xr → validate.py --original. pptx = thumbnail.py to read layouts → add_slide.py to duplicate → clean.py to drop orphans → validate.py --original. xlsx = write with openpyxl → recalc.py → read the JSON body.
Always finish with all three layers: validate.py (file) · markitdown | grep (content) · soffice.py + pdftoppm (visual). In that order.
Explicitly do not: hand-copy slide XML to duplicate a slide · zip from outside unpacked · treat recalc's exit 0 as "no errors" · omit --original on template-derived files · save a workbook you loaded with data_only=True · assume validate.py checked your xlsx.
The one-line test: is anything watching this step that can exit non-zero? If not, do not let the model perform it — make it a script, or cut it from the process.
[Content_Types].xml at the root declares what kind each one is. Together with the _rels/ relationship files it forms a ledger — adding or deleting files without updating the ledger is the most common way to corrupt a document.<w:r>)merge_runs.py puts back together.schemas/ ships ISO-IEC29500-4:2016 and the ECMA 4th edition, plus the mce and microsoft extension sets.mc:AlternateContent) that lets newer features degrade gracefully in older readers. It is also why validation cannot rely on a single schema set.<w:ins> / <w:del>. validate.py --author checks that every text difference actually left such a trail.thumbnail.py, add_slide.py and clean.py, plus the original wording of the file / content / visual QA triorecalc.py JSON fields and exit-code contract, the two-pass load_workbook read, plus keep_vba, merged cells and quoting trapssoffice.py, validators/ (base · docx · pptx · redlining), schemas/pip install 'markitdown[all]'; per the GitHub repo page, 2026-09: ~184k stars, MITnpm install pptxgenjs; per the GitHub repo page, 2026-09: ~6.2k stars, MITskill_id) and ten ways to install them; the prerequisite for this issuezip -Xr in SKILL.md as a prompt and wrapping it in a script?recalc.py returning errors_found with exit 0 a bug?#REF!" and "recalculation itself failed" are different events: the first needs a human to look, the second needs the pipeline to stop. Collapsing them into one exit code would be the real bug. The price is that you must read the JSON body — one line of documentation traded for one class of false negative.