Report Generation skill for Codex
Charts, numbers and prose from one source of truth. Codex builds recurring reports as a pipeline rather than a document: a frozen data snapshot feeds tested metric functions, charts are drawn at final size, narrative sentences are generated from the same numbers with explicit wording rules, and Jinja2, docxtpl or Quarto renders the output. A consistency check fails the build when the text and the data disagree, and distribution waits for your confirmation.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- report-generation Installs to ~/.agents/skills/report-generation/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install report-generation 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 report-generation Installs to .agents/skills/report-generation/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/report-generation.zipunzip -o report-generation.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))) report-generation 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 $report-generation, 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 reproducible data-driven reports that combine computed metrics, charts and written narrative, rendered to PDF, DOCX or HTML through Jinja2 with WeasyPrint or Playwright, docxtpl, or Quarto, with every number in the text traced to code and a check that catches drift. Use when asked to automate a weekly or monthly report, turn an analysis notebook into a PDF or Word report, generate per-customer or per-region reports, or add charts and commentary to a recurring data export.
Use it when
- A recurring report is assembled by hand from exports and screenshots
- An analysis must become a PDF, DOCX or HTML report with charts and commentary
- Many parameterized variants (per region, client or team) are needed
- Report text has drifted from the numbers in its own tables
Use something else when
- The deliverable is a presentation rather than a document: use slide-deck-design
- You only need to convert existing HTML to PDF: use html-to-pdf
How to check the result
Rebuild from the pinned snapshot, run the metric tests, confirm computed values appear in the rendered text, table totals reconcile and no placeholders remain.
The mistake it prevents
Typing numbers into the narrative or letting the report query live data at render time, so the prose, charts and reruns stop agreeing.
A report is done when one command rebuilds it from a pinned data snapshot, the first page states the conclusions, every number in the prose comes from the same computation as the charts and tables, and an automated check fails the build if text and data disagree.
Architecture (keep the layers apart)
data snapshot -> metrics.py (pure functions) -> context dict -> template -> renderer -> checks
\-> charts.py (files) -/
- Data: load once, from a dated extract (
data/2026-10-27/orders.parquet) or a query with fixed parameters. Never let the report query “now”. - Metrics: plain functions returning numbers, unit-tested. No formatting here.
- Narrative: sentences generated from metrics with explicit wording rules, or written by a person into slots; never typed numbers.
- Template: layout only (Jinja2 HTML, docxtpl Word template, or Quarto document).
- Renderer: produces the file; swappable without touching metrics.
Choose the output route
| Need | Route |
|---|---|
| Polished PDF, designer control, CSS | Jinja2 HTML -> WeasyPrint (no browser) or Playwright Chromium (JS charts) |
| Recipients edit the report in Word | docxtpl template with InlineImage charts (see docx-templates) |
| Analysts work in notebooks, code and prose together | Quarto (.qmd) -> PDF, DOCX or HTML |
| Interactive web report | Static HTML with embedded SVG charts or a small JS chart library |
| Many variants (per region/customer) | Any of the above, parameterized, one output per parameter set |
Check AGENTS.md and the existing pipeline before choosing; reuse the repo’s renderer if
it has one. Installing WeasyPrint, Playwright browsers or Quarto needs network and approval.
Workflow
- [ ] 1. Agree audience, questions answered, cadence, format
- [ ] 2. Freeze the data input and parameters
- [ ] 3. Implement metrics with tests
- [ ] 4. Make charts as files at final size
- [ ] 5. Write narrative rules and the template
- [ ] 6. Render and run the consistency checks
- [ ] 7. Deliver with a run manifest (sending or publishing needs confirmation)
1. Shape the content
Start the report with the answer: 3 to 5 key findings, each one sentence with a number and a comparison (“Revenue was 1.24M, up 8% on September and 3% above plan”). Then supporting sections, each opening with its takeaway, then chart or table, then detail. Methodology, definitions and data caveats go at the end.
3. Metrics
# metrics.py
from dataclasses import dataclass
import pandas as pd
@dataclass(frozen=True)
class Metrics:
revenue: float
revenue_prev: float
orders: int
aov: float
top_region: str
def compute(orders: pd.DataFrame, period: str, prev: str) -> Metrics:
cur = orders[orders["period"] == period]
old = orders[orders["period"] == prev]
rev = float(cur["amount"].sum())
return Metrics(
revenue=rev,
revenue_prev=float(old["amount"].sum()),
orders=int(cur["order_id"].nunique()),
aov=rev / max(1, cur["order_id"].nunique()),
top_region=cur.groupby("region")["amount"].sum().idxmax(),
)
Test with a tiny hand-made DataFrame whose answers you know. Use Decimal for money when
the report is financial and must match ledgers to the cent.
4. Charts
import matplotlib
matplotlib.use("Agg") # headless; no display needed
import matplotlib.pyplot as plt
def revenue_trend(df, path):
fig, ax = plt.subplots(figsize=(6.5, 2.8), layout="constrained") # final printed size in inches
ax.plot(df["period"], df["revenue"], color="#1f4e79", linewidth=2)
ax.annotate(f"{df['revenue'].iloc[-1] / 1e6:.2f}M", (df["period"].iloc[-1], df["revenue"].iloc[-1]),
textcoords="offset points", xytext=(4, 4), fontsize=9)
ax.spines[["top", "right"]].set_visible(False)
ax.set_ylabel("Revenue (EUR)")
ax.set_ylim(bottom=0)
fig.savefig(path) # .svg for HTML/PDF, .png with dpi=200 for DOCX
plt.close(fig)
Rules: chart title states the takeaway (or the section heading does), units on axes, consistent colours for the same series across charts, no 3D, no dual axes unless unavoidable, source and date in a caption. Close every figure to avoid memory growth in batch runs.
5. Narrative rules
Generate sentences from metrics with explicit rules so wording never contradicts the data:
def change_phrase(cur: float, prev: float, unit: str = "") -> str:
if prev == 0:
return "with no prior-period baseline"
pct = (cur - prev) / abs(prev) * 100
if abs(pct) < 0.5:
return "flat on the previous period"
direction = "up" if pct > 0 else "down"
return f"{direction} {abs(pct):.0f}% on the previous period"
def fmt_money(x: float) -> str:
return f"{x / 1e6:.2f}M" if abs(x) >= 1e6 else f"{x / 1e3:.0f}k"
summary = (f"Revenue was {fmt_money(m.revenue)}, {change_phrase(m.revenue, m.revenue_prev)}; "
f"{m.top_region} was the largest region.")
When a person (or an LLM) writes commentary, give them the metrics table and slots; afterwards run the consistency check in step 6 on their text too.
6. Render
Jinja2 HTML to PDF with WeasyPrint:
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from weasyprint import HTML
env = Environment(loader=FileSystemLoader("templates"), undefined=StrictUndefined, autoescape=True)
html = env.get_template("monthly.html.j2").render(
m=m, summary=summary, charts={"trend": "charts/trend.svg"},
generated_at=run_ts, data_snapshot=snapshot_id, period=period)
HTML(string=html, base_url=".").write_pdf(f"out/report-{period}.pdf")
Quarto with parameters (Jupyter engine; mark the parameters cell with the parameters tag):
quarto render report.qmd --to pdf -P period:2026-10 -P region:EMEA --output report-EMEA-2026-10.pdf
Per-variant batches: loop over parameter sets, render each to a deterministic file name, and write a manifest row per output.
Consistency checks (run every build)
import re, subprocess
pdf_text = subprocess.run(["pdftotext", "-layout", f"out/report-{period}.pdf", "-"],
capture_output=True, text=True, check=True).stdout
expected = {fmt_money(m.revenue), f"{m.orders:,}", m.top_region} # same formatters as the template
missing = [e for e in expected if e not in pdf_text]
assert not missing, f"report text does not show computed values: {missing}"
for leftover in (r"\{\{", r"\bNone\b", r"\bnan\b"):
assert not re.search(leftover, pdf_text), f"leftover {leftover} in report"
Also check: page count within the expected range, every chart file referenced exists and is non-empty, totals in tables equal the sum of their rows, the period label on page 1 matches the parameter. For narrative written by people or models, extract numbers from their text and confirm each one appears in the metrics table (after formatting) or flag it for review.
7. Deliver
Write out/manifest.json: parameters, data snapshot id and row counts, code version
(git rev-parse --short HEAD), generated timestamp, outputs with sizes. Put “Data as of
<snapshot date>” and “Generated <timestamp>” in the report footer. Emailing, uploading
to a shared drive or posting to chat requires the user’s explicit confirmation, with the
recipient list shown first.
Gotchas
- Percent of a percent: say “up 2 percentage points” for rate changes, not “up 2%”.
- Rounding: round once at presentation; summing rounded values gives totals that do not match. Show a note when displayed parts do not add up exactly.
- Time zones and period boundaries: define the period in one place (UTC or business time zone) and use it for both metrics and labels.
- Empty segments (a region with no sales) crash
idxmaxor produce blank charts; handle and say “no data” in the text. - Fonts in containers: install the report’s fonts in the build image, or PDFs fall back to a default font and reflow.
- Reports that query live databases at render time are not reproducible; snapshot first.
Output
## Report build: monthly-revenue 2026-10
- Data: warehouse extract 2026-11-01T02:00Z, 48,213 orders (snapshot a91f)
- Outputs: out/report-2026-10.pdf (9 pages), 6 regional variants
- Checks: metrics tests pass; computed values found in PDF text; table totals reconcile;
no leftover placeholders
- Not sent: awaiting confirmation for distribution list "exec-monthly" (11 recipients) 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: "Report Generation"
short_description: "Charts, numbers and prose from one source of truth"
default_prompt: "$report-generation Automate our monthly revenue report as a PDF with charts and a short summary from orders.parquet" 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.