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.

  • Documents & Office
  • $docx-documents
  • 267 lines
  • 3 files
  • ~2,550 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- docx-documents

Installs to ~/.agents/skills/docx-documents/ 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 $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

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.

~/.agents/skills/docx-documents/SKILL.md

--- name: docx-documents description: Creates and edits Word .docx files with python-docx or docx (docx.js) - real Heading and List styles, tables with repeating header rows, images,… license: MIT ---

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

  1. Read AGENTS.md and the manifest: Python service → python-docx; Node/TypeScript → docx (docx.js). Installing either needs network; ask for approval if the sandbox blocks it.
  2. If the user has a branded template (.docx or .dotx), build from it. Styles, headers, numbering and theme fonts come free; recreating them in code is slower and worse.
  3. Inspect what you are editing: python3 scripts/docx_inspect.py template.docx --text prints styles in use, outline, tables, fields, tracked changes and leftover placeholders.
  4. Decide the route:
NeedRoute
Generate a new document from datapython-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")

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, and page_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 font size is in half-points and widths in twips (1/20 pt) unless a WidthType says otherwise.
  • table.style = "..." raises KeyError if the style is not in the document; the default template has Table Grid and Light 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.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 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.py171 lines, 7.0 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem