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`).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill reporting --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Reporting?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-reporting)More formats (shields.io, HTML) on the badges page.
---
name: reporting
description: "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`)."
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`](../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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!