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`).
npx skills add https://github.com/ericrisco/rsc-harness --skill reporting
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.
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.
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 |
| "Self-serve, slice by region/date on demand" | dashboard |
When both are wanted, build the report and let it link to the dashboard as a "go deeper" footer.
| 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 |
| Notion / living doc | Internal team, the doc evolves and gets commented | Page over file → notion-connector |
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.
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.
# 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.
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.
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:
Revenue | 248,300 | 201,400. The reader must do the math and guess if it matters.The numbers are the evidence; the sentence is the report.
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 |
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; for a Drive/Workspace destination see
google-workspace. The GH Actions schedule: YAML is in
references/pipeline.md.
Reliability checklist — every recurring report:
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.
are pure cost and noise.
| 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 |
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.
Take ericrisco/reporting from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.