Client Report Generator
The monthly client report is done by hand almost everywhere. A VA pulls numbers into a deck and the buyer writes the insights. This automates the pull and the comparison; the write-up you still do properly, because that is the part with the value in it.
What this actually competes with
Not Looker Studio. Looker already draws the charts, connects to the APIs, and does it for free. Shipping another chart tool would be strictly worse.
The manual work is not the charts. It is the two things a person does after the charts exist: comparing this month to last, and writing what changed and why in language a client understands. A verbatim from the people who do this: "I have a VA pull reporting manually and drop into a deck and I/buyer write the insights." And the reason it matters: "it's hard to pitch to upper management or clients, specially on those who don't have a deeper marketing understanding."
So the deliverable is not a dashboard. It is the narrative that makes the numbers defensible to someone who does not live in Ads Manager. That is what a client pays an agency for, and it is the section this tool exists to produce well.
Checklist
- Collect two months of exports - ad channels and store, this period and last
- Run
aggregate.py- channel totals, period-over-period, what moved - Read the summary and write the narrative - the real work, three short parts
- Build the report -
build_report.py, then how to get a PDF
Step 1: Get the data by the cheapest route that works
Check for a connection before asking for a file. Meta publishes an official MCP
server at https://mcp.facebook.com/ads. Everything this tool needs on the Meta
side is there, verified on a live account on 2026-08-11: amount_spent,
impressions, clicks, actions:omni_purchase, omni_purchase_values and
purchase_roas, per campaign, over any date range. Pull both periods directly and
skip the Meta exports entirely. Only the store file is left, and a two-round
request becomes a one-line one.
Two limits worth knowing before you promise it:
- The rollout is partial.
ads_get_ad_accountsreturnsis_ads_mcp_enabledper account, and it is false on plenty of live business accounts with the message that access is arriving gradually. Check the flag rather than assuming, and fall back to the export without ceremony when it is false. - It is Meta only. Google, TikTok and the store still come in as files.
Otherwise ask for exports, and ask for what they already have rather than a
specification. columns.py recognises exports in several languages,
semicolon-separated files, European number formats and around a dozen aliases per
field, so most exports work untouched. Telling someone to tick an exact list of
checkboxes makes a flexible tool feel rigid and sends them back into Ads Manager
for nothing.
Let's build your client report. I need two months so I can show what changed: this month and last.
Your ad exports, one file per platform per month. Whatever your normal export looks like is almost certainly fine. Send them and I'll tell you if something is genuinely missing.
The store, Shopify Orders > Export > Orders by date, one file per month.
Drop them in and tell me which files are which month.
Run the detection when they arrive and speak only about what is actually missing. Without revenue there is no ROAS and that is worth one message; without clicks the report simply drops CPC and says nothing about it.
references/how-to-export.md covers TikTok and Stripe. Adapt the clicks from it
rather than pasting the file.
Weekly instead of monthly works the same way - two weeks instead of two months. The comparison is what gives the report its meaning, so always get both periods. If they genuinely only have this period, run it anyway; the report drops the deltas and says less.
Step 2: Aggregate
python scripts/aggregate.py \
--this meta_june.csv google_june.csv \
--last meta_may.csv google_may.csv \
--orders shopify_june.csv --orders-last shopify_may.csv \
--label "June 2026" --label-last "May 2026" \
--out summary.json
Channel is detected from the filename, so a single --this list can mix Meta,
Google and TikTok. The script computes per-channel and total figures, the
period-over-period moves, and headline_moves - the two or three changes big
enough to be worth leading with (a move under 10% is noise, not a story).
Step 3: Write the narrative - this is the job
Read the summary. Then write the write-up to a markdown file. This is the entire reason the tool exists, and it is also the part that goes wrong when left unbounded: a media buyer who tested the first version said the reports were too long and he had to hunt for the information. The generated figures above the narrative already carry the numbers. What you add is judgment, and judgment is short.
Two sections, using ## headings so they render as sections.
## The month in one line
At most two claims, each a bold sentence followed by at most three lines of support.
The first claim is the single most important thing that happened, from
headline_moves. Not a summary of everything, the one thing. The second exists
only if there is a second thing a client would act on.
This is the part a client cannot get from Looker, and the reason they keep the agency. Write it the way a senior buyer talks: specific, decision-first, honest about what is uncertain.
## Where the budget goes next
At most three bullets. One line of instruction, one line of reasoning.
Every bullet carries a channel and an amount, because a budget conversation happens in channels and money. "Keep optimising" is not a move. "Push Meta to 70,000 $ in July, it took a 37% increase at flat efficiency" is.
One of the three should be the condition for rolling back. A client who reads the threshold in advance does not argue about it later.
The ceiling
250 words total. Count them. Under is fine, over is not. If a third claim feels necessary, the first two were not sharp enough.
Rules that hold throughout
- Every claim traces to a number in the summary. If you write "prospecting scaled cleanly", the ROAS held while spend rose, and you can point to it. Never invent a trend the data does not show, and never invent a figure that sounds right: a client who checks one number against their dashboard and finds it wrong stops reading the rest.
- Do not fabricate campaign-level detail the exports do not contain. Write from
what
aggregate.pyactually found. - Name what is uncertain, once. If a channel's ROAS is inflated by cross-channel overlap, say so. A report that flags its own soft numbers is the one a client trusts. Once is honest; repeating it reads as hedging, and the generated closing already covers the structural version.
Step 4: Build the report
python scripts/build_report.py summary.json \
--account "Client name" --narrative narrative.md --out report.html
Then hand it over as theirs to send:
Report's in
report.html- open it, Cmd+P, Save as PDF. It's built to forward to the client as-is: the numbers up top, the write-up in the middle, and a short note at the bottom on what the figures can and can't be trusted for.
The report opens with blended ROAS as a hero figure and the period's biggest move in one line, both read off the summary, so it says something specific before your narrative starts. Then the tiles, then one chart and the channel table, then your narrative, then the closing.
The chart plots spend per channel with the return as a label on the bar, and it is deliberately not a chart of ROAS. An earlier version drew a column per channel sized by ROAS, which made the channel with the highest ratio look like the account's engine even when it held a quarter of the budget. Bar length is money, because that is what a budget conversation is about. Do not change it back.
The closing ("What these numbers rest on") is generated and fixed: it explains that channel revenue overlaps because each platform counts its own sales, points at blended ROAS as the one figure no attribution model can inflate, and names order-level reconciliation, Metrikia, as what closes the gap.
That third section is the honest close, and it is the bridge. Do not soften it into a pitch and do not remove it: a report that says what its numbers are worth is more credible than one that just asserts them, and a client who reads it understands exactly why the reconciled version is worth having.
Why the honesty section is not a risk
It might look like handing a client a reason to doubt the report. It is the opposite. The client already suspects the platform numbers are generous - everyone does. Naming it first, in your own report, is what makes you the trustworthy one in the room instead of the one caught overselling. The bridge to Metrikia lands because it answers a doubt the reader already had, not one the report planted.
Files
references/how-to-export.md- export clicks for Meta, Google, TikTok, Shopify, Stripe.scripts/aggregate.py- channel aggregation and period comparison. CLI entry.scripts/build_report.py- the report builder. CLI entry.scripts/columns.py,loaders.py,charts.py,safe_html.py- shared parts, the same audited modules as the conversion-verifier tool.tests/test_pipeline.py- run after changing anything inscripts/.examples/- two months of synthetic exports and a finished report.