# Reporting

> Use when a recurring report must ship itself on a cadence — weekly digest, monthly exec pack, board pack — via a fetch→narrate→render→deliver pipeline with a schedule and a freshness gate. NOT a live view people slice (that is `dashboard`), NOT picking which KPIs to track (that is `kpi-framework`), NOT a one-off investigation (that is `analytics`).

- Skill: `ericrisco/reporting` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add ericrisco/reporting`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/reporting/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/reporting

---


# reporting — the standing artifact that ships itself

A report is a **push** artifact: a fixed snapshot that lands in someone's inbox or shared drive on a
cadence, the same shape every period, with no human assembling it. That is the whole job. If a human
opens a live view to slice numbers themselves, that is **pull** — a
[`dashboard`](../dashboard/SKILL.md), not a report. Hold that line; almost every failed "reporting"
project is a dashboard wearing a report's name, or a report nobody can trust because the numbers went
stale and no one noticed.

Your deliverable is a **runnable pipeline**, not advice: a template, a generation script, a schedule,
and a delivery step, with a freshness gate so it fails loud instead of shipping yesterday's numbers.

## Step 0 — Write the report contract (no contract, no build)

Before any code, pin six fields. An undefined audience is a report no one reads; an undefined source
is a number no one can defend.

| Field | What it fixes | Example |
| --- | --- | --- |
| **Audience** | Who reads it → sets depth and tone | "5-person leadership team" |
| **Cadence** | When it ships → sets the scheduler | "every Monday 08:00 Europe/Madrid" |
| **Sections** | The fixed skeleton, same every period | Exec summary · revenue · pipeline · risks |
| **Metric source** | Where numbers come from + **who owns them** | `metrics.csv` from the data team |
| **Channel** | The artifact + transport | PDF over email |
| **Owner** | Who gets paged when it breaks | a named person, not "the team" |

Rule: you **consume** an agreed metric set — you do not adjudicate which KPIs matter. If the ask is
"which metrics should we even track?", that is [`kpi-framework`](../kpi-framework/SKILL.md), not this
skill. The contract names the source and its owner so a wrong number has an address.

## Decide: push report vs live dashboard (this branches — settle it first)

Audiences want a citable snapshot for the period more often than a live feed — that is why push wins
for exec summaries, weekly reviews, and finance close packs.

| Signal in the ask | Route |
| --- | --- |
| "Same summary, every week/month/quarter" | **reporting** (here) |
| "Consistent snapshot the board can cite in a meeting" | **reporting** (here) |
| "It must arrive even if no one logs in" | **reporting** (here) |
| "A screen people open to filter/drill live" | [`dashboard`](../dashboard/SKILL.md) |
| "Self-serve, slice by region/date on demand" | [`dashboard`](../dashboard/SKILL.md) |

When both are wanted, build the report and let it link to the dashboard as a "go deeper" footer.

## Pick the artifact format

| Format | Wins when | Notes |
| --- | --- | --- |
| **Email-body HTML** | Short digest, ≤1 screen, read on a phone | Inline the key numbers; no attachment to open |
| **PDF** | Exec / client / board, fixed layout that must look identical for everyone | The default for anything formal; pinned render stack below |
| **Spreadsheet** | Analysts who will re-filter and pivot the data themselves | Hand off the data, not a picture → [`spreadsheet-ops`](../spreadsheet-ops/SKILL.md) |
| **Notion / living doc** | Internal team, the doc evolves and gets commented | Page over file → [`notion-connector`](../notion-connector/SKILL.md) |

A board pack is a PDF; an analyst hand-off is a spreadsheet. Picking PDF for analysts who wanted to
pivot is the most common format miss.

## The four-layer pipeline: fetch → narrate → render → deliver

Keep the four layers separate so each is testable and the template never knows where data came from.

**Pinned stack** (Python, the most-documented 2025 PDF path — verified on PyPI 2026-06-02):
`pandas` (data) → `Jinja2` (HTML template) → `WeasyPrint` **68.1** (HTML+CSS → PDF), `Matplotlib`
for embedded charts. Pin WeasyPrint — major versions change CSS support, so an unpinned bump can
silently reflow a board pack. Re-check PyPI before you freeze, then freeze what you resolve.

```python
# render.py — minimal fetch → narrate → render. Delivery + charts live in references/pipeline.md.
import pandas as pd
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML  # WeasyPrint==68.1

def build_report(metrics_csv: str, params: dict) -> bytes:
    df = pd.read_csv(metrics_csv)                      # fetch
    df = df[df["region"] == params["region"]]          # per-recipient scope, NOT a new template
    cur, prev = df["revenue"].iloc[-1], df["revenue"].iloc[-2]
    ctx = {
        "title": f'{params["region"]} weekly revenue',
        "period": params["period"],
        "summary": narrate(cur, prev),                 # narrate (see below)
        "rows": df.tail(8).to_dict("records"),
    }
    env = Environment(loader=FileSystemLoader("templates"))
    html = env.get_template("report.html.j2").render(**ctx)   # render
    return HTML(string=html).write_pdf()
```

Rule: **one template, per-recipient params** — regional managers each get their region from the same
`report.html.j2`, scoped by `params`, never a forked template per recipient. A template-per-recipient
codebase rots the first time the layout changes. The full worked template, the Matplotlib base64 chart
embed, the per-recipient loop, the GitHub Actions workflow, and the freshness gate are in
[`references/pipeline.md`](references/pipeline.md).

## The narrative layer (the part that gets read)

Executives read the **executive summary and stop**. Lead every report with: the report's purpose in
one line, **3 key takeaways**, and **what changed versus last period**. Then per section, one
period-over-period delta sentence and one "so what". An annotated chart callout is retained; a bare
chart is not.

```python
def narrate(cur: float, prev: float) -> str:
    delta = (cur - prev) / prev * 100
    arrow = "up" if delta >= 0 else "down"
    flag = " — investigate" if abs(delta) >= 20 else ""    # anomaly callout
    return (f"Revenue {arrow} {abs(delta):.1f}% vs last week "
            f"(€{cur:,.0f} from €{prev:,.0f}){flag}.")
```

Bad → Good, the difference that decides whether the report is useful:

- **Bad:** a table row `Revenue | 248,300 | 201,400`. The reader must do the math and guess if it matters.
- **Good:** "Revenue up 23.3% WoW (€248,300 from €201,400) — investigate: driven by the enterprise renewal that won't recur next week."

The numbers are the evidence; the sentence is the report.

## Schedule + reliability gate (where reports actually die)

Reports die two ways: **stale data** ships silently, and **a silent break** means no one notices for
weeks. Treat the schedule as managed infrastructure.

**Where to run it:**

| Situation | Scheduler |
| --- | --- |
| You already run a server / cron host | **cron** (`0 8 * * 1` = Mondays 08:00) |
| No server, want zero infra | **GitHub Actions** `on: schedule: - cron:` — the recommended cloud option |
| Multi-app event wiring beyond a timer | route the plumbing to [`automation-flows`](../automation-flows/SKILL.md) |

This skill owns the report-shaped concern (contract, narrative, artifact) and *uses* a scheduler — it
does not teach scheduling in general. The actual transport (SMTP/Gmail send) belongs to
[`email-connector`](../email-connector/SKILL.md); for a Drive/Workspace destination see
[`google-workspace`](../google-workspace/SKILL.md). The GH Actions `schedule:` YAML is in
[`references/pipeline.md`](references/pipeline.md).

**Reliability checklist — every recurring report:**

- [ ] **Freshness gate before send** — assert the source is newer than the last period; if stale, do
      NOT send, raise instead. A report that consumes dirty data is not this skill's job to clean —
      that is [`data-cleaning`](../data-cleaning/SKILL.md); this gate only refuses to ship
      known-stale numbers.
- [ ] **Fail loud** — on any error, alert the owner (the report not arriving is itself a silent failure).
- [ ] **Idempotent run** — re-running for the same period produces the same artifact, no duplicate send.
- [ ] **Dead-report cleanup** — if no one opened the last N editions, kill the schedule. Unread reports
      are pure cost and noise.

## Anti-patterns

| Anti-pattern | Why it bites | Do instead |
| --- | --- | --- |
| Email a live dashboard link, call it "the report" | Recipient must log in and slice; most won't, the snapshot is lost | Ship the fixed artifact; link the dashboard as a footer |
| No freshness gate | Stale numbers ship silently; trust dies on the first wrong figure | Assert source newer than last period; refuse to send if stale |
| One mega-report for every audience | Execs drown in analyst detail; analysts can't refilter a PDF | Contract per audience; one template, per-recipient params |
| Raw metric dump, no narrative | Reader does the math, misses what changed, stops opening it | Exec summary + 3 takeaways + a "so what" per section |
| Schedule with no failure alert | A broken job is invisible for weeks; the report just stops | Fail loud to the named owner on any error |
| A template forked per recipient | Layout change must be made N times; they drift | Single template scoped by `params` |
| Redefining KPIs inside the report | The report quietly becomes the metric authority, numbers diverge | Consume an agreed set; defer definition to [`kpi-framework`](../kpi-framework/SKILL.md) |
| Unpinned WeasyPrint / render lib | A minor bump reflows the board pack with no warning | Pin (WeasyPrint 68.1) and re-check PyPI before changing |
| Hand-assembling the digest weekly | It breaks the week the owner is on leave | Automate the pipeline end to end; a human only reads it |

## Verify

Run `scripts/verify.sh` (read-only; pass a path to a generated pipeline directory, or run in it). It
confirms a Jinja2 template renders against a sample context without error, the generation script
produces a non-empty PDF/HTML artifact, a schedule definition exists (a crontab line or a
`.github/workflows/*.yml` with a `schedule:` block), and a delivery step is wired. It soft-warns if no
freshness/failure gate is detected, and exits 0 on an empty/clean target — nothing to check is not a
failure.

