Automated marketing reports: build it once, never rebuild it
Most "automated" marketing reporting automates the wrong link in the chain. It schedules an export, or it emails a PDF on the first of the month. Meanwhile the parts that actually cost you a day — reconciling channels that count conversions differently, noticing that Search Console hasn't finalized yet, working out why the number moved, and rewriting the same commentary in slightly different words — stay manual. This piece is about automating the whole chain, including the middle, and about what changes when the thing doing the analysis is your own AI agent rather than a scheduler.
The five links, and which ones you've actually automated
Every recurring marketing report is the same pipeline:
- Collect — pull each source: Search Console, GA4, Meta Ads, Google Ads, plus whatever arrives as a CSV.
- Normalize — reconcile them so spend is spend, a click is a click, and a conversion means one thing across platforms.
- Gate — decide which days are complete enough to report. This is the link nobody automates and everybody gets wrong.
- Analyse — find what moved, and why it matters.
- Deliver — get it in front of the person who asked.
Scheduled exports automate link 1. Email automation automates link 5. Links 2 through 4 — the expensive ones — are where your Tuesday goes. Worse, automating only the ends creates a specific failure: a report that arrives reliably, on time, and quietly wrong, because nothing in the pipeline knew that Meta was still re-attributing last week's conversions.
Automation without a freshness gate is just faster wrongness. Search Console finalizes roughly two to three days late. Meta re-attributes conversions over a trailing window of about a week. Any automated report that treats "yesterday" as complete will publish a decline that isn't real — and then you'll spend the morning explaining it.
Automate link 2 by normalizing on ingest
The reason reconciliation never stays automated is that most setups do it at report time — a blend here, a VLOOKUP there, a hand-tuned mapping in a BI tool. Every new campaign naming convention breaks it.
TableBI moves that work to ingest. Connect each source once, and everything lands in a unified facts table with shared dimensions and shared metric definitions, plus the lossless per-platform raw tables underneath for when you need native detail:
# install once, and plant the skill so your agent knows the CLI exists npm i -g @tablebi/cli tablebi login tablebi install # one workspace per client or brand; OAuth opens in the browser tablebi connect gsc --site sc-domain:acme.com -w acme tablebi connect ga4 -w acme tablebi connect google_ads -w acme tablebi connect meta_ads -w acme
After that, the rate metrics are defined exactly once, as macros over sums — roas, cpa, cpc, cpm, ctr, cvr, aov. That detail sounds pedantic until you realize it's what makes the report reproducible: an automated pipeline that re-derives CPA per sheet will eventually average a column of averages and publish a number nobody can trace. Run tablebi schema -w acme --json to see the full contract your agent works against.
Refreshes are managed for you — sources sync on a schedule rather than because you remembered, and a pinned dashboard re-queries live data whenever it's opened. You can also force a pull whenever you need one:
# incremental by default; waits for completion unless you pass --no-wait
tablebi sync google_ads -w acme
Automate link 3: make the report refuse to lie
This is the step that turns automation from a liability into an asset. Before any recurring report gets written, check whether the data is actually ready:
# anything stale or unconnected, per source
tablebi pending -w acme --json
{
"asOf": "2026-07-27",
"overall": "fresh",
"sites": [
{ "platform": "search_console", "dataThrough": "2026-07-25",
"ageDays": 2, "stale": false }
]
}
Two facts fall out of that payload that a scheduler could never supply: the report should end at July 25, not July 27, and no source needs a human. Every --json response from any command carries the same kind of trust block — per-source freshness, the lineage behind each metric, and caveats that stop an agent drawing a confident wrong conclusion (for instance, that Search Console clicks and GA4 sessions are different populations and were never supposed to match).
Give your agent a standing instruction — rehydrate, gate on freshness, then report — and the pipeline becomes self-checking:
# one call: definitions, sources, freshness, existing dashboards
tablebi context -w acme --json
Automate link 4 without letting a model invent numbers
Here's the distinction that matters more than any feature: the numbers should be computed, the narrative should be generated. Tools that pipe your raw data into a hosted LLM and ask it for "insights" blur those, and you end up unable to say where a figure came from.
TableBI hosts no model. Figures come out of a deterministic SQL engine; the interpretation happens in your own Claude Code session, under your own key, at $0 inference cost to the backend. A month-over-month comparison is a query, not a prompt:
# this period vs the one before it, per channel tablebi ask "SELECT channel, SUM(CASE WHEN date >= (SELECT MAX(date) FROM facts) - 29 THEN cost END) AS spend_now, SUM(CASE WHEN date < (SELECT MAX(date) FROM facts) - 29 THEN cost END) AS spend_prev, roas(SUM(conversion_value), SUM(cost)) AS roas FROM facts WHERE date >= (SELECT MAX(date) FROM facts) - 59 GROUP BY channel ORDER BY spend_now DESC" -w acme --json
Then the agent does what agents are good at: reading that JSON, noticing that one channel's spend rose while its ROAS fell, checking the raw altitude to see whether it was one campaign or all of them, and writing two paragraphs a client can act on. The commentary is automated. The arithmetic never was.
Automate link 5 by deleting the deliverable
The fastest way to automate sending a report is to stop sending one. Pin the analysis instead — a public read-only URL that stores the query rather than a snapshot, so it's current every time the client opens it:
tablebi pin --title "Acme · monthly marketing" \ --widget "Spend by channel (30d)::line=SELECT date, SUM(cost) AS spend FROM facts …" \ --widget "ROAS by channel=SELECT channel, roas(SUM(conversion_value), SUM(cost)) AS roas FROM facts …" \ --widget "Organic clicks (90d)::line=SELECT date, SUM(clicks) AS clicks FROM search_console_raw …" \ -w acme ✓ published → https://dk.tablebi.com/d/dsh_… (public, read-only, self-refreshing)
Here's a real one, pinned exactly this way — live data, not a mockup: