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.

  • Documents & Office
  • $report-generation
  • 214 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- report-generation

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

~/.agents/skills/report-generation/SKILL.md

--- name: report-generation description: Builds reproducible data-driven reports that combine computed metrics, charts and written narrative, rendered to PDF, DOCX or HTML through Jinja2… license: MIT ---

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

NeedRoute
Polished PDF, designer control, CSSJinja2 HTML -> WeasyPrint (no browser) or Playwright Chromium (JS charts)
Recipients edit the report in Worddocxtpl template with InlineImage charts (see docx-templates)
Analysts work in notebooks, code and prose togetherQuarto (.qmd) -> PDF, DOCX or HTML
Interactive web reportStatic 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 idxmax or 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.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: "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.

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