---
name: reporting
description: "Use when you need a recurring report that ships to stakeholders on a cadence — a weekly team digest, monthly exec pack, quarterly board summary, or recurring client performance report — and it must go out reliably without a human in the loop. Symptoms: a standing metrics email keeps breaking or shows stale numbers, the Monday digest is hand-assembled every week, leadership asks for 'the same report but every month', or a client expects a PDF on a schedule. Triggers: 'set up a weekly automated report emailed to the exec team', 'build a monthly PDF report for our biggest client on a schedule', 'send the board a quarterly summary every quarter', 'our Monday metrics email keeps going out with last week's numbers — make it reliable', 'automatiza un informe mensual para dirección', 'munta un informe setmanal automàtic per al comitè'. NOT a live screen people open to slice metrics (that is dashboard), NOT deciding which KPIs to track (that is kpi-framework), NOT a one-off investigation (that is analytics)."
tags: [reporting, automated-reports, recurring, stakeholder-reporting, scheduled-delivery, data-analytics]
recommends: [dashboard, kpi-framework, analytics, automation-flows, email-connector, spreadsheet-ops, google-workspace, notion-connector, github-actions]
origin: risco
---

# 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, 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, 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 consistent, citable snapshot for the period far more often than a live feed; that is
why scheduled push beats a dashboard 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** |
| "Self-serve, slice by region/date on demand" | **dashboard** |

When both are wanted, build the report; let it link to the dashboard as a "go deeper" footer. Do not
email a dashboard link and call it a report — see the anti-patterns table.

## 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."

Never ship a raw metric dump with no narrative. 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; 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 |
| 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 exists and renders against a sample context without error, that the
generation script produces a non-empty PDF/HTML artifact, that a schedule definition exists (a crontab
line or a `.github/workflows/*.yml` with a `schedule:` block), and that a delivery step is wired. It
soft-warns if no freshness/failure gate is detected. On an empty/clean target it exits 0 — nothing to
check is not a failure.

References: [`pipeline.md`](references/pipeline.md) — full template, chart embed, per-recipient loop,
GitHub Actions `schedule:` YAML, freshness gate, and failure alert.
