How do I automate my agency's client reporting with AI
Pull GA4, Search Console, and Ads as numbers. AI drafts the narrative. A named human approves before any client email. Per-client credentials, never shared.
William Spurlock Founder — Spurlock Studios 30 MIN
You automate an agency’s client reporting with AI by splitting the job: Google APIs own the numbers, a model drafts a narrative that may only cite those numbers, and a named human approves the packet plus draft before any client email. n8n pulls GA4, Search Console, and Google Ads into one dated packet per client. The model never holds the send credential. A shared “agency Google” login is not a reporting system.
This spoke sits under the Production n8n handbook. Credential walls and instance-per-client design live in multi-client n8n isolation. This page is the reporting pipeline: pull → packet → draft → approve → send. Across 600+ automations built and 500+ live, the graphs that survive treat the model as a copywriter with a closed spreadsheet, not as an analyst who can invent a YoY.
The short answer
- Pull numbers first. One packet per
clientId+ closed period. Property IDs, site URLs, and Ads customer IDs are fields, not folklore. - Draft second. The model writes prose against the packet. Missing prior-period data means no ”% vs last month” sentence.
- Approve third. A named AM or analyst sees numbers table + draft + sources. Approve / Reject / Request edit. Timeout holds.
- Send last. Email or portal write uses a frozen packet and an idempotency key. Replay the send step, not the whole month.
- Do not invent hours saved. Measure send-without-approve, invented citations, duplicate period sends, and time-to-approve. Whether the week of build is even worth it is automation ROI without fantasy spreadsheets.
| Hop | Who owns it | Allowed to guess? |
|---|---|---|
| Pull | GA4 / GSC / Ads APIs via per-client credentials | No. Empty source → exception, not a filled cell |
| Packet | Deterministic code | No. Totals and rates copy from the API |
| Draft | Model | Prose only. Citations must match packet keys |
| Approve | Named human | Judgment — tone, caveats, “do not send this client this line” |
| Send | Email / portal API | No. Keyed send after humanStatus === approved |
The model drafts the letter. The APIs own the math. The human owns the client relationship.
How do I automate my agency’s client reporting with AI?
You do not “turn on AI reporting.” You build one loop every client period has to survive: pull → packet → draft → approve → send. AI belongs in draft. Client-visible mail belongs after a person (or a written class they signed) says so.
n8n’s human-in-the-loop for tools list is purchases, external communications, and deletes. A client report email is the middle one with a brand attached. Do not give the model a Gmail tool and hope HITL saves you. Leave send as a later node that never sees the chat completion as its credential.
| Job | Automation may | Human must | Graph must never |
|---|---|---|---|
| Monthly performance email | Pull closed-period metrics into JSON | Confirm numbers + tone before send | Let the model call SMTP |
| Weekly pulse Slack to the AM | Packet + one-screen table | AM forwards or kills | Auto-ping the client Slack |
| PDF appendix of the numbers table | Render from the packet | Attach only the approved period | Generate charts from invented series |
| Portal “report ready” flag | Write needs_review | Flip to sent after approve | Mark sent because the draft “looked done” |
| YoY / wow commentary | Cite prior-period fields if present | Kill any % not in the packet | Compute a story from a missing cell |
| Anomaly callout | Flag deltas the packet already calculated | Decide if the client hears it | Invent a cause (“algorithm update”) |
| Looker Studio live dashboard | Separate product — share the property | Client logs in; no email send | Treat a dashboard as this rail |
| Cross-client “benchmark” | Stop | Legal + contract first | Blend tenants to make a rank |
Procedure — the only v1 that is honest:
- Pick one client and one closed period (last full month, not “through yesterday”).
- Write the packet schema before you pick a model.
- Pull GA4, then GSC, then Ads, each with that client’s credential and ID.
- Fail closed on missing source, quota, or auth. Do not draft on a partial packet unless the schema marks the source optional and the narrative is banned from citing it.
- Draft into
needs_review. Wait. Human acts. Then send once.
If you cannot name the period bounds and the three IDs on one card, you are not ready to automate the letter.
What belongs in the numbers packet versus the narrative?
The packet is a typed object. The narrative is a string that may only reference keys in that object. Mix those jobs and the model will “helpfully” fill a blank CTR.
Write the schema first. Example shape — names can change; the rule cannot:
| Field | Type | Rule |
|---|---|---|
clientId | string | Your tenant key. Never the Google email. |
periodStart / periodEnd | YYYY-MM-DD | Closed range. Inclusive as the vendor defines it. |
ga4.propertyId | string | properties/1234 form used by runReport |
gsc.siteUrl | string | Exact Search Console property URL, encoded in the path |
ads.customerId | string | Digits only. No hyphens. Sub-account ID, not the manager ID |
timezones.ga4 / gsc / ads | string | Recorded, not assumed equal |
metrics.* | number or null | Copied from API. null beats 0 when the call failed |
priorPeriod | object or omitted | If omitted, narrative must not say “vs last month” |
freshness.gscLastDate | date or null | Last date GSC actually returned |
quota.ga4TokensUsed | number or null | From returnPropertyQuota: true when you ask for it |
Narrative is allowed to: restate a metric, compare two present numbers, list top queries that exist as rows, and flag a delta the packet already computed.
Narrative is forbidden to: invent a missing source, reconcile GSC clicks with GA4 sessions into “one traffic number,” name a Google core update, or attach a dollar revenue figure that did not come from the Ads or GA4 payload.
Checklist before the model sees the packet:
- Every metric has a
sourceenum (ga4/gsc/ads) - Rates (CTR, CVR) are copied, not recomputed in the prompt
-
nullstaysnullin the prompt JSON — do not stringify as"" - Prior period is either complete or absent
- Timezones are listed on the approval card
- No field named
hoursSavedoraiImpactPct
GSC clicks and GA4 sessions measure different events. Search Console Help lists time lag, Pacific Time bucketing, and tool differences as reasons the numbers will not match Analytics. The packet keeps both. The letter does not average them.
How do I pull GA4 without mixing clients?
Each run loads one GA4 property ID and one credential that can read that property. A default credential with a propertyId overlay is how Client B’s sessions land in Client A’s email.
Use the Data API properties.runReport POST to https://analyticsdata.googleapis.com/v1beta/{property=properties/*}:runReport. Scope: https://www.googleapis.com/auth/analytics.readonly (or the broader analytics scope — prefer readonly for a report pull). The property identifier sits in the path, not as a hope in the body.
| Pull rule | Pass | Fail |
|---|---|---|
| Credential | Service account or client OAuth for that property | Agency founder’s Google login reused |
| Property | properties/{id} from the client’s GA4 admin | Hard-coded studio property |
| Date range | Closed calendar period in the property timezone | yesterday on a Monday “monthly” job |
| Metrics | Named in the request (sessions, keyEvents, …) | Prompt asks the model to “estimate sessions” |
| Quota | returnPropertyQuota: true on the requests you care about | Silent 429, empty packet, draft anyway |
| Sampling | Inspect response metadata when present | Treat a sampled UI screenshot as the API |
Data API quotas (as published for standard properties): Core tokens 200,000 per property per day, 40,000 per property per hour, 14,000 per project per property per hour, 10 concurrent core requests. Analytics 360 limits are higher on that same page. Token cost varies by request. The page tells you to read PropertyQuota on the response rather than guess. Hedge: those numbers move; re-read the quota doc when you size a 40-client Monday batch.
GA4 data freshness is not an SLA. Standard intraday is typically 2–6 hours; daily processing is often 12+ hours and can run 24–48 hours. Some data arrives late. Do not pull “this month through this morning” and let the model announce a final month. Close the period.
Procedure:
- Resolve
clientId→{ ga4PropertyId, ga4CredentialId }from your tenant table. - If either is missing, dead-letter. Do not fall back to another client’s row.
runReportwithdateRanges, at least one metric, dimensions you actually need.- Store raw JSON + normalized metrics. Keep the execution ID.
- On 401/403, pause that client’s schedule, not the whole agency graph.
A 40-client loop that shares one OAuth client and swaps property IDs in a Code node is still one credential boundary. Isolation of the instance is the other post. Here the rule is simpler: the credential used in the node must be the credential that was granted on that property.
How do Search Console and Google Ads join the same packet?
Same pattern: one ID, one credential, one closed range, then merge in code. Different clocks. Different row limits. Different “empty” meanings.
Search Console
searchanalytics.query is POST https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/searchAnalytics/query. startDate and endDate are required YYYY-MM-DD. Official docs put those dates in Pacific Time. Authorize with webmasters.readonly unless you truly need write.
Google’s performance-data how-to is the agency-relevant page:
- Data is typically available after 2–3 days. Probe the last 10 days grouped by
dateto see what is actually present. - The API does not guarantee every row. It returns top rows. Max 50,000 rows per day per search type.
- Grouping by page and/or query may drop some data so the system can finish.
- Page with
startRowin 25,000 increments until a page returns 0 rows.
If periodEnd is yesterday, GSC may not have that day yet. A missing last date is not “zero clicks.” Record freshness.gscLastDate. Ban the narrative from calling the month “complete” on GSC if the last returned date is older than periodEnd.
Google Ads
Use GoogleAdsService.Search or SearchStream with GAQL. SearchStream streams the full result; Search pages at 10,000 rows. The REST path is versioned (/vN/customers/{customer_id}/googleAds:searchStream). Read the current page when you implement — the vN moves.
Google’s reporting example is the landmine for agencies:
If you want to retrieve data for a sub-account, you must use that sub-account’s ID. Querying with a manager account ID only returns data directly owned by that manager account and does not include data from its sub-accounts.
An MCC / manager ID is not a fan-out. Loop clients. Each row in your tenant table has that customerId (no hyphens) and a credential that can read it. You also need a developer token on the request. Do not pretend the n8n Google Ads node invents one.
metrics.cost_micros is micros. Divide in code. Do not ask the model to “make it dollars.”
| Source | Clock | Empty / incomplete | Merge rule |
|---|---|---|---|
| GA4 | Property timezone | Freshness delay; quota | Closed period only |
| GSC | Pacific Time on daily buckets | 2–3 day lag; dropped query/page rows | Last returned date on the card |
| Ads | Account timezone | Manager ID returns the wrong account | Sub-account ID only |
Merge procedure:
- Pull each source into its own object with
ok: true|falseanderrorif any. - Join on
clientId+ period. Do not join on domain string guesses. - If GSC and GA4 date bounds disagree because of timezone, keep both bounds in the packet. Do not shift GSC into the GA4 zone inside the prompt.
- Optional sources (client has no Ads) must be explicit
ads: { ok: false, reason: "not_in_scope" }, not silent omission that the model fills.
Looker Studio can still be the live dashboard the client opens. That is not this email rail. Do not scrape Looker. Pull the APIs.
How do I implement this in n8n?
One workflow per cadence (monthly is the honest v1). A Switch or a tenant-table loop loads one client per execution item. Shared graphs are fine. Shared credentials are not.
Spine:
- Trigger — cron after GSC’s lag (many teams run monthly on the 4th, not the 1st). Hedge the day against your own freshness probe, not a folklore “Google is done on Tuesday.”
- Load tenant row —
clientId, IDs, credential names, AM Slack ID, to-email,reportType. - Claim idempotency key —
report:{clientId}:{periodStart}:{periodEnd}:{reportType}before any pull that you would hate to double-pay in quota, and again before send. Same spine as the handbook. - HTTP Request (GA4) — credential = that client’s GA4. Path includes
properties/{id}. - HTTP Request (GSC) — credential = that client’s GSC.
siteUrlencoded. - HTTP Request (Ads) — credential + developer token + sub-account
customerId. - Code / Set — build packet. Fail closed. Write packet to Data Store / Postgres / Airtable with
status: packet_ready. - LLM node — input = packet JSON + system rules. Output =
{ narrative, citedKeys[] }only. No send tools attached. - Validate citations — every
citedKeys[]exists on the packet and is non-null. Else exception queue, no Wait. - Wait — Wait node on webhook or form, or Slack send-and-wait. Card shows table + draft + IDs + timezones + freshness.
- Switch on human action —
approved→ send.rejected/edit→ hold. Timeout → hold. - Send — email node uses the send credential, frozen packet, frozen narrative. Store vendor message id. Mark key
completed.
| Node | Credential it may hold | Credential it must not hold |
|---|---|---|
| GA4 HTTP | Client GA4 readonly | SMTP / CMS publish |
| GSC HTTP | Client GSC readonly | GA4 of another client |
| Ads HTTP | That Ads customer | Manager-only token used as if it were the client |
| LLM | Model provider | Any Google client, any mailbox |
| Wait / Slack | Staff notify | Client-facing mailbox |
| Email send | Agency or client SMTP after approve | Model provider |
Wait details that bite:
- Resume URL is
$execution.resumeUrl, unique per execution. Partial re-runs change it. The node that sends the card must run in the same execution as Wait (Wait docs). - Limit Wait Time, when on, automatically resumes after the limit. That resume is not an approval. Branch it to
status: held_timeout. Never to SMTP. - Wait times under 65 seconds stay in-process. Monthly approve is days — execution offloads to the database. That is expected.
If you insist on an AI Agent, attach HITL to the send tool and still keep send credentials off the model. Better: no send tool. The agent that can mail the client will, eventually.
Checklist for v1 canvas:
- Tenant table has three IDs and three credential references
- Cron is after your GSC date probe, not “1st 9am”
- Packet stored before LLM
- Citation validator before Wait
- Timeout path ≠ send path
- Error workflow named owner (AM + ops)
- Forced tests: wrong Ads customerId, GSC 2-day-missing end, duplicate cron
What is the AI allowed to write?
A cover letter. Not a second analytics product. The prompt gets the packet and a ban list. The output schema is small on purpose.
| Allowed | Banned |
|---|---|
“Sessions were 12,400 (ga4.sessions)” | “Traffic is up ~20%” with no prior period |
| “Top query by clicks: {row from gsc.topQueries[0]}” | A query that is not in the rows |
“Spend was $X (ads.cost from micros in code)” | A ROAS the packet did not contain |
| “GSC last date in this pull is D — treat search as incomplete” | “Search was flat” when gsc.ok === false |
| “GA4 and GSC still will not match; they never did” | One blended “visits” number |
| Tone pass for this client’s voice, after numbers are locked | Cause: “core update,” “seasonality,” “competitor” unless a human typed it |
Output JSON:
{
"narrative": "plain text or markdown",
"citedKeys": ["ga4.sessions", "gsc.clicks"],
"needsHumanLine": ["optional flags for the AM"]
}
Procedure for the citation gate:
- Parse
citedKeysas a list of paths. - Resolve each path on the packet. Missing or
null→ fail. - Regex the narrative for
%,$, and integers over a threshold you set. Each hit must map to a cited value. Unmapped number → fail. - Fail → exception queue with the draft attached. Do not Wait. Do not send.
- Pass → Wait.
This is not “model accuracy.” It is a checksum. Confidence is not a control. If the vendor’s JSON Schema node is easier than a Code node, use it. The rule is the same: unknown fields null, extra invented metrics rejected.
Do not ask the model to pick the date range. Do not ask it to choose the property ID. Those are tenant-table fields.
Why must a human approve before any client email?
Because the client does not care that your canvas was green. They care that you attributed a ranking drop to a story you made up, or mailed another brand’s spend.
The gate is a boolean on this packet: humanStatus === approved for clientId + period + reportType. Slack emoji on a channel is not that boolean. A Wait timeout is not that boolean. A “high confidence” score is not that boolean.
n8n is explicit that external communications are HITL territory (human-in-the-loop for tools). Reporting mail is client communication. Put a person on it until you have a written promotion rule — and even then, promotion is “skip the draft rewrite,” not “skip the send gate,” until you have a dated window of zero invented-citation incidents.
| Card field | Why it is there |
|---|---|
Client name + clientId | Wrong-tenant catch |
| Period + three timezones | Clock catch |
| Numbers table (packet, not the prose) | AM checks math without trusting the letter |
gscLastDate / GA4 freshness note | Incomplete-month catch |
Ads customerId last four | MCC catch |
| Draft narrative | Tone + banned-cause catch |
| Approve / Reject / Edit | Explicit. No “react with checkmark” |
| Actor + timestamp | Audit |
Approval procedure:
- AM opens the card (Slack, n8n form, or internal URL).
- Scans the table against the letter. Any number in the letter missing from the table → Reject.
- Kills any causal claim they did not ask for.
- Approve writes
humanStatus,actor,approvedAtonto the stored packet. - Only then does the send node run, with that stored row as input — not the LLM’s live output.
If the AM is on PTO, the backup is a named person, not Limit Wait Time. Timeout holds the month. A late report beats a wrong report.
Autonomy you can promote later, if you must: skip the tone rewrite for a client class that never edits. Keep the send gate. Keep citation validation. Keep per-client credentials. That is the honest ceiling for v1 and v2.
What breaks this in production?
The failure that costs you the retainer is a send. Everything else is recoverable if the gate holds.
| Failure | What you see | Cost | Do this instead |
|---|---|---|---|
| Shared agency Google credential | Client B metrics in Client A’s PDF | Incident + possible contract issue | Per-client credentials; see isolation for the wall around the box |
Ads manager ID in customers/{id} | Tiny or empty Ads section that looks “quiet” | You report the MCC’s own campaigns | Sub-account ID from the tenant table |
| GSC lag treated as zero | “Clicks fell off a cliff” on the 1st | Panic email, then a correction | Probe last 10 days; run after 2–3 day lag |
| Wait timeout → send | Emails fire at 8am because nobody clicked | Unreviewed narrative in the client inbox | Timeout → held_timeout |
| Model invents YoY | Beautiful % , empty priorPeriod | You are now the unreliable narrator | Citation gate |
| Retry after SMTP 500 | Two emails, same month | You look sloppy | Idempotency key on send |
| Quota 429 mid-loop | Partial packet, draft still runs | Mixed completeness across clients | Fail that item; do not draft |
| Timezone mash | GA4 Tuesday ≠ GSC Tuesday | Arguments in the QBR | Print clocks on the card |
| Scraped Looker instead of APIs | Layout drift, login walls | Brittle Monday | Data API + Search Analytics + GAQL |
| Agent with Gmail tool | HITL ignored once, then forever | The actual nightmare | No send tool on the model |
Forced tests before the second client:
- Duplicate cron the same period — second send must no-op.
- Swap Ads
customerIdto the manager ID in staging — packet mustads.ok === falseor show a loud mismatch check, not a quiet empty. - Pull GSC with
endDate= yesterday — card must show incomplete freshness, not “0 clicks.” - Strip
priorPeriod— any%in the draft must fail the citation gate. - Let Wait expire — mailbox stays empty.
Poison payloads go to a replayable store with the original packet, execution ID, and error — same DLQ idea as the handbook. Do not retry send until a human says the packet is the packet.
Auth drift (revoked GA4) pauses that client. It does not disable the other 39. If your architecture cannot pause one tenant, you are not looping clients. You are sharing a kitchen. That is the isolation post.
How do I measure the loop without inventing hours saved?
Track control of the send. Do not track a fantasy ”% of reporting week recovered.” I will not publish a studio-wide hours-saved figure for this rail. The 35,000+ hours saved receipt is aggregate client busywork across the book of work, not a reporting-pack KPI you can copy onto a sales deck.
| Metric | How you know | Target |
|---|---|---|
| Send without approve | Count of SMTP successes where humanStatus !== approved | Zero |
| Timeout-sends | Sends whose Wait ended via Limit Wait Time | Zero |
| Invented citation | Citation-gate fails + any that slipped past (manual tag) | Down; zero slipped |
| Duplicate period send | COUNT(*) GROUP BY clientId, period, reportType where sent | Zero |
| Time-to-approve | approvedAt - packetReadyAt vs your SLA | Named SLA, not a guess |
| Reject reasons | Coded: wrong_tenant, invented_%, tone, incomplete_gsc, … | Readable log |
| Source fail rate | ga4.ok / gsc.ok / ads.ok per week | Dated; not a vendor SLA |
| Human edit rate | Share of approves that used Edit first | Context, not a vanity drop |
Observation window — dated, exportable:
- Every packet:
clientId, period, sourceokflags, execution ID - Every Wait: actor or
timeout - Every send: vendor message id, idempotency key
- Duplicate query in the Friday pack
- No field named
hoursSavedPct,aiWriteoff, oranalystFte
ROI for building the rail is still the ROI mindset: observed hours on this path, failure cost of a bad send, maintenance. Ranges. Not a calculator that outputs 37%.
If leadership wants “AI saved 12 hours per AM,” run a two-week diary on the current manual process before you build, then a two-week diary after. Same clients, same cadence. If you did not collect the before, you do not have a after. You have a story.
When should I hire vs DIY this automation?
DIY the loop when one AM will click every card, you have three APIs on one client, and a wrong send is an apology not a lawsuit. Book the $500 Automation Audit when you are looping tenants, mixing Ads manager IDs, or planning to skip the gate.
| Situation | DIY | Audit / build |
|---|---|---|
| One client, one monthly email, you watch every card | Yes | — |
| Three sources already in the client’s own Google, readonly | Yes | — |
| Staging: duplicate cron still one email | Yes to go live on that client | — |
| Auto-send on “the draft looks good” week one | No | Yes — to keep send behind the boolean |
| 15+ clients on one Community box with shared OAuth | No — stop | Yes, and read isolation first |
| Ads pulled from the MCC id “because it’s easier” | No | Yes |
| Client-facing Slack / email from the LLM node | No | Yes |
| Nobody named owns Tuesday approve | No live send | Extract-to-card can still exist |
| You need a time-saved % for a pitch deck | Do not build that metric | Yes, to keep it off the graph |
| Volume is two reports a month and already clean | Maybe not worth a canvas | Only if the cost is learning the spine |
Decision list:
- If you cannot pause one client in two minutes, you are not in DIY-live.
- If the AM will not name an approver and a backup, you can still pull packets to Slack. You cannot send.
- If the only “AI” request is a dashboard of hours saved, decline the metric. Build pull-approve-send or build nothing.
- If credentials are still personal logins, fix seats before you schedule a Monday batch.
DIY is a smaller loop, not a sloppier one. Citation gates and keys still ship.
What should I skip if I only have a week?
Skip autonomy theater. Ship one client, one closed month, one email path.
Do this week:
- Tenant row: three IDs, three credentials, AM, to-email.
- Packet schema + ban list (
hoursSaved, blended traffic, causal Google stories). - GA4
runReport+ GSC date probe + AdsSearchStreamon the sub-account. - LLM →
{ narrative, citedKeys }+ citation gate. - Wait card with table + Limit Wait Time → hold.
- Idempotent send. Forced duplicate test. Forced manager-ID test. Forced missing-GSC-day test.
- Runbook: who pauses, who approves, where held packets live.
Skip this week:
- Auto-send, even on “the AM always approves”
- All clients on the same Google credential
- Agent-with-Gmail-tools
- Scraping Looker Studio
- YoY commentary without a prior-period pull
- A dashboard tile named hours saved
- Cross-client benchmarks
- Same-day-as-period-end cadence
A week of one honest loop beats a month of a model that mails.
When is this not worth doing yet?
Skip the canvas when you cannot name the three IDs, nobody will click the card, or the report is already a 10-minute paste you do twice a month. AI does not fix a reporting process that does not exist.
| Blocker | Why the loop fails | Do this first |
|---|---|---|
| GA4 property unknown / mixed with UA folklore | Pull will hit the wrong dataset | Admin screenshot of property ID in the tenant row |
| GSC property is a domain property vs URL prefix mismatch | 403 or empty | Copy the exact resource from Search Console settings |
| Ads only at manager level | You will report the wrong account | List client customer IDs |
| Personal Google logins | Graph dies on PTO | Service accounts or client-owned OAuth, added to their properties |
| No AM owner | Cards rot; timeout becomes policy | Name approver + backup |
| Success = “save 80% of reporting” | You will invent a % | Diary the current hours or walk away |
| Clients expect same-day-end numbers | GSC/GA4 freshness will fight you | Move cadence; say so in the SOW |
| Cannot pause one tenant | Blast radius is the roster | Isolation work before the mail rail |
Go-live pause test — if any box is empty, keep send off:
- Named human can disable the workflow in two minutes
- LLM credential cannot see SMTP
-
clientIdis visible on the approval card - Limit Wait Time holds
- Duplicate query on period+client is in the Friday pack
- Ads path uses sub-account IDs
Worth doing the moment reporting is weekly-or-monthly, the numbers already live in GA4/GSC/Ads, and a human will still own the client email. That is the whole product.
How is this different from isolating client n8n?
This post is the reporting pipeline. The isolation post is the tenant wall. You need both. Building a beautiful pull-approve-send graph on a shared Community credential store is how you mail the wrong client with the right spine.
| Question | This post | Isolation post |
|---|---|---|
| What is the product? | Packet → draft → approve → send | Instance / Projects / license / offboarding |
| What is the blast radius that hurts? | A client email | Another client’s OAuth token |
| Folders | Irrelevant to the letter | Not tenancy |
| MCC / manager Ads ID | Wrong report payload | Wrong if that credential is also shared |
| Human gate | Before SMTP | Before handing a client the editor |
| License / SUL | Out of scope here | Confirm with n8n before you host their keys |
You can implement this pipeline on instance-per-client (cleanest) or on a licensed project model you actually verified. Do not use this spoke as permission to dump every client’s GA4 into one n8n Cloud with folders named after brands. Folders organize canvases. They do not sandbox tokens.
If you are still arguing about Community folders, stop this build. Finish isolation. Then come back and wire runReport.
FAQ
How do I automate my agency’s client reporting with AI?
Pull GA4, Search Console, and Ads into one dated packet per client with that client’s credentials. Let a model draft a narrative that may only cite packet keys. Require a named human to approve that packet plus draft, then send with an idempotency key. The model never holds SMTP. A shared agency Google login is not a reporting system.
How do I measure whether automated client reporting is working?
Track send-without-approve (target zero), timeout-sends (target zero), citation-gate fails, duplicate period sends, time-to-approve versus SLA, and coded reject reasons. Do not invent a time-saved percentage, an “AI writeoff,” or a studio-wide hours figure for this rail. If you want hours, diary the manual process before and after on the same clients.
What usually fails first when teams try this?
A Wait timeout that sends, or an Ads pull against the manager account that looks like a quiet month. Close second: GSC’s 2–3 day lag treated as zero clicks, and a model that invents YoY because priorPeriod was missing. Shared OAuth across clients is the incident that makes the first three look small.
How long does this take to show results?
You should see the AM reviewing a card instead of rebuilding the numbers table once one client, one closed period, and the citation gate are live. I will not invent a days-to-hours-saved or payback figure. Early proof is one email per period, zero timeout-sends, and a reject log you can read.
What should I skip if I only have a week?
Skip auto-send, shared credentials, agent-with-Gmail, Looker scraping, and a hours-saved dashboard. Do one client, a packet schema, three readonly pulls, a citation gate, a Wait that holds on timeout, and a forced duplicate plus manager-ID test. A week of that loop beats a week of a model that mails.
When is this not worth doing yet?
When you cannot name the GA4 property, GSC site URL, and Ads customer ID, when nobody will own the approval SLA, when logins are personal, or when the KPI you want is a time-saved percentage the graph cannot honestly produce. Fix IDs and ownership first. Pull-approve-send is for numbers that already exist in the APIs.
CTA
Pull the numbers. Draft the letter. Approve. Then send once.
If you want that rail built to production standard, start with the handbook, then use automation or book the $500 Automation Audit.
What questions does this article answer?
- How do I automate my agency's client reporting with AI?
- Pull GA4, Search Console, and Ads into one dated packet per client with that client's credentials. Let a model draft a narrative that may only cite packet keys. Require a named human to approve that packet plus draft, then send with an idempotency key. The model never holds SMTP. A shared agency Google login is not a reporting system.
- How do I measure whether automated client reporting is working?
- Track send-without-approve (target zero), timeout-sends (target zero), citation-gate fails, duplicate period sends, time-to-approve versus SLA, and coded reject reasons. Do not invent a time-saved percentage, an "AI writeoff," or a studio-wide hours figure for this rail. If you want hours, diary the manual process before and after on the same clients.
- What usually fails first when teams try this?
- A Wait timeout that sends, or an Ads pull against the manager account that looks like a quiet month. Close second: GSC's 2–3 day lag treated as zero clicks, and a model that invents YoY because `priorPeriod` was missing. Shared OAuth across clients is the incident that makes the first three look small.
- How long does this take to show results?
- You should see the AM reviewing a card instead of rebuilding the numbers table once one client, one closed period, and the citation gate are live. I will not invent a days-to-hours-saved or payback figure. Early proof is one email per period, zero timeout-sends, and a reject log you can read.
- What should I skip if I only have a week?
- Skip auto-send, shared credentials, agent-with-Gmail, Looker scraping, and a hours-saved dashboard. Do one client, a packet schema, three readonly pulls, a citation gate, a Wait that holds on timeout, and a forced duplicate plus manager-ID test. A week of that loop beats a week of a model that mails.
- When is this not worth doing yet?
- When you cannot name the GA4 property, GSC site URL, and Ads customer ID, when nobody will own the approval SLA, when logins are personal, or when the KPI you want is a time-saved percentage the graph cannot honestly produce. Fix IDs and ownership first. Pull-approve-send is for numbers that already exist in the APIs.
Last reviewed
Automation
Automation After the show is not you at 1 a.m.
Post-show onboarding — thank-you, join path, merch nudge — belongs in a human-gated n8n rail, not your thumb at load-out.
Automation Paperwork that is not the plant
Invoice and PO matching, intake, and support triage in n8n with Metrc fences — the paperwork operators hate, not a menu widget.
Automation Saturday still books — the missed-call rail for trades
A missed-call text-back that routes zip and books a slot beats voicemail and Saturday desk coverage you cannot keep staffed. If a kid is cheaper, say so.
Automation Why doesn’t worker concurrency cap my n8n sub-workflows
Worker concurrency does not cap n8n sub-workflows. Each Execute Workflow child is a new execution the production limit skips, usually on the parent worker.
Will's Journal in your inbox.
What I learned this week building for shops, floors, and houses.
You're on the list.
Sign-up failed — try again.
By subscribing, you agree to the Privacy Policy.