Spurlock Studios
Contact
Share LinkedIn X
A single wrench vs a signed blank contract. Thesis: AUTOMATE AGENCY S CLIENT REPORTING.

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.
HopWho owns itAllowed to guess?
PullGA4 / GSC / Ads APIs via per-client credentialsNo. Empty source → exception, not a filled cell
PacketDeterministic codeNo. Totals and rates copy from the API
DraftModelProse only. Citations must match packet keys
ApproveNamed humanJudgment — tone, caveats, “do not send this client this line”
SendEmail / portal APINo. 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.

JobAutomation mayHuman mustGraph must never
Monthly performance emailPull closed-period metrics into JSONConfirm numbers + tone before sendLet the model call SMTP
Weekly pulse Slack to the AMPacket + one-screen tableAM forwards or killsAuto-ping the client Slack
PDF appendix of the numbers tableRender from the packetAttach only the approved periodGenerate charts from invented series
Portal “report ready” flagWrite needs_reviewFlip to sent after approveMark sent because the draft “looked done”
YoY / wow commentaryCite prior-period fields if presentKill any % not in the packetCompute a story from a missing cell
Anomaly calloutFlag deltas the packet already calculatedDecide if the client hears itInvent a cause (“algorithm update”)
Looker Studio live dashboardSeparate product — share the propertyClient logs in; no email sendTreat a dashboard as this rail
Cross-client “benchmark”StopLegal + contract firstBlend tenants to make a rank

Procedure — the only v1 that is honest:

  1. Pick one client and one closed period (last full month, not “through yesterday”).
  2. Write the packet schema before you pick a model.
  3. Pull GA4, then GSC, then Ads, each with that client’s credential and ID.
  4. 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.
  5. 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:

FieldTypeRule
clientIdstringYour tenant key. Never the Google email.
periodStart / periodEndYYYY-MM-DDClosed range. Inclusive as the vendor defines it.
ga4.propertyIdstringproperties/1234 form used by runReport
gsc.siteUrlstringExact Search Console property URL, encoded in the path
ads.customerIdstringDigits only. No hyphens. Sub-account ID, not the manager ID
timezones.ga4 / gsc / adsstringRecorded, not assumed equal
metrics.*number or nullCopied from API. null beats 0 when the call failed
priorPeriodobject or omittedIf omitted, narrative must not say “vs last month”
freshness.gscLastDatedate or nullLast date GSC actually returned
quota.ga4TokensUsednumber or nullFrom 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 source enum (ga4 / gsc / ads)
  • Rates (CTR, CVR) are copied, not recomputed in the prompt
  • null stays null in the prompt JSON — do not stringify as ""
  • Prior period is either complete or absent
  • Timezones are listed on the approval card
  • No field named hoursSaved or aiImpactPct

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 rulePassFail
CredentialService account or client OAuth for that propertyAgency founder’s Google login reused
Propertyproperties/{id} from the client’s GA4 adminHard-coded studio property
Date rangeClosed calendar period in the property timezoneyesterday on a Monday “monthly” job
MetricsNamed in the request (sessions, keyEvents, …)Prompt asks the model to “estimate sessions”
QuotareturnPropertyQuota: true on the requests you care aboutSilent 429, empty packet, draft anyway
SamplingInspect response metadata when presentTreat 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:

  1. Resolve clientId → { ga4PropertyId, ga4CredentialId } from your tenant table.
  2. If either is missing, dead-letter. Do not fall back to another client’s row.
  3. runReport with dateRanges, at least one metric, dimensions you actually need.
  4. Store raw JSON + normalized metrics. Keep the execution ID.
  5. 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 date to 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 startRow in 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.

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.”

SourceClockEmpty / incompleteMerge rule
GA4Property timezoneFreshness delay; quotaClosed period only
GSCPacific Time on daily buckets2–3 day lag; dropped query/page rowsLast returned date on the card
AdsAccount timezoneManager ID returns the wrong accountSub-account ID only

Merge procedure:

  1. Pull each source into its own object with ok: true|false and error if any.
  2. Join on clientId + period. Do not join on domain string guesses.
  3. 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.
  4. 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:

  1. 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.”
  2. Load tenant row — clientId, IDs, credential names, AM Slack ID, to-email, reportType.
  3. 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.
  4. HTTP Request (GA4) — credential = that client’s GA4. Path includes properties/{id}.
  5. HTTP Request (GSC) — credential = that client’s GSC. siteUrl encoded.
  6. HTTP Request (Ads) — credential + developer token + sub-account customerId.
  7. Code / Set — build packet. Fail closed. Write packet to Data Store / Postgres / Airtable with status: packet_ready.
  8. LLM node — input = packet JSON + system rules. Output = { narrative, citedKeys[] } only. No send tools attached.
  9. Validate citations — every citedKeys[] exists on the packet and is non-null. Else exception queue, no Wait.
  10. Wait — Wait node on webhook or form, or Slack send-and-wait. Card shows table + draft + IDs + timezones + freshness.
  11. Switch on human action — approved → send. rejected / edit → hold. Timeout → hold.
  12. Send — email node uses the send credential, frozen packet, frozen narrative. Store vendor message id. Mark key completed.
NodeCredential it may holdCredential it must not hold
GA4 HTTPClient GA4 readonlySMTP / CMS publish
GSC HTTPClient GSC readonlyGA4 of another client
Ads HTTPThat Ads customerManager-only token used as if it were the client
LLMModel providerAny Google client, any mailbox
Wait / SlackStaff notifyClient-facing mailbox
Email sendAgency or client SMTP after approveModel 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.

AllowedBanned
“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 lockedCause: “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:

  1. Parse citedKeys as a list of paths.
  2. Resolve each path on the packet. Missing or null → fail.
  3. Regex the narrative for %, $, and integers over a threshold you set. Each hit must map to a cited value. Unmapped number → fail.
  4. Fail → exception queue with the draft attached. Do not Wait. Do not send.
  5. 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 fieldWhy it is there
Client name + clientIdWrong-tenant catch
Period + three timezonesClock catch
Numbers table (packet, not the prose)AM checks math without trusting the letter
gscLastDate / GA4 freshness noteIncomplete-month catch
Ads customerId last fourMCC catch
Draft narrativeTone + banned-cause catch
Approve / Reject / EditExplicit. No “react with checkmark”
Actor + timestampAudit

Approval procedure:

  1. AM opens the card (Slack, n8n form, or internal URL).
  2. Scans the table against the letter. Any number in the letter missing from the table → Reject.
  3. Kills any causal claim they did not ask for.
  4. Approve writes humanStatus, actor, approvedAt onto the stored packet.
  5. 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.

FailureWhat you seeCostDo this instead
Shared agency Google credentialClient B metrics in Client A’s PDFIncident + possible contract issuePer-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 campaignsSub-account ID from the tenant table
GSC lag treated as zero“Clicks fell off a cliff” on the 1stPanic email, then a correctionProbe last 10 days; run after 2–3 day lag
Wait timeout → sendEmails fire at 8am because nobody clickedUnreviewed narrative in the client inboxTimeout → held_timeout
Model invents YoYBeautiful % , empty priorPeriodYou are now the unreliable narratorCitation gate
Retry after SMTP 500Two emails, same monthYou look sloppyIdempotency key on send
Quota 429 mid-loopPartial packet, draft still runsMixed completeness across clientsFail that item; do not draft
Timezone mashGA4 Tuesday ≠ GSC TuesdayArguments in the QBRPrint clocks on the card
Scraped Looker instead of APIsLayout drift, login wallsBrittle MondayData API + Search Analytics + GAQL
Agent with Gmail toolHITL ignored once, then foreverThe actual nightmareNo send tool on the model

Forced tests before the second client:

  1. Duplicate cron the same period — second send must no-op.
  2. Swap Ads customerId to the manager ID in staging — packet must ads.ok === false or show a loud mismatch check, not a quiet empty.
  3. Pull GSC with endDate = yesterday — card must show incomplete freshness, not “0 clicks.”
  4. Strip priorPeriod — any % in the draft must fail the citation gate.
  5. 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.

MetricHow you knowTarget
Send without approveCount of SMTP successes where humanStatus !== approvedZero
Timeout-sendsSends whose Wait ended via Limit Wait TimeZero
Invented citationCitation-gate fails + any that slipped past (manual tag)Down; zero slipped
Duplicate period sendCOUNT(*) GROUP BY clientId, period, reportType where sentZero
Time-to-approveapprovedAt - packetReadyAt vs your SLANamed SLA, not a guess
Reject reasonsCoded: wrong_tenant, invented_%, tone, incomplete_gsc, …Readable log
Source fail ratega4.ok / gsc.ok / ads.ok per weekDated; not a vendor SLA
Human edit rateShare of approves that used Edit firstContext, not a vanity drop

Observation window — dated, exportable:

  • Every packet: clientId, period, source ok flags, 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, or analystFte

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.

SituationDIYAudit / build
One client, one monthly email, you watch every cardYes—
Three sources already in the client’s own Google, readonlyYes—
Staging: duplicate cron still one emailYes to go live on that client—
Auto-send on “the draft looks good” week oneNoYes — to keep send behind the boolean
15+ clients on one Community box with shared OAuthNo — stopYes, and read isolation first
Ads pulled from the MCC id “because it’s easier”NoYes
Client-facing Slack / email from the LLM nodeNoYes
Nobody named owns Tuesday approveNo live sendExtract-to-card can still exist
You need a time-saved % for a pitch deckDo not build that metricYes, to keep it off the graph
Volume is two reports a month and already cleanMaybe not worth a canvasOnly if the cost is learning the spine

Decision list:

  1. If you cannot pause one client in two minutes, you are not in DIY-live.
  2. If the AM will not name an approver and a backup, you can still pull packets to Slack. You cannot send.
  3. If the only “AI” request is a dashboard of hours saved, decline the metric. Build pull-approve-send or build nothing.
  4. 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:

  1. Tenant row: three IDs, three credentials, AM, to-email.
  2. Packet schema + ban list (hoursSaved, blended traffic, causal Google stories).
  3. GA4 runReport + GSC date probe + Ads SearchStream on the sub-account.
  4. LLM → { narrative, citedKeys } + citation gate.
  5. Wait card with table + Limit Wait Time → hold.
  6. Idempotent send. Forced duplicate test. Forced manager-ID test. Forced missing-GSC-day test.
  7. 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.

BlockerWhy the loop failsDo this first
GA4 property unknown / mixed with UA folklorePull will hit the wrong datasetAdmin screenshot of property ID in the tenant row
GSC property is a domain property vs URL prefix mismatch403 or emptyCopy the exact resource from Search Console settings
Ads only at manager levelYou will report the wrong accountList client customer IDs
Personal Google loginsGraph dies on PTOService accounts or client-owned OAuth, added to their properties
No AM ownerCards rot; timeout becomes policyName approver + backup
Success = “save 80% of reporting”You will invent a %Diary the current hours or walk away
Clients expect same-day-end numbersGSC/GA4 freshness will fight youMove cadence; say so in the SOW
Cannot pause one tenantBlast radius is the rosterIsolation 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
  • clientId is 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.

QuestionThis postIsolation post
What is the product?Packet → draft → approve → sendInstance / Projects / license / offboarding
What is the blast radius that hurts?A client emailAnother client’s OAuth token
FoldersIrrelevant to the letterNot tenancy
MCC / manager Ads IDWrong report payloadWrong if that credential is also shared
Human gateBefore SMTPBefore handing a client the editor
License / SULOut of scope hereConfirm 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.

FAQ

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.
Sources

Last reviewed

More from this lane

Automation

All →
Book the audit