Word DOCX Documents skill for Codex
Word files built on styles, not hacks. Codex builds Word documents the way a careful typesetter would: from the user's template, with Heading and List styles, tables whose header row repeats, footers with live PAGE and NUMPAGES fields, and placeholder replacement that survives Word splitting text into runs. A bundled standard-library inspector flags fake headings, spacing paragraphs, leftover placeholders and stray tracked changes before delivery.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- docx-documents Installs to ~/.agents/skills/docx-documents/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install docx-documents 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 docx-documents Installs to .agents/skills/docx-documents/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/docx-documents.zipunzip -o docx-documents.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))) docx-documents 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 $docx-documents, 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
Creates and edits Word .docx files with python-docx or docx (docx.js) - real Heading and List styles, tables with repeating header rows, images, sections, headers and footers with page numbers, hyperlinks and safe text replacement that survives split runs. Use when the user asks to generate a Word report or letter, modify an existing .docx, fill a company template, fix formatting in a generated document, or add docx export to an app.
Use it when
- A Word report, letter or contract must be generated from data
- An existing .docx needs targeted edits without losing its formatting
- A generated document has broken headings, spacing or table layout
- A Node or Python app needs a .docx export
Use something else when
- Many documents from one template with loops and conditionals: use docx-templates
- Edits must appear as tracked changes or comments: use word-tracked-changes
- You only need to convert a .docx to PDF or Markdown: use document-conversion
How to check the result
Run scripts/docx_inspect.py with --strict to check outline, table headers, fields and placeholders, then render a PDF preview with LibreOffice and review page breaks.
The mistake it prevents
Replacing text run by run misses placeholders that Word has split across several runs, so half the template stays unfilled.
Done well means: the document uses styles (not direct formatting) so it restyles cleanly, headings drive the navigation pane and TOC, tables repeat their header row, an edited template keeps its look, and a structural check passes before the file is handed over.
Before writing code
- Read
AGENTS.mdand the manifest: Python service →python-docx; Node/TypeScript →docx(docx.js). Installing either needs network; ask for approval if the sandbox blocks it. - If the user has a branded template (
.docxor.dotx), build from it. Styles, headers, numbering and theme fonts come free; recreating them in code is slower and worse. - Inspect what you are editing:
python3 scripts/docx_inspect.py template.docx --textprints styles in use, outline, tables, fields, tracked changes and leftover placeholders. - Decide the route:
| Need | Route |
|---|---|
| Generate a new document from data | python-docx / docx.js (this skill) |
| Many documents from one Word template with loops and conditions | $docx-templates (docxtpl / docxtemplater) |
| Letters to a mailing list | $mail-merge |
| Show edits as tracked changes or add review comments | $word-tracked-changes |
| Convert to PDF, HTML or Markdown | $document-conversion |
python-docx: generate
from docx import Document
from docx.enum.section import WD_ORIENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
doc = Document("brand-template.docx") # or Document() for the default template
normal = doc.styles["Normal"]
normal.font.name = "Calibri"
normal.font.size = Pt(10.5)
normal.paragraph_format.space_after = Pt(6)
doc.core_properties.title = "Q3 Operations Review"
doc.core_properties.author = "Ops Analytics"
doc.add_heading("Q3 Operations Review", level=0) # Title style
doc.add_heading("Summary", level=1)
doc.add_paragraph("Throughput rose 12% while cost per order fell.")
for item in ("Two new carriers onboarded", "Returns backlog cleared"):
doc.add_paragraph(item, style="List Bullet")
doc.add_heading("Regional results", level=1)
rows = [("North", 1204, "4.1%"), ("South", 980, "3.7%")]
table = doc.add_table(rows=1, cols=3)
table.style = "Table Grid" # must exist in the template's styles
hdr = table.rows[0]
for cell, label in zip(hdr.cells, ("Region", "Orders", "Defect rate")):
cell.text = label
cell.paragraphs[0].runs[0].bold = True
tr_pr = hdr._tr.get_or_add_trPr() # repeat header row on every page
flag = OxmlElement("w:tblHeader")
flag.set(qn("w:val"), "true")
tr_pr.append(flag)
for region, orders, rate in rows:
cells = table.add_row().cells
cells[0].text, cells[1].text, cells[2].text = region, f"{orders:,}", rate
for c in cells[1:]:
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
doc.add_picture("chart.png", width=Cm(16))
doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER
landscape = doc.add_section() # new section for a wide appendix
landscape.orientation = WD_ORIENT.LANDSCAPE
landscape.page_width, landscape.page_height = landscape.page_height, landscape.page_width
doc.save("q3-review.docx")
Page numbers, hyperlinks and other XML-level features
python-docx has no high-level API for fields or for writing hyperlinks. Use small helpers:
def add_field(paragraph, instr):
"""Insert a field such as 'PAGE', 'NUMPAGES' or 'TOC \\o "1-3" \\h \\z \\u'."""
run = paragraph.add_run()
for kind, text in (("begin", None), (None, instr), ("separate", None), ("end", None)):
if kind:
el = OxmlElement("w:fldChar")
el.set(qn("w:fldCharType"), kind)
else:
el = OxmlElement("w:instrText")
el.set(qn("xml:space"), "preserve")
el.text = f" {text} "
run._r.append(el)
def add_hyperlink(paragraph, url, text):
r_id = paragraph.part.relate_to(
url, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
is_external=True,
)
link = OxmlElement("w:hyperlink")
link.set(qn("r:id"), r_id)
run = OxmlElement("w:r")
rpr = OxmlElement("w:rPr")
style = OxmlElement("w:rStyle")
style.set(qn("w:val"), "Hyperlink")
rpr.append(style)
run.append(rpr)
t = OxmlElement("w:t")
t.text = text
run.append(t)
link.append(run)
paragraph._p.append(link)
footer = doc.sections[0].footer.paragraphs[0]
footer.alignment = WD_ALIGN_PARAGRAPH.RIGHT
footer.add_run("Page ")
add_field(footer, "PAGE")
footer.add_run(" of ")
add_field(footer, "NUMPAGES")
A TOC field is inserted empty; Word fills it on open only if asked. Add
<w:updateFields w:val="true"/> to settings so Word offers to update fields:
upd = OxmlElement("w:updateFields")
upd.set(qn("w:val"), "true")
doc.settings.element.append(upd)
The Hyperlink character style exists in Word’s default template; in a custom template,
check doc.styles and fall back to blue underlined run formatting.
python-docx: edit an existing document safely
Word splits text into runs at arbitrary points (spell-check, revisions, formatting), so
"{{client}}" may be stored as "{{cli" + "ent}}". Replace at paragraph level:
def iter_paragraphs(doc):
yield from doc.paragraphs
for t in doc.tables:
for row in t.rows:
for cell in row.cells:
yield from cell.paragraphs
for s in doc.sections:
for part in (s.header, s.footer, s.first_page_header, s.first_page_footer):
if not part.is_linked_to_previous: # reading a linked one creates a new part
yield from part.paragraphs
def replace_in_paragraph(p, old, new):
if old not in p.text:
return False
runs = p.runs
full = "".join(r.text for r in runs)
start = full.index(old)
end = start + len(old)
pos = 0
for r in runs: # keep formatting of the run where the match starts
r_start, r_end = pos, pos + len(r.text)
pos = r_end
if r_end <= start or r_start >= end:
continue
keep_before = r.text[: max(start - r_start, 0)]
keep_after = r.text[max(end - r_start, 0):] if r_end > end else ""
r.text = keep_before + (new if r_start <= start < r_end else "") + keep_after
return True
Call it in a loop until it returns False for repeated placeholders. Nested tables need
recursion over cell.tables. For anything beyond a few placeholders, switch to $docx-templates.
docx.js (Node)
import { writeFileSync } from "node:fs";
import {
AlignmentType, Document, Footer, HeadingLevel, Packer, PageNumber,
Paragraph, Table, TableCell, TableRow, TextRun, WidthType,
} from "docx";
const rows = [["North", "1,204"], ["South", "980"]];
const cell = (text, bold = false) =>
new TableCell({ children: [new Paragraph({ children: [new TextRun({ text, bold })] })] });
const doc = new Document({
creator: "Ops Analytics",
title: "Q3 Operations Review",
styles: { default: { document: { run: { font: "Calibri", size: 21 } } } }, // half-points
sections: [{
footers: {
default: new Footer({
children: [new Paragraph({
alignment: AlignmentType.RIGHT,
children: [new TextRun({ children: ["Page ", PageNumber.CURRENT, " of ", PageNumber.TOTAL_PAGES] })],
})],
}),
},
children: [
new Paragraph({ text: "Q3 Operations Review", heading: HeadingLevel.TITLE }),
new Paragraph({ text: "Regional results", heading: HeadingLevel.HEADING_1 }),
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
new TableRow({ tableHeader: true, children: [cell("Region", true), cell("Orders", true)] }),
...rows.map((r) => new TableRow({ children: r.map((v) => cell(v)) })),
],
}),
],
}],
});
writeFileSync("q3-review.docx", await Packer.toBuffer(doc));
docx.js builds new documents only; it cannot open and edit an existing .docx. For edits
in Node, use docxtemplater ($docx-templates) or unzip and patch the XML deliberately.
Hard rules
- Never insert empty paragraphs for spacing or manual page breaks for layout; use
paragraph_format.space_before/after,keep_with_next, andpage_break_before. - Never fake headings with bold Normal text; use Heading 1-3 so the outline, TOC and accessibility tree work.
- Set fonts on styles, not runs. East Asian text also needs
style.element.rPr.rFonts.set(qn("w:eastAsia"), "Yu Gothic")on the style. - Units: python-docx uses EMU internally; always pass
Pt,Cm,Inches. docx.js fontsizeis in half-points and widths in twips (1/20 pt) unless aWidthTypesays otherwise. table.style = "..."raisesKeyErrorif the style is not in the document; the default template hasTable GridandLight Grid Accent 1, many corporate templates do not.- Images: size explicitly (width only keeps aspect ratio); provide alt text where supported.
- Save to a new filename; never overwrite the user’s source template.
Verification
python3 scripts/docx_inspect.py q3-review.docx --strict
soffice --headless --convert-to pdf --outdir /tmp/preview q3-review.docx # optional visual check
- The inspector shows the expected heading outline, every table has a repeating header,
no leftover
{{placeholders}}, no spacing paragraphs, no unexpected tracked changes. - Re-open with
Document("q3-review.docx")in a test and assert key paragraphs and table cell values. - If LibreOffice is available, render to PDF and look at page breaks, table widths and footer numbering; Word and LibreOffice differ slightly, so confirm critical layout in Word.
Output summary
**Created:** reports/q3-review.docx (from brand-template.docx)
**Structure:** Title, 4 Heading 1 sections, 2 tables (header rows repeat), 1 chart image
**Fields:** PAGE / NUMPAGES footer; TOC refreshes when opened in Word
**Checks:** docx_inspect --strict clean; PDF preview reviewed (6 pages) What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
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 DOCX Documents"
short_description: "Word files built on styles, not hacks"
default_prompt: "$docx-documents Generate the weekly ops report as a Word document using our brand template" scripts/docx_inspect.py
#!/usr/bin/env python3
"""Inspect a .docx without third-party packages.
Prints a structural summary (paragraph styles, headings outline, tables, images,
sections, headers/footers, fields, comments, tracked changes) and flags common
generation defects: empty paragraphs used as spacing, heavy direct formatting,
fake headings (bold Normal paragraphs), and leftover template placeholders.
Usage:
python3 docx_inspect.py report.docx # summary + warnings
python3 docx_inspect.py report.docx --text # also dump paragraph text
python3 docx_inspect.py report.docx --strict # exit 1 if any warning
Exit codes: 0 ok, 1 warnings under --strict, 2 unreadable or not a .docx.
"""
from __future__ import annotations
import re
import sys
import zipfile
from collections import Counter
from xml.etree import ElementTree as ET
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
NS = {"w": W}
PLACEHOLDER = re.compile(r"(\{\{.*?\}\}|\{%.*?%\}|«[^»]+»|\[\[.*?\]\]|<<[^>]+>>|TODO|XXX|Lorem ipsum)")
def q(tag: str) -> str:
prefix, local = tag.split(":")
return f"{{{NS[prefix]}}}{local}"
def para_text(p: ET.Element) -> str:
return "".join(t.text or "" for t in p.iter(q("w:t")))
def style_names(z: zipfile.ZipFile) -> dict[str, str]:
try:
root = ET.fromstring(z.read("word/styles.xml"))
except KeyError:
return {}
names = {}
for s in root.findall("w:style", NS):
sid = s.get(q("w:styleId"))
name_el = s.find("w:name", NS)
names[sid] = name_el.get(q("w:val")) if name_el is not None else sid
return names
def main(argv: list[str]) -> int:
args = [a for a in argv[1:] if not a.startswith("--")]
flags = {a for a in argv[1:] if a.startswith("--")}
if len(args) != 1:
print(__doc__.strip())
return 2
path = args[0]
try:
z = zipfile.ZipFile(path)
body = ET.fromstring(z.read("word/document.xml")).find("w:body", NS)
except (zipfile.BadZipFile, KeyError, ET.ParseError, FileNotFoundError) as exc:
print(f"ERROR: cannot read {path} as .docx: {exc}")
return 2
names = style_names(z)
styles: Counter[str] = Counter()
warnings: list[str] = []
outline: list[str] = []
empty_run = 0
max_empty_run = 0
direct_fmt_runs = 0
total_runs = 0
fake_headings = 0
placeholders: list[str] = []
dump: list[str] = []
paragraphs = list(body.iter(q("w:p")))
in_table = {id(p) for tbl in body.iter(q("w:tbl")) for p in tbl.iter(q("w:p"))}
for p in paragraphs:
ppr = p.find("w:pPr", NS)
sid = None
if ppr is not None and ppr.find("w:pStyle", NS) is not None:
sid = ppr.find("w:pStyle", NS).get(q("w:val"))
style = names.get(sid, sid) if sid else "Normal"
styles[style] += 1
text = para_text(p)
runs = p.findall("w:r", NS)
total_runs += len(runs)
for r in runs:
rpr = r.find("w:rPr", NS)
if rpr is not None and any(
el is not None and el.get(q("w:val")) not in ("0", "false", "off")
for el in (rpr.find(f"w:{t}", NS) for t in ("rFonts", "sz", "color", "b", "i"))
):
direct_fmt_runs += 1
has_drawing = p.find(".//w:drawing", NS) is not None
if id(p) in in_table:
pass
elif not text.strip() and not has_drawing and p.find(".//w:br", NS) is None:
empty_run += 1
max_empty_run = max(max_empty_run, empty_run)
else:
empty_run = 0
if style.lower().startswith("heading") or style == "Title":
level = re.sub(r"\D", "", style) or "0"
outline.append(f"{' ' * max(int(level) - 1, 0)}- {text[:70]}")
elif id(p) not in in_table and runs and text.strip() and len(text) < 80 and all(
(r.find("w:rPr/w:b", NS) is not None) for r in runs if (para_text(r) or "").strip()
):
fake_headings += 1
for m in PLACEHOLDER.findall(text):
placeholders.append(m)
if "--text" in flags:
dump.append(f"[{style}] {text}")
tables = body.findall(".//w:tbl", NS)
header_rows = sum(1 for t in tables if t.find("w:tr/w:trPr/w:tblHeader", NS) is not None)
images = [n for n in z.namelist() if n.startswith("word/media/")]
sections = len(body.findall(".//w:sectPr", NS))
headers = [n for n in z.namelist() if re.match(r"word/header\d*\.xml", n)]
footers = [n for n in z.namelist() if re.match(r"word/footer\d*\.xml", n)]
fields = Counter()
field_roots = [body] + [ET.fromstring(z.read(n)) for n in headers + footers]
for root in field_roots:
for instr in root.iter(q("w:instrText")):
if instr.text and instr.text.strip():
fields[instr.text.strip().split()[0]] += 1
for fs in root.iter(q("w:fldSimple")):
fields[(fs.get(q("w:instr")) or "?").strip().split()[0]] += 1
ins = len(list(body.iter(q("w:ins"))))
dels = len(list(body.iter(q("w:del"))))
comments = 0
if "word/comments.xml" in z.namelist():
comments = len(ET.fromstring(z.read("word/comments.xml")).findall("w:comment", NS))
print(f"File: {path}")
print(f"Paragraphs: {len(paragraphs)} Tables: {len(tables)} ({header_rows} with repeating header) "
f"Images: {len(images)} Sections: {sections}")
print(f"Headers: {len(headers)} Footers: {len(footers)} Comments: {comments} "
f"Tracked insertions: {ins} deletions: {dels}")
print("Fields: " + (", ".join(f"{k} x{v}" for k, v in fields.items()) or "none"))
print("Styles used: " + ", ".join(f"{k} x{v}" for k, v in styles.most_common()))
print("Outline:")
print("\n".join(outline) if outline else " (no Heading-styled paragraphs)")
if max_empty_run >= 2:
warnings.append(f"{max_empty_run} consecutive empty paragraphs used as spacing; use space_before/after")
if total_runs and direct_fmt_runs / total_runs > 0.3:
warnings.append(f"{direct_fmt_runs}/{total_runs} runs carry direct font/size/colour formatting; move it into styles")
if fake_headings:
warnings.append(f"{fake_headings} short all-bold paragraphs look like headings but are not Heading-styled")
if not outline and len(paragraphs) > 30:
warnings.append("long document with no Heading styles: navigation pane and TOC will be empty")
if placeholders:
warnings.append(f"leftover placeholders: {sorted(set(placeholders))[:10]}")
if ins or dels:
warnings.append("document contains tracked changes; accept or reject before delivery if unintended")
if tables and header_rows < len(tables):
warnings.append(f"{len(tables) - header_rows} table(s) without a repeating header row")
if dump:
print("\nText:")
print("\n".join(dump))
print("\nWarnings:" if warnings else "\nWarnings: none")
for w in warnings:
print(f" - {w}")
return 1 if warnings and "--strict" in flags else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
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.