Academyby Dasow

Stop 3 of 10 · Weekly

The reporting agent

Pull three platforms into one row shape, write twenty reports with the Claude API from those rows only, and hold every one in a review queue until a person reads it.

Watch first · 0:53

The job

First working day of the month, twenty reports. Nour's old pipeline read CSV exports somebody downloaded by hand. The agent waits for nobody: it pulls from the Google Ads API, the Meta Marketing API and the GA4 Data API on a schedule, writes the numbers to disk, and only then asks a model to write prose over them. Three stages, three programs, one directory per run.

Stage File Output
Pull pull.py runs/2026-10-01/A07/metrics.json and manifest.json
Write write.py runs/2026-10-01/A07/report.md
Check check.py pass or a list of figures with no source

Separate stages let you rerun the writing without paying for the pull again, and let the checker run over a draft nobody has read yet.

Stage one: pull and normalise

The three platforms disagree about everything: what a conversion is, what a date range means, what a currency looks like. Normalise once, in the pull, into one row shape you define, and take the account ids from the registry server rather than from a constant in the file.

Build the pull
Write pull.py for our agency. It takes a run date, reads the account list and platform ids from our registry MCP server, and for each account pulls last month and the month before from the Google Ads API, the Meta Marketing API and the GA4 Data API using the read-only credentials from config.py.

Normalise everything into one row shape: account_code, platform, campaign, spend, impressions, clicks, conversions, and the date range as two dates. Write runs/<date>/<code>/metrics.json with the rows, the totals per platform, the blended totals and a currency field.

Check the current request shapes in the official documentation for each API rather than writing from memory, and tell me which pages you used.

Wrap each account and each platform separately. On failure, retry twice with a growing wait, then write a missing entry for that platform into metrics.json and a row into runs/<date>/manifest.json with the account, the platform, the error and the time. Never let one failure stop the run.

Stage two: write, from the numbers only

The model writes the report. It does not calculate it. Give it metrics.json, the template and one instruction that matters more than the rest: no figure that is not in the payload.

Build the writer
Write write.py. For each account directory in a run, call the Claude API once with metrics.json and template.md, and save the result as report.md in the same directory. Read the current model ids and request shape from docs.claude.com rather than assuming.

The system prompt says: you are writing a monthly report for a paid media client. Use only figures present in the JSON payload and never calculate a new one. Where a platform is marked missing, write one sentence naming it and saying the data was unavailable. Follow the template section order exactly, write plain sentences with no superlatives, and give the largest month over month movement its own paragraph with the campaign named.

Log the model id, the token counts and the wall time per account into the run manifest, because stop nine reads that.
Build the number checker
Write check.py. It reads report.md and metrics.json for an account, extracts every number in the report including currencies and percentages, and asserts each one either appears in metrics.json or is a percentage derivable from two figures that do. Print the failures with the sentence around them.

Write test_check.py with three drafts: one clean, one carrying a spend figure that is not in the payload, and one carrying a correct percentage derived from two payload figures. The middle one must fail and the other two must pass.

The schedule, and the queue in front of the client

Put the three stages on a cron entry, early enough that the drafts are waiting when the agency opens. What a schedule never changes is who is accountable. Passing drafts land in a review queue with a status field, and the send step refuses anything not marked approved by a named person.

Quick check

Try it

Report a bug or share feedback