DOCX Templates skill for Codex
Business owns the layout, code owns the data. Codex sets up Word generation so the template stays editable in Word while the code supplies validated data: it inventories every tag, writes a data contract, uses paragraph and table-row loops, rich text and images, renders with strict undefined handling and escaping, repairs tags Word split across runs, and adds fixture-based render tests that catch leftover placeholders.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- docx-templates Installs to ~/.agents/skills/docx-templates/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install docx-templates 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-templates Installs to .agents/skills/docx-templates/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/docx-templates.zipunzip -o docx-templates.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-templates 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-templates, 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
Builds templated Word generation where non-developers own the .docx layout and code supplies data, using docxtpl (Jinja2 tags, paragraph and table-row loops, RichText, InlineImage, subdocuments) in Python or docxtemplater (PizZip, loops, conditions) in Node, with template linting and render tests. Use when asked to generate contracts, quotes, reports or certificates from a Word template, fill {{placeholders}} in a .docx, fix tags that Word split or broke, or add loops and conditionals to a Word template.
Use it when
- A designer- or business-owned Word template must be filled from application data
- Templates need loops over table rows, conditional paragraphs or inline images
- A template broke after someone edited it in Word
- You want automated tests that guard a template against regressions
Use something else when
- You are producing hundreds of letters from a spreadsheet: use mail-merge
- The document is built entirely in code with no template file: use docx-documents
How to check the result
Render every fixture, assert no {{, {% or leftover delimiters, None or undefined remain in the document XML, and review PDF conversions of the long-name and many-row cases.
The mistake it prevents
Rendering without autoescape, so a customer name containing an ampersand produces a .docx that Word reports as corrupt.
Done well means the template stays editable by the business owner in Word, every tag in it is known to the code, rendering fails loudly on missing data instead of printing blanks, and a render test with realistic data protects the template from future edits.
Pick the engine
| Situation | Engine |
|---|---|
| Python service or script | docxtpl (pip install docxtpl, Jinja2 syntax) |
| Node/TypeScript service or browser | docxtemplater + PizZip (npm i docxtemplater pizzip) |
| Template already uses Word MERGEFIELDs from Mailings | Keep them; see mail-merge |
| Document is built entirely from code, no designer-owned layout | python-docx or docx.js: see docx-documents |
Images in docxtemplater need the paid image module or an open-source alternative; in
docxtpl InlineImage is built in. Installing either needs network.
Workflow
- [ ] 1. Read AGENTS.md and the existing template; list every tag
- [ ] 2. Define the data contract (schema + sample JSON)
- [ ] 3. Fix or author tags in the template
- [ ] 4. Render with strict undefined handling
- [ ] 5. Render tests: sample data + edge cases, converted to PDF for review
- [ ] 6. Document the tag vocabulary for template owners
1. Inventory tags
from docxtpl import DocxTemplate
tpl = DocxTemplate("templates/quote.docx")
print(sorted(tpl.get_undeclared_template_variables()))
Also grep the raw XML; tags split across runs show up as fragments:
unzip -p templates/quote.docx word/document.xml | grep -o '{[^}]*' | head -50
unzip -Z1 templates/quote.docx | grep -E 'header|footer' # tags there are rendered too
2. Data contract
Write the context shape down and validate data before rendering (Pydantic, JSON Schema or zod). Format numbers, dates and currencies in code; templates only place strings.
{
"quote_no": "Q-2026-0142",
"issued": "28 October 2026",
"customer": { "name": "Smith & Sons Ltd", "address_lines": ["1 High St", "Leeds LS1 1AA"] },
"items": [{ "sku": "SV-01", "desc": "Annual support", "qty": "1", "total": "4,800.00" }],
"vat_applies": true,
"grand_total": "5,760.00"
}
3. Tag syntax (docxtpl)
| Goal | Tag | Notes |
|---|---|---|
| Value | {{ customer.name }} | Escaped when autoescape=True |
| Conditional paragraph | {%p if vat_applies %} … {%p endif %} | Each {%p %} tag on its own paragraph; that paragraph is removed |
| Loop paragraphs | {%p for line in customer.address_lines %} {{ line }} {%p endfor %} | |
| Loop table rows | {%tr for it in items %} in its own row, row with {{ it.desc }}, {%tr endfor %} row | Tag rows are removed; the middle row repeats |
| Loop table columns | {%tc for c in cols %} … {%tc endfor %} | |
| Inside one run | {%r if urgent %}URGENT{%r endif %} | Keeps run formatting |
| Rich text | {{r note }} with RichText(...) in context | Note the r |
| Image | {{ logo }} with InlineImage(tpl, "logo.png", width=Mm(30)) | |
| Line breaks in a value | Listing("a\nb") or RichText with \n | Plain \n in a string is ignored by Word |
from docxtpl import DocxTemplate, InlineImage, RichText
from docx.shared import Mm
from jinja2 import StrictUndefined, Environment
tpl = DocxTemplate("templates/quote.docx")
ctx = load_context() # validated dict
ctx["logo"] = InlineImage(tpl, "assets/logo.png", width=Mm(30))
ctx["note"] = RichText("Prices valid for 30 days.", italic=True)
env = Environment(undefined=StrictUndefined, autoescape=True) # missing keys raise
tpl.render(ctx, jinja_env=env, autoescape=True)
tpl.save("out/Q-2026-0142.docx")
autoescape=True is required whenever data can contain &, < or >; without it a
company called “Smith & Sons” produces a document Word refuses to open.
Subdocuments (a block built in code and inserted whole; needs the optional
docxcompose package, pip install "docxtpl[subdoc]"):
sd = tpl.new_subdoc()
sd.add_paragraph("Terms differ for this customer:")
ctx["custom_terms"] = sd # template: {{p custom_terms }}
3b. docxtemplater (Node)
import fs from "node:fs";
import PizZip from "pizzip";
import Docxtemplater from "docxtemplater";
const zip = new PizZip(fs.readFileSync("templates/quote.docx"));
const doc = new Docxtemplater(zip, {
paragraphLoop: true, // loops spanning whole paragraphs don't leave empty lines
linebreaks: true, // "\n" in values becomes a Word line break
nullGetter(part) { // fail on missing data instead of printing "undefined"
if (!part.module) throw new Error(`Missing value for tag {${part.value}}`);
return "";
},
});
try {
doc.render(data);
} catch (err) {
for (const e of err.properties?.errors ?? [err]) console.error(e.properties?.explanation ?? e.message);
throw err;
}
fs.writeFileSync("out/quote.docx", doc.getZip().generate({ type: "nodebuffer", compression: "DEFLATE" }));
docxtemplater syntax: {name}, loops and truthy sections {#items}...{/items},
inverted {^items}No items{/items}, raw OOXML {@rawXml} (dangerous with user data).
Table-row loops work by placing {#items} in the first cell and {/items} in the last
cell of the same row. The delimiters can be changed with the delimiters option if the
document legitimately contains braces.
Fixing tags Word broke
Word splits text into runs when it was typed with spell-check, autocorrect, revision
tracking or mixed formatting, giving XML like {{ cust</w:t></w:r><w:r><w:t>omer }}.
docxtpl and docxtemplater repair many cases, but not all. Fix order:
- In Word, select the whole tag, cut, and paste back as plain text (Ctrl+Shift+V), or retype it in one go with proofing disabled for that text.
- Replace curly quotes inside tags:
{% if status == “paid” %}must use straight quotes. Turn off “straight quotes with smart quotes” in AutoCorrect while editing templates. - Accept all tracked changes in the template; revisions split runs.
- Keep formatting uniform across the whole tag (one font, size, colour).
Reject templates that still fail; do not “fix” them by regex on the XML each render.
5. Render tests
Keep fixtures next to the template and test in CI:
import subprocess, zipfile, re
from pathlib import Path
CASES = ["minimal.json", "typical.json", "long_names.json", "many_items.json", "special_chars.json"]
def test_quote_template(tmp_path):
for case in CASES:
out = tmp_path / f"{case}.docx"
render("templates/quote.docx", load(f"tests/fixtures/{case}"), out)
xml = zipfile.ZipFile(out).read("word/document.xml").decode()
assert not re.search(r"\{\{|\{%|\}\}|%\}", xml), f"unrendered tag in {case}"
assert "None" not in xml and "undefined" not in xml
For visual review, convert renders to PDF in one LibreOffice call and look at the pages:
soffice --headless --convert-to pdf --outdir out/pdf out/*.docx.
Edge cases worth a fixture: empty lists (does the table header still make sense?), very
long names (wrapping in table cells), &, <, quotes and emoji in values, right-to-left
text if the business has such customers, 100+ rows (page breaks, repeating header row:
set “Repeat as header row” on the template’s header row in Word).
6. Document the vocabulary
Give template owners a one-page reference: every tag, its meaning, sample value, and which tags may be deleted safely. Store it next to the template and update it whenever the data contract changes.
Gotchas
{%p %}and{%tr %}tags must be alone in their paragraph or row; other text there is deleted with the tag.- A Jinja error message points at XML, not the Word page; search the error’s tag name in
word/document.xmlto locate it. - Headers, footers and footnotes are templated too; tags there need the same care.
- Fields like TOC and page numbers are not updated by rendering; set “update fields on open” or convert via LibreOffice, which recalculates them.
- Do not hand untrusted users the ability to upload templates: Jinja in docxtpl executes
expressions; use a sandboxed environment (
jinja2.sandbox.SandboxedEnvironment) if they must.
Output
## Template: templates/quote.docx
- Engine: docxtpl, StrictUndefined, autoescape on
- Tags (11): quote_no, issued, customer.name, customer.address_lines[], items[] (sku, desc,
qty, total), vat_applies, grand_total, logo, note
- Fixed: 3 split tags, 2 curly-quote conditions
- Tests: 5 fixtures render, no leftover tags; PDFs reviewed for long-name and 120-row cases 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: "DOCX Templates"
short_description: "Business owns the layout, code owns the data"
default_prompt: "$docx-templates Generate quotes from templates/quote.docx with a line-item table that grows with the data" 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.