Excel XLSX Spreadsheets skill for Codex
Workbooks with live formulas that open clean. Codex writes workbooks the way a financial analyst expects: inputs separated from calculations, formulas instead of pasted results, explicit number formats, Excel tables, frozen headers and validation lists. It knows that openpyxl stores formulas without results, avoids the data_only save trap, and runs a bundled standard-library audit for error values, uncached formulas and formula-injection text.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- xlsx-spreadsheets Installs to ~/.agents/skills/xlsx-spreadsheets/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install xlsx-spreadsheets 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 xlsx-spreadsheets Installs to .agents/skills/xlsx-spreadsheets/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/xlsx-spreadsheets.zipunzip -o xlsx-spreadsheets.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))) xlsx-spreadsheets 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 $xlsx-spreadsheets, 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, edits and audits Excel .xlsx workbooks with openpyxl, XlsxWriter, pandas or ExcelJS - live formulas instead of pasted numbers, number formats, Excel tables, freeze panes, validation, conditional formatting, large-file streaming and cached-value checks. Use when the user asks to build a financial model or report in Excel, export data to .xlsx, update an existing workbook without breaking it, or read values out of a messy spreadsheet.
Use it when
- Generating an Excel report or model from code
- Editing an existing workbook while keeping its formulas and formatting
- Reading values from a workbook returns formulas or None
- Exporting large datasets to .xlsx from Python or Node
Use something else when
- The question is which formula to write or why one fails: use excel-formulas
- You need pivot tables or native charts: use excel-charts-pivots
- Data is plain tabular text with no formatting needs: use csv-processing
How to check the result
Run scripts/xlsx_audit.py on the output to catch error values, injection-risk text and uncached formulas, then compare one key total against the source data.
The mistake it prevents
Loading a workbook with data_only=True and saving it replaces every formula with its last cached value, often None.
Done well means: numbers that depend on other numbers are formulas, inputs are separated
from calculations, every column has an explicit number format, the workbook opens with no
repair prompt and no #REF!/#NAME?, and the file was audited after it was written.
Choose the library
| Task | Default | Notes |
|---|---|---|
| Read values from a workbook | openpyxl.load_workbook(p, data_only=True, read_only=True) | Returns cached values; None where never calculated |
| Edit an existing workbook | openpyxl | Loses charts, images in some cases, shapes and slicers it cannot parse: check first |
| New formatted report, Python | openpyxl, or XlsxWriter for very large/fast writes and native charts | XlsxWriter cannot read or edit |
| DataFrame dump with light formatting | pandas.DataFrame.to_excel via pd.ExcelWriter(..., engine="openpyxl") | Format afterwards through writer.sheets[name] |
| Node / TypeScript | exceljs | Reads and writes; streaming writer for big exports |
| Pivot tables and charts | Use $excel-charts-pivots | |
| Writing or debugging formulas themselves | Use $excel-formulas |
Read AGENTS.md and the manifest first; reuse the library already in the project.
Installs need network; ask for approval if the sandbox blocks it.
Workflow
- [ ] 1. Inspect inputs: sheets, used ranges, formulas vs values, macros (.xlsm)
- [ ] 2. Lay out the workbook: Inputs / Data / Calc / Summary sheets
- [ ] 3. Write data with types (numbers as numbers, dates as datetime)
- [ ] 4. Write formulas that reference cells, never hard-coded results
- [ ] 5. Apply formats, widths, freeze panes, tables, validation
- [ ] 6. Save to a new file; recalculate if values must be readable by code
- [ ] 7. Audit: python3 scripts/xlsx_audit.py out.xlsx
Inspect quickly:
from openpyxl import load_workbook
wb = load_workbook("in.xlsx") # formulas as strings
for ws in wb.worksheets:
formulas = sum(1 for row in ws.iter_rows() for c in row if c.data_type == "f")
print(ws.title, ws.dimensions, "formulas:", formulas, "merged:", len(ws.merged_cells.ranges))
print("defined names:", list(wb.defined_names.keys()))
Build a report with openpyxl
from datetime import date
from openpyxl import Workbook
from openpyxl.formatting.rule import CellIsRule, ColorScaleRule
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.datavalidation import DataValidation
from openpyxl.worksheet.table import Table, TableStyleInfo
wb = Workbook()
inputs = wb.active
inputs.title = "Inputs"
inputs["A1"], inputs["B1"] = "Tax rate", 0.2
inputs["B1"].number_format = "0.0%"
inputs["B1"].fill = PatternFill("solid", fgColor="FFF2CC") # input cells look different
ws = wb.create_sheet("Sales")
headers = ["Date", "Region", "Units", "Unit price", "Net", "Tax", "Gross"]
ws.append(headers)
rows = [(date(2025, 9, 1), "North", 120, 9.5), (date(2025, 9, 1), "South", 80, 10.25)]
for r, (d, region, units, price) in enumerate(rows, start=2):
ws.append([d, region, units, price,
f"=C{r}*D{r}",
f"=E{r}*Inputs!$B$1",
f"=E{r}+F{r}"])
last = ws.max_row
formats = {"A": "yyyy-mm-dd", "C": "#,##0", "D": "#,##0.00", "E": "#,##0.00",
"F": "#,##0.00", "G": "#,##0.00"}
for col, fmt in formats.items():
for cell in ws[col][1:]:
cell.number_format = fmt
for i, h in enumerate(headers, start=1):
ws.column_dimensions[get_column_letter(i)].width = max(12, len(h) + 4)
ws.cell(row=1, column=i).font = Font(bold=True)
ws.cell(row=1, column=i).alignment = Alignment(horizontal="center")
ws.freeze_panes = "A2"
table = Table(displayName="SalesTable", ref=f"A1:G{last}")
table.tableStyleInfo = TableStyleInfo(name="TableStyleMedium2", showRowStripes=True)
ws.add_table(table)
dv = DataValidation(type="list", formula1='"North,South,East,West"', allow_blank=False)
ws.add_data_validation(dv)
dv.add(f"B2:B{last + 500}")
ws.conditional_formatting.add(f"G2:G{last}", ColorScaleRule(start_type="min", start_color="F8696B",
end_type="max", end_color="63BE7B"))
ws.conditional_formatting.add(f"C2:C{last}", CellIsRule(operator="lessThan", formula=["0"],
font=Font(color="9C0006")))
summary = wb.create_sheet("Summary", 0)
summary["A1"], summary["B1"] = "Total gross", "=SUM(SalesTable[Gross])"
summary["B1"].number_format = "#,##0.00"
wb.save("sales.xlsx")
Big files
- Reading:
load_workbook(p, read_only=True, data_only=True)and iteratews.iter_rows(values_only=True); callwb.close()at the end (read-only keeps the file open). - Writing millions of cells:
Workbook(write_only=True)andws.append(row), or XlsxWriter withWorkbook(path, {"constant_memory": True})(rows must be written in order). - Excel limits: 1,048,576 rows and 16,384 columns per sheet, 32,767 characters per cell,
sheet names at most 31 characters without
[]:*?/\. Split across sheets or ship CSV beyond that.
pandas and ExcelJS
import pandas as pd
with pd.ExcelWriter("export.xlsx", engine="openpyxl") as xw:
df.to_excel(xw, sheet_name="Orders", index=False)
ws = xw.sheets["Orders"]
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
import ExcelJS from "exceljs";
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Sales", { views: [{ state: "frozen", ySplit: 1 }] });
ws.columns = [
{ header: "Region", key: "region", width: 14 },
{ header: "Units", key: "units", width: 10, style: { numFmt: "#,##0" } },
{ header: "Price", key: "price", width: 12, style: { numFmt: "#,##0.00" } },
{ header: "Net", key: "net", width: 14, style: { numFmt: "#,##0.00" } },
];
rows.forEach((r, i) => {
const n = i + 2;
ws.addRow({ ...r, net: { formula: `B${n}*C${n}` } });
});
ws.getRow(1).font = { bold: true };
await wb.xlsx.writeFile("sales.xlsx");
For very large Node exports use new ExcelJS.stream.xlsx.WorkbookWriter({ filename }),
call row.commit() per row and await wb.commit() at the end.
Hard rules and gotchas
- Formulas are stored, not calculated. openpyxl and ExcelJS write the formula with no
cached result; Excel computes on open, but
data_only=Truereaders, pandas and many previewers see empty cells. If code must read results, recalculate (next section). - Never
load_workbook(..., data_only=True)and thensave()over the original: every formula is replaced by its last cached value (orNone). - Formula text uses English function names and comma separators. Functions newer than
Excel 2010 need the
_xlfn.prefix when written by code (=_xlfn.XLOOKUP(...)), or Excel shows#NAME?. See$excel-formulasfor dynamic arrays. - openpyxl treats any string starting with
=as a formula. For untrusted text, setcell.value = textthencell.data_type = "s"so it is stored as a string; never let user data become a formula (=HYPERLINK(...)injection). - Timezone-aware datetimes raise in openpyxl; convert to naive local or UTC first and say which.
- Store money as numbers with a format, not strings like
"$1,200". UseDecimal-rounded values when cents matter, and round in formulas (=ROUND(x,2)) where totals are shown. - Macros: open
.xlsmwithload_workbook(p, keep_vba=True)and save with the.xlsmextension, or the macros are dropped. - Editing a workbook that has charts, pivot caches, images or form controls: openpyxl may
drop or degrade them. Test on a copy, compare the parts list (
unzip -l) before and after, and warn the user if anything disappeared.
Recalculate and audit
python3 scripts/xlsx_audit.py sales.xlsx # errors, injection-risk text, uncached formulas
soffice --headless --calc --convert-to xlsx --outdir recalculated sales.xlsx
python3 scripts/xlsx_audit.py recalculated/sales.xlsx --require-cached
The LibreOffice step (needs LibreOffice installed) rewrites the file with computed values.
Re-run the audit with --require-cached; if formulas still lack values, the converter
did not recalculate, so verify the numbers in Excel instead of trusting the file. The script
exits 1 on #REF!, #DIV/0!, #NAME? and other error values, and on defined names that
point at #REF!.
Then check meaning, not just syntax: total one column in Python from the source data and compare it with the workbook’s total cell.
Delivery note
**File:** out/sales.xlsx (sheets: Summary, Inputs, Sales)
**Live formulas:** Net, Tax, Gross per row; Summary!B1 = SUM(SalesTable[Gross])
**Inputs:** Inputs!B1 tax rate (yellow cells are editable)
**Checks:** xlsx_audit clean; recalculated total 2,187.60 matches source data 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: "Excel XLSX Spreadsheets"
short_description: "Workbooks with live formulas that open clean"
default_prompt: "$xlsx-spreadsheets Export last month's orders to a formatted Excel file with totals that update" scripts/xlsx_audit.py
#!/usr/bin/env python3
"""Audit an .xlsx/.xlsm workbook using only the standard library.
Reports per sheet: used range, cell count, formulas, formulas with no cached value
(never calculated, e.g. written by openpyxl), error values (#REF!, #DIV/0!, #N/A,
#NAME?, #VALUE!, #NUM!, #NULL!, #SPILL!, #CALC!), and text cells that start with
= + - @ (possible formula/CSV injection from user data). Also lists defined names
pointing at #REF! and external workbook links.
Usage:
python3 xlsx_audit.py book.xlsx # report, exit 1 on error values
python3 xlsx_audit.py book.xlsx --require-cached # also fail if any formula lacks a cached value
python3 xlsx_audit.py book.xlsx --max-list 50 # list up to 50 offending cells per category
Exit codes: 0 clean, 1 problems found, 2 unreadable file.
"""
from __future__ import annotations
import posixpath
import sys
import zipfile
from xml.etree import ElementTree as ET
NS_MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
NS_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
NS_PKG_REL = "http://schemas.openxmlformats.org/package/2006/relationships"
M = f"{{{NS_MAIN}}}"
ERRORS = {"#REF!", "#DIV/0!", "#N/A", "#NAME?", "#VALUE!", "#NUM!", "#NULL!", "#SPILL!", "#CALC!", "#GETTING_DATA"}
RISKY_PREFIX = ("=", "+", "-", "@")
def shared_strings(z: zipfile.ZipFile) -> list[str]:
if "xl/sharedStrings.xml" not in z.namelist():
return []
out = []
for _, el in ET.iterparse(z.open("xl/sharedStrings.xml")):
if el.tag == f"{M}si":
out.append("".join(t.text or "" for t in el.iter(f"{M}t")))
el.clear()
return out
def sheet_paths(z: zipfile.ZipFile) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
wb = ET.fromstring(z.read("xl/workbook.xml"))
rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
target = {r.get("Id"): r.get("Target") for r in rels.findall(f"{{{NS_PKG_REL}}}Relationship")}
sheets = []
for s in wb.iter(f"{M}sheet"):
rid = s.get(f"{{{NS_REL}}}id")
t = target.get(rid, "")
path = t.lstrip("/") if t.startswith("/") else posixpath.normpath(posixpath.join("xl", t))
sheets.append((s.get("name"), path))
names = [(d.get("name"), d.text or "") for d in wb.iter(f"{M}definedName")]
return sheets, names
def audit_sheet(z: zipfile.ZipFile, path: str, sst: list[str], limit: int) -> dict:
res = {"dim": "?", "cells": 0, "formulas": 0, "uncached": [], "errors": [], "risky": []}
for _, el in ET.iterparse(z.open(path)):
if el.tag == f"{M}dimension":
res["dim"] = el.get("ref", "?")
elif el.tag == f"{M}c":
res["cells"] += 1
ref, typ = el.get("r", "?"), el.get("t", "n")
f = el.find(f"{M}f")
v = el.find(f"{M}v")
val = v.text if v is not None else None
if f is not None:
res["formulas"] += 1
if val is None and typ != "inlineStr":
res["uncached"].append(ref)
if typ == "e" and val in ERRORS or (typ == "str" and val in ERRORS):
res["errors"].append(f"{ref}={val}")
text = None
if typ == "s" and val is not None and val.isdigit() and int(val) < len(sst):
text = sst[int(val)]
elif typ == "inlineStr":
text = "".join(t.text or "" for t in el.iter(f"{M}t"))
if f is None and text and text.startswith(RISKY_PREFIX) and len(text) > 1 and not text[1:].replace(".", "", 1).isdigit():
res["risky"].append(f"{ref}={text[:30]!r}")
el.clear()
for k in ("uncached", "errors", "risky"):
res[k + "_n"] = len(res[k])
res[k] = res[k][:limit]
return res
def main(argv: list[str]) -> int:
args = [a for a in argv[1:] if not a.startswith("--")]
require_cached = "--require-cached" in argv
limit = 10
if "--max-list" in argv:
i = argv.index("--max-list")
limit = int(argv[i + 1])
args = [a for a in args if a != argv[i + 1]]
if len(args) != 1:
print(__doc__.strip())
return 2
try:
z = zipfile.ZipFile(args[0])
sheets, names = sheet_paths(z)
sst = shared_strings(z)
except (zipfile.BadZipFile, KeyError, ET.ParseError, FileNotFoundError) as exc:
print(f"ERROR: cannot read {args[0]} as a workbook: {exc}")
return 2
problems = 0
print(f"Workbook: {args[0]} sheets: {len(sheets)} shared strings: {len(sst)}")
for name, path in sheets:
if path not in z.namelist():
print(f"\n[{name}] (no worksheet part; chart sheet or dialog sheet)")
continue
r = audit_sheet(z, path, sst, limit)
print(f"\n[{name}] range {r['dim']} cells {r['cells']} formulas {r['formulas']}")
if r["uncached_n"]:
print(f" formulas without cached value: {r['uncached_n']} e.g. {', '.join(r['uncached'])}")
if require_cached:
problems += 1
if r["errors_n"]:
print(f" error values: {r['errors_n']} e.g. {', '.join(r['errors'])}")
problems += 1
if r["risky_n"]:
print(f" text starting with = + - @ (injection risk): {r['risky_n']} e.g. {', '.join(r['risky'])}")
bad_names = [n for n, ref in names if "#REF!" in ref]
if bad_names:
print(f"\nDefined names pointing at #REF!: {', '.join(bad_names)}")
problems += 1
links = [n for n in z.namelist() if n.startswith("xl/externalLinks/") and n.endswith(".xml")]
if links:
print(f"\nExternal workbook links: {len(links)} (values depend on files outside this workbook)")
if "xl/vbaProject.bin" in z.namelist():
print("\nContains VBA macros (xl/vbaProject.bin): open with keep_vba=True and save as .xlsm")
print("\nResult: " + ("PROBLEMS FOUND" if problems else "clean"))
return 1 if problems 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.