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.

  • Documents & Office
  • $xlsx-spreadsheets
  • 199 lines
  • 3 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- xlsx-spreadsheets

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

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.

~/.agents/skills/xlsx-spreadsheets/SKILL.md

--- name: xlsx-spreadsheets description: Creates, edits and audits Excel .xlsx workbooks with openpyxl, XlsxWriter, pandas or ExcelJS - live formulas instead of pasted numbers, number… license: MIT ---

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

TaskDefaultNotes
Read values from a workbookopenpyxl.load_workbook(p, data_only=True, read_only=True)Returns cached values; None where never calculated
Edit an existing workbookopenpyxlLoses charts, images in some cases, shapes and slicers it cannot parse: check first
New formatted report, Pythonopenpyxl, or XlsxWriter for very large/fast writes and native chartsXlsxWriter cannot read or edit
DataFrame dump with light formattingpandas.DataFrame.to_excel via pd.ExcelWriter(..., engine="openpyxl")Format afterwards through writer.sheets[name]
Node / TypeScriptexceljsReads and writes; streaming writer for big exports
Pivot tables and chartsUse $excel-charts-pivots
Writing or debugging formulas themselvesUse $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 iterate ws.iter_rows(values_only=True); call wb.close() at the end (read-only keeps the file open).
  • Writing millions of cells: Workbook(write_only=True) and ws.append(row), or XlsxWriter with Workbook(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=True readers, pandas and many previewers see empty cells. If code must read results, recalculate (next section).
  • Never load_workbook(..., data_only=True) and then save() over the original: every formula is replaced by its last cached value (or None).
  • 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-formulas for dynamic arrays.
  • openpyxl treats any string starting with = as a formula. For untrusted text, set cell.value = text then cell.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". Use Decimal-rounded values when cents matter, and round in formulas (=ROUND(x,2)) where totals are shown.
  • Macros: open .xlsm with load_workbook(p, keep_vba=True) and save with the .xlsm extension, 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.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: "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.py140 lines, 5.9 KB
#!/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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem