Marketing Agency / Technical Guide

Automate Weekly Client SEO/Ads Reports with n8n + Claude Step-by-Step Guide

A complete walkthrough for building an n8n + Claude pipeline that pulls every client’s GA4, Search Console and Google/Meta Ads numbers, runs anomaly detection, asks Claude to write the insight section in your agency’s voice, assembles a branded PDF and ships it to each client — fully white-label, every Monday at 9am.

15 min read
Intermediate-Advanced
n8n + Claude API
Updated May 2026
What You’ll Build

Client config (Airtable)

Pull GA4 / GSC / Ads (parallel)

WoW / YoY anomaly detection

Claude writes insights

Branded PDF (Puppeteer)

Email + Slack handoff to AM

1. The Problem — Monday Morning Reporting Tax

Every digital marketing agency past ~12 retainer clients hits the same Monday morning ritual: an account manager opens 4 tabs per client (GA4, Search Console, Google Ads, Meta Ads), copies numbers into a Google Slides template, writes 3-5 bullets of “what happened last week” from memory, and sends it before the noon client check-in. Multiply by 30 clients and you’ve burned 25-40 analyst hours on a deliverable nobody is paid extra for — and the insights are inconsistent because three different humans wrote them.

Real numbers from a 32-client agency

Active retainers32
Avg analyst hours per weekly report1h 10min
Total weekly reporting hours (agency-wide)~38 hrs
Reports delivered before Monday 9am41%
Anomalies surfaced proactively (not by client)28%

The hidden cost is bigger than the visible one. The clients who churn rarely cite “the report was late” — they cite “I never knew what was actually working.” Reports that just dump pretty charts let problems fester for weeks. The agencies that retain best are the ones whose Monday email always says clearly: “this went up because, this dropped because, here’s what we’re doing about it.”

What “automated reporting” means here

It is not a Looker Studio template with the client logo swapped in. That’s what most agencies already have, and it’s the reason clients say their reports feel generic. Real automation has three parts:

  • Data layer: a single n8n workflow per agency that pulls GA4, GSC, Google Ads, Meta Ads in parallel for every client every Monday at 6am.
  • Insight layer: Claude reads the WoW and YoY deltas, identifies the 3-5 things actually worth saying, and writes them in the agency’s house voice — not Google’s “sessions decreased by 12.3% week-over-week” style.
  • Delivery layer: a branded PDF assembled with the client’s logo, color tokens and account-manager signature, emailed to the client and posted into the AM’s Slack channel for review-then-send.
Insight
The biggest retention lift comes from clients seeing a consistent Monday delivery. Lateness is read as “they don’t have it together”; on-time-with-real-insight is read as “they’re on top of my account.” We see this pattern repeatedly across our marketing agency automation deployments.

2. System Architecture

Eight components, each replaceable. The orchestration layer is self-hosted n8n so the agency owns every credential and every line of report logic — clients hate when their reports stop because a third-party SaaS deprecated an integration. Airtable holds client config so AMs without code access can change a client’s logo, KPI list or recipient email.

The stack

n8n (self-hosted)
Orchestration. One master workflow loops over every active client, sub-workflows handle each data source.
Claude API
Sonnet for the insight write-up, Haiku for short summary lines and email subject generation.
GA4 Data API
Sessions, conversions, revenue, channel grouping. Service account per agency, property ID per client.
Search Console API
Clicks, impressions, CTR, avg position. Top movers by query and page.
Google Ads + Meta Ads
Spend, CPA, ROAS, top campaigns by delta. MCC-level OAuth for Google, system user token for Meta.
Airtable
Client config: brand tokens, KPI selection, recipients, AM owner, report toggles.
Puppeteer (PDF)
Renders an HTML template with the client’s logo and tokens, exports to PDF/A. DocRaptor as fallback.
SMTP + Slack
Postmark for transactional email, Slack incoming webhook for AM review channel.

Cost estimate (30 clients, weekly cadence)

Claude Sonnet (30 clients x 4 weeks x ~3,500 tok in / 900 tok out)~$48
Claude Haiku (subject lines + summaries)~$6
VM (n8n + Chromium for Puppeteer on Hetzner CCX23)~$48
Postmark (1,200 emails/mo)~$15
Airtable team plan~$60
Total / month (30 clients)~$177

At a typical agency loaded analyst rate of $65/hour, recovering even 25 of the 38 weekly reporting hours is ~$1,625/week or ~$84,500/year. The same orchestration layer slots into the broader AI automation services we deploy.

1

Client Config in Airtable

Every client is one row. The AM should be able to onboard a new client in 8-10 minutes by filling that row — no engineer needed. The schema is intentionally flat so it can be edited in the grid view; nested config goes into JSON fields that the AM rarely touches.

Airtable schema — `clients` table

Airtable — clients table fieldsSCHEMA
client_id            (autonumber, primary)
client_name          (text)         "Acme Coffee Roasters"
status               (select)       active | paused | offboarded
am_owner             (linked)       -> staff table
ga4_property_id      (text)         "properties/376412899"
gsc_site_url         (text)         "sc-domain:acmecoffee.com"
google_ads_customer  (text)         "123-456-7890"
meta_ad_account_id   (text)         "act_998877665544"
report_kpis          (multi-select) sessions, conversions, revenue,
                                    cpa, roas, organic_clicks, ...
brand_logo           (attachment)   client logo PNG, >= 600px wide
brand_primary_hex    (text)         "#0E5C3F"
brand_secondary_hex  (text)         "#F4B860"
brand_tone           (select)       formal | friendly | technical
recipients           (text)         comma-separated emails
slack_review_channel (text)         "#client-acme-internal"
report_day           (select)       Monday | Tuesday | ... (default Mon)
report_locale        (select)       en-US | en-GB | he-IL | ...

n8n loader: pull active clients

n8n Airtable node — list active clientsJSON
{
  "operation": "list",
  "base":  "{{ $credentials.airtable.baseId }}",
  "table": "clients",
  "filterByFormula":
    "AND({status}='active', {report_day}='{{ $today.weekday }}')",
  "fields": [
    "client_id","client_name","ga4_property_id","gsc_site_url",
    "google_ads_customer","meta_ad_account_id","report_kpis",
    "brand_logo","brand_primary_hex","brand_secondary_hex",
    "brand_tone","recipients","slack_review_channel","report_locale"
  ]
}
Watch Out
Don’t put OAuth tokens in Airtable, even encrypted. Tokens live in the n8n credential vault and are referenced by client_id only. Otherwise an AM with edit access to the table can accidentally exfiltrate every client’s Ads credentials in one CSV export.
2

Parallel API Pulls per Client

For each client, fire all four data sources in parallel — there is no dependency between them. The whole pull should finish in under 6 seconds even with cold caches. If one source fails, fail soft: still build the report from the others, and Slack the AM that the broken source needs reauth.

GA4 Data API request

GA4 — runReport (last 7 days vs prior 7 days)JSON
POST https://analyticsdata.googleapis.com/v1beta/{{ $json.ga4_property_id }}:runReport
Authorization: Bearer {{ $credentials.googleSA.token }}
Content-Type: application/json

{
  "dateRanges": [
    { "startDate": "7daysAgo",  "endDate": "yesterday",
      "name": "current" },
    { "startDate": "14daysAgo", "endDate": "8daysAgo",
      "name": "previous" }
  ],
  "dimensions": [
    { "name": "sessionDefaultChannelGroup" }
  ],
  "metrics": [
    { "name": "sessions" },
    { "name": "totalUsers" },
    { "name": "conversions" },
    { "name": "totalRevenue" },
    { "name": "engagementRate" }
  ],
  "limit": 50
}

Search Console — top movers

GSC — searchAnalytics.queryJSON
POST https://www.googleapis.com/webmasters/v3/sites/{{ encodeURIComponent($json.gsc_site_url) }}/searchAnalytics/query
Authorization: Bearer {{ $credentials.googleSA.token }}

{
  "startDate":   "{{ $json.range.start }}",
  "endDate":     "{{ $json.range.end }}",
  "dimensions":  ["query","page"],
  "rowLimit":    250,
  "dataState":   "final",
  "type":        "web"
}

// Run twice (current 7d + prior 7d), then diff in n8n Function node
// to surface top 10 winning queries and top 10 losing queries by clicks.

Google Ads — campaign report

Google Ads — GAQL via RESTJSON
POST https://googleads.googleapis.com/v17/customers/{{ $json.google_ads_customer }}/googleAds:search
Authorization:    Bearer {{ $credentials.googleAds.token }}
developer-token:  {{ $credentials.googleAds.devToken }}
login-customer-id: {{ $credentials.googleAds.mccId }}

{
  "query": "
    SELECT
      campaign.id, campaign.name, campaign.status,
      metrics.cost_micros, metrics.clicks, metrics.impressions,
      metrics.conversions, metrics.conversions_value,
      segments.date
    FROM campaign
    WHERE segments.date DURING LAST_7_DAYS
      AND campaign.status != 'REMOVED'
    ORDER BY metrics.cost_micros DESC
    LIMIT 50
  "
}

n8n parallel-pull pattern

Use a Split In Batches node over the client list, and inside each iteration use four parallel HTTP nodes joined by a Merge node in “wait for all” mode. Wrap each external call in a Try/Catch sub-workflow so a single 401 from Meta doesn’t kill the entire run.

Insight
Always pull `dataState: final` from GSC. The default “all” data backfills for 2-3 days and your WoW deltas will look wrong by Tuesday morning when clients re-check. The trade-off is you’re reporting on data that ends 3 days ago, which is fine — just label the report range honestly.
3

Anomaly Detection (WoW & YoY)

If you hand Claude a raw 50-row CSV and ask “what’s interesting?” you get a paragraph that lists the obvious. The trick is to do the math in code first, label each metric with a severity, and feed Claude only the labeled deltas. That keeps token cost low and makes Claude’s output focus on the right 3-5 things every time.

Threshold table (per-metric)

MetricNotableConcerningCritical
Sessions WoW±10%±20%±35%
Conversions WoW±15%±25%±40%
Revenue WoW±15%±25%±40%
CPA WoW±12%±20%±35%
ROAS WoW±10%±18%±30%
Organic clicks WoW±12%±22%±35%

Anomaly classifier (n8n Function node)

n8n Function — classify each metric WoW + YoYJS
const thresholds = {
  sessions:        { notable: 10, concerning: 20, critical: 35 },
  conversions:     { notable: 15, concerning: 25, critical: 40 },
  revenue:         { notable: 15, concerning: 25, critical: 40 },
  cpa:             { notable: 12, concerning: 20, critical: 35 },
  roas:            { notable: 10, concerning: 18, critical: 30 },
  organic_clicks:  { notable: 12, concerning: 22, critical: 35 }
};

function classify(metric, current, previous) {
  if (!previous) return { severity: "new", pct: null };
  const pct = ((current - previous) / previous) * 100;
  const t   = thresholds[metric] ?? { notable: 10, concerning: 20, critical: 35 };
  const a   = Math.abs(pct);
  let sev   = "stable";
  if (a >= t.critical)   sev = "critical";
  else if (a >= t.concerning) sev = "concerning";
  else if (a >= t.notable)    sev = "notable";
  return { severity: sev, pct: Number(pct.toFixed(1)),
           direction: pct >= 0 ? "up" : "down" };
}

const out = $input.all().map(item => {
  const r = item.json;
  return {
    metric: r.metric,
    current: r.current,
    previous_week: r.previous,
    previous_year: r.previous_yoy,
    wow: classify(r.metric, r.current, r.previous),
    yoy: classify(r.metric, r.current, r.previous_yoy)
  };
});

return out;

After classification, filter to only the metrics tagged `notable`, `concerning` or `critical` and pass that filtered list to Claude. The “stable” rows still appear in the report’s data table — they just don’t get airtime in the insight section.

4

Claude Insight Prompt

The insight section is where automated reports live or die. A bad prompt gives you “Sessions decreased by 12.3% week-over-week” — a sentence the client could have generated themselves from the table. A good prompt makes Claude pick the 3-5 things actually worth saying, frame each in the agency’s voice, and split them into Wins, Concerns and Next Steps.

The system prompt

Claude system prompt — weekly insight writerTXT
You are the senior account strategist at {{ agency_name }} writing
the weekly recap section for one client. Your job is to pick the 3-5
things actually worth saying this week and write them in plain language
the client's marketing director can forward to their CEO unedited.

CLIENT CONTEXT:
- Client: {{ client_name }}
- Industry: {{ client_industry }}
- Primary KPI: {{ primary_kpi }}
- Tone: {{ brand_tone }} (formal | friendly | technical)
- This week's range: {{ range_label }}

INPUT DATA:
- Anomaly-classified metrics (only severity != stable provided)
- Top 10 winning organic queries WoW
- Top 10 losing organic queries WoW
- Top 5 ad campaigns by spend with ROAS deltas
- Notes from the AM (if any) — these take priority over data

OUTPUT — strict JSON, no prose:
{
  "headline": "one sentence the client would forward to their CEO",
  "wins":     ["1-2 sentence bullet", ...],   // 1-3 items
  "concerns": ["1-2 sentence bullet", ...],   // 0-3 items
  "next_steps": ["1-2 sentence bullet", ...], // 1-3 items
  "subject_line": "<= 60 chars, no emoji, no client name"
}

RULES:
- Never say "sessions decreased by X%". Say WHY it likely happened
  using the supplied query/campaign movers as evidence.
- Never invent a cause not supported by the data or AM notes.
- If a metric is "critical" down, it must appear in concerns.
- If primary KPI is up week-over-week AND year-over-year, lead the
  headline with that.
- Never reference seasonality unless YoY data shows the same pattern.
- Voice: write like a smart human strategist, not a dashboard.
- Never use the words "leverage", "synergy", "robust" or "holistic".

n8n HTTP Request to Claude

n8n HTTP Request — Claude messages callJSON
{
  "method": "POST",
  "url": "https://api.anthropic.com/v1/messages",
  "headers": {
    "x-api-key": "{{ $credentials.anthropic.apiKey }}",
    "anthropic-version": "2023-06-01",
    "content-type": "application/json"
  },
  "body": {
    "model": "claude-sonnet-4-5",
    "max_tokens": 1200,
    "system": "{{ $json.systemPrompt }}",
    "messages": [
      {
        "role": "user",
        "content": "ANOMALIES:n{{ JSON.stringify($json.anomalies) }}nnQUERY MOVERS:n{{ JSON.stringify($json.gscMovers) }}nnCAMPAIGN MOVERS:n{{ JSON.stringify($json.adsMovers) }}nnAM NOTES:n{{ $json.amNotes || 'none' }}"
      }
    ]
  }
}

Sample output

Claude — example output (Acme Coffee Roasters, week of Apr 28)JSON
{
  "headline": "Strongest revenue week of Q2 so far, driven by the new Ethiopia Yirgacheffe landing page picking up branded + non-branded traffic.",
  "wins": [
    "Organic revenue is up 28% week-over-week and 41% year-over-year. The Yirgacheffe page (live since Apr 14) added 1,240 new clicks from queries like 'ethiopian single origin coffee' and 'yirgacheffe vs sidamo'.",
    "Search Brand campaign ROAS climbed from 6.1x to 8.4x after the negative-keyword cleanup last Tuesday — wasted spend on coffee maker queries dropped to under 3% of budget."
  ],
  "concerns": [
    "Meta Advantage+ CPA jumped from $18 to $27 (+50%). The creative refresh that went live Friday is fatiguing fast — frequency on the top adset is at 4.2."
  ],
  "next_steps": [
    "Replace the two top-frequency Meta creatives with the seasonal Mother's Day variants by Wednesday.",
    "Build internal links from the existing Sidamo and Guatemala pages to the new Yirgacheffe page to compound the SEO momentum."
  ],
  "subject_line": "Acme weekly: best revenue week of Q2"
}
Insight
The AM notes field is the single biggest quality lever. A two-line note like “client launched new pricing on Wed, expect signup dip” stops Claude from raising a false alarm and is read by the client as proof you actually know their business. Make adding the note part of the AM’s Friday close-out routine.
5

Branded PDF Assembly

Render the report as HTML using the client’s brand tokens, then convert to PDF with Puppeteer. This pattern lets every report be infinitely customizable per client (logo, colors, fonts, section order) without per-client code. The HTML template lives in the n8n repo, the tokens come from the Airtable row.

Report HTML structure

report-template.html — handlebars stubHTML
<!doctype html>
<html lang="{{ locale }}">
<head>
  <meta charset="utf-8">
  <style>
    :root {
      --brand-primary:   {{ brand_primary_hex }};
      --brand-secondary: {{ brand_secondary_hex }};
    }
    body { font-family: Inter, sans-serif; color: #1a1a1a; }
    .cover { background: var(--brand-primary); color: white; padding: 60px; }
    .section h2 { color: var(--brand-primary); border-bottom: 2px solid var(--brand-secondary); }
    .kpi-grid  { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
    .kpi       { background: #f7f7f7; padding: 18px; border-left: 4px solid var(--brand-primary); }
  </style>
</head>
<body>

  <section class="cover">
    <img src="{{ brand_logo_url }}" height="48">
    <h1>Weekly Performance Report</h1>
    <p>{{ client_name }} — {{ range_label }}</p>
  </section>

  <section class="section">
    <h2>Headline</h2>
    <p class="lead">{{ insight.headline }}</p>
  </section>

  <section class="section">
    <h2>KPIs</h2>
    <div class="kpi-grid">
      {{#each kpis}}
        <div class="kpi">
          <div class="label">{{ label }}</div>
          <div class="value">{{ value }}</div>
          <div class="delta {{ delta_class }}">{{ delta_label }}</div>
        </div>
      {{/each}}
    </div>
  </section>

  <section class="section">
    <h2>Wins</h2>
    <ul>{{#each insight.wins}}<li>{{ this }}</li>{{/each}}</ul>
  </section>

  <section class="section">
    <h2>Concerns</h2>
    <ul>{{#each insight.concerns}}<li>{{ this }}</li>{{/each}}</ul>
  </section>

  <section class="section">
    <h2>Next Steps</h2>
    <ul>{{#each insight.next_steps}}<li>{{ this }}</li>{{/each}}</ul>
  </section>

  <footer>
    Prepared by {{ am_name }} · {{ agency_name }} · {{ today }}
  </footer>
</body>
</html>

Puppeteer render (n8n Code node)

n8n Code node — render HTML to PDFJS
const puppeteer  = require('puppeteer');
const Handlebars = require('handlebars');

const tpl  = Handlebars.compile($input.first().json.templateHtml);
const html = tpl($input.first().json.report);

const browser = await puppeteer.launch({
  args: ['--no-sandbox','--disable-dev-shm-usage']
});
const page    = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });

const pdf = await page.pdf({
  format: 'A4',
  printBackground: true,
  margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
});

await browser.close();

return [{
  binary: {
    report_pdf: {
      data: pdf.toString('base64'),
      mimeType: 'application/pdf',
      fileName: `${$json.report.client_slug}-week-${$json.report.iso_week}.pdf`
    }
  }
}];
Security
Run Puppeteer in a separate Docker container with no network access — Chromium is a large attack surface and the only thing it needs is the local HTML. If a client’s logo URL is ever compromised, the renderer cannot exfiltrate anything because it cannot reach the public internet from inside the container.
6

Delivery + AM Handoff

There are two delivery models. The fully-automated one emails the PDF straight to the client at 9am Monday. The supervised one drops the PDF and Claude’s draft into the AM’s Slack channel at 7am for a 60-second review and a one-click “send” — the better choice when you’re rolling out, because it builds AM trust before you hand the keys over.

Slack review payload

Slack — incoming webhook (Block Kit)JSON
{
  "channel": "{{ $json.client.slack_review_channel }}",
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text",
                "text": ":memo: {{ $json.client.client_name }} — weekly draft ready" }
    },
    {
      "type": "section",
      "text": { "type": "mrkdwn",
                "text": "*Headline:* {{ $json.report.insight.headline }}" }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn",
          "text": "*Subject:*n{{ $json.report.insight.subject_line }}" },
        { "type": "mrkdwn",
          "text": "*Recipients:*n{{ $json.client.recipients }}" }
      ]
    },
    {
      "type": "actions",
      "elements": [
        { "type": "button", "text": { "type": "plain_text",
          "text": "Send to client" }, "style": "primary",
          "action_id": "send_report",
          "value": "{{ $json.report.id }}" },
        { "type": "button", "text": { "type": "plain_text",
          "text": "Edit draft" }, "url": "{{ $json.report.editor_url }}" },
        { "type": "button", "text": { "type": "plain_text",
          "text": "Hold" }, "style": "danger",
          "action_id": "hold_report",
          "value": "{{ $json.report.id }}" }
      ]
    }
  ]
}

Outbound email (Postmark)

Postmark — send-with-attachmentJSON
POST https://api.postmarkapp.com/email
X-Postmark-Server-Token: {{ $credentials.postmark.token }}

{
  "From":      "{{ $json.client.am_name }} <{{ $json.client.am_email }}>",
  "To":        "{{ $json.client.recipients }}",
  "Subject":   "{{ $json.report.insight.subject_line }}",
  "HtmlBody":  "{{ $json.report.email_html }}",
  "TextBody":  "{{ $json.report.email_text }}",
  "MessageStream": "outbound",
  "Attachments": [{
    "Name":        "{{ $json.report.pdf_filename }}",
    "Content":     "{{ $json.report.pdf_base64 }}",
    "ContentType": "application/pdf"
  }],
  "TrackOpens":  true,
  "Tag":         "weekly-report",
  "Metadata": {
    "client_id": "{{ $json.client.client_id }}",
    "iso_week":  "{{ $json.report.iso_week }}"
  }
}

Setting `From` to the AM’s address (with proper SPF/DKIM via Postmark) is the difference between “this looks like marketing automation” and “this looks like my account manager”. Always send `TrackOpens: true` and stash the open event in Postgres — open rate is the single best leading indicator that the report is actually being read. The same pattern drives our work for SaaS automations where personalized sender identity moves reply rates dramatically.

9. White-Label Layer

A real white-label system is more than a logo swap. It controls three layers per client: visual identity (logo, fonts, color tokens), voice (tone select that bends Claude’s writing style) and footer attribution (your AM, your domain, optional client-side co-branding). Done right, the report looks like the agency’s house product but feels like the client’s own internal document.

Per-client brand tokens

TokenSourceUsed in
brand_primary_hexAirtableCover, section headers, KPI accent bar
brand_secondary_hexAirtableSection dividers, chart accent
brand_logoAirtable attachmentCover page top-left
brand_toneAirtable selectClaude system prompt
am_name + am_emailStaff tableFooter + email From header
report_localeAirtableNumber formatting, weekday names, currency

Voice tone control

The `brand_tone` Airtable field swaps a one-paragraph block at the bottom of the Claude system prompt. The simplest implementation has three presets and they cover 90% of clients:

  • Formal: “Write in a measured, professional tone. Use complete sentences. No contractions. Lead with data, follow with interpretation.”
  • Friendly: “Write like a smart colleague explaining things over coffee. Contractions are fine. Lead with the human takeaway, not the percentage.”
  • Technical: “Write for an in-house growth engineer. Use precise marketing terminology. Show the math when it matters. Skip the cheerleading.”

Multi-language reports

The `report_locale` token controls three things: the language the Claude prompt asks for (“respond in Hebrew” / “respond in Spanish”), the number format (`1,234.5` vs `1.234,5`) and the weekday/month names rendered in the cover page header. The HTML template uses `Intl.NumberFormat(locale)` and the same template handles Latin and RTL clients without forking.

Insight
Resist the urge to white-label down to the agency level. The system should always have your name in the footer in micro-type — clients move and a report they keep on file three years later should still trace back to who built the relationship.

10. Common Failures & Fixes

Three failure modes show up in nearly every agency rollout. They’re cheap to design around on day one and expensive to retrofit after the first 90 days of clients are reading reports.

Failure 1: Phantom anomaly during a known launch

Symptom: Client launched a new pricing page on Wednesday. Tuesday’s signups dipped 38% as expected. The Monday report leads with “Critical concern: signups down 38%” and the client’s CEO emails before lunch.

Fix: The AM-notes field. Add a `client_events` table in Airtable with date, label and direction. Auto-injected into Claude’s prompt, with a rule: “if a client event is logged within the report window, use it as the cause and downgrade severity by one tier.”

Failure 2: Token expiry on Monday morning

Symptom: Google Ads OAuth token expires Sunday night. 12 client reports go out at 9am Monday with the Ads section blank because the workflow swallowed the 401.

Fix: Run a credential health check on Saturday at 6pm — make a single dummy request per OAuth credential and Slack the agency owner if any return 401. Refresh tokens proactively rather than reactively. The Saturday window leaves Sunday available for manual reauth.

Failure 3: Claude inventing a cause

Symptom: Sessions are flat, but Claude writes “the recent algorithm update from Google likely contributed to the drop.” There was no algorithm update. The AM has to scrub the report.

Fix: Two rules in the system prompt — “never reference a Google or Meta update unless explicitly listed in the AM-notes field” and “never invent a cause not supported by query/campaign movers in the input.” Run a once-a-month spot check across 5 random reports and add new forbidden phrasings whenever you catch one.

11. Measured Results — 90 Days In

Numbers from a real implementation at a 32-client digital agency (4 AMs, 2 senior strategists, mixed SEO + paid retainers averaging $4.2k/month). No change in service offering during the test — the lift is entirely from time recovered, on-time delivery and insight quality.

Reporting hours saved
29 hrs / wk
across 32 retainers
On-time Monday delivery
98%
vs 41% baseline
Client NPS lift
+19
measured at 90 days
Logo retention
+11pp
12-month rolling

The headline metric inside the agency is what AMs do with the reclaimed 29 hours: more strategy calls, more proactive optimizations between reporting cycles, and the bandwidth to take on net-new retainers without hiring. The retention improvement (an 11-percentage-point lift in 12-month logo retention) was the single largest revenue impact and only became visible at the 9-month mark.

Email open rate on the weekly report stabilized at 71% across the client portfolio. Anything below 50% is a signal a client has tuned out — the system flags it automatically and prompts the AM to schedule a check-in call.

12. Implementation Timeline & Cost

DIY Path
70 – 110 hours
  • n8n self-host + Puppeteer container: 6–10 hrs
  • Airtable client config + n8n loader: 4–6 hrs
  • GA4 + GSC + Ads OAuth + parallel pulls: 14–20 hrs
  • Anomaly detection + thresholds tuning: 8–12 hrs
  • Claude insight prompt + voice tone presets: 12–18 hrs
  • HTML report template + brand tokenization: 14–20 hrs
  • Slack review + Postmark delivery: 6–10 hrs
  • AM training + run-book + monitoring: 6–10 hrs
With SEOKRU
4-week deployment
  • Week 1: Audit current reporting, lock the KPI list, design HTML template
  • Week 2: Wire GA4/GSC/Ads + anomaly thresholds + Airtable schema
  • Week 3: Claude insight prompt tuning against 4 weeks of historical data
  • Week 4: Pilot on 5 clients, AM training, then ramp to full portfolio
  • Includes: voice-tone presets, monthly insight quality review, ongoing prompt tuning
Get a Custom Implementation →

FAQ

Why not just use Looker Studio or AgencyAnalytics?
Off-the-shelf reporting tools solve the data-pull problem and stop there. They give the client a dashboard with charts. The hard part isn’t getting numbers — it’s the insight section, the AM-voice writing, the per-client brand control and the proactive anomaly flagging. The n8n + Claude approach owns that whole pipeline and lets you change a single prompt to upgrade insight quality across every client at once. Looker Studio remains a great option as the embedded data layer, but it does not replace the deliverable a client actually reads on Monday morning.
How do you keep Claude from hallucinating numbers?
Numbers never come from Claude. The PDF template renders the metrics directly from the API responses, and Claude only writes the narrative section. The prompt also forbids Claude from referencing a percentage that wasn’t in the supplied anomaly list — if it tries, the value won’t match what’s printed in the KPI grid and the AM will spot the inconsistency immediately. Once a month, run a spot check across 5 reports to catch any drift.
Can it pull from non-Google sources — TikTok, LinkedIn, etc.?
Yes. The pattern is identical: one HTTP node per source, OAuth or system token in the n8n credential vault, normalized into the same `metrics_snapshot` shape before the anomaly classifier runs. We’ve built versions with TikTok Ads, LinkedIn Ads, Bing Ads, Pinterest, Klaviyo, Shopify and HubSpot Marketing inside the same workflow. The principle: get every number into one normalized schema before Claude sees anything.
What about client portals — does this replace ours?
It complements rather than replaces. Most agencies keep their portal as the always-on view (live charts, drill-downs, raw data exports) and use the weekly PDF as the curated narrative. The PDF is what lands in the client’s inbox, gets forwarded to their CEO and ends up in the quarterly board deck. Both surfaces draw from the same n8n data layer, so numbers always reconcile.
How does it handle clients in different time zones?
The Airtable row carries `report_day` and `report_locale`. The master n8n workflow runs hourly and processes only the clients whose local Monday 6am has just passed. A US Eastern client and a UK client both get a “Monday morning” report — they just hit different rows of the schedule.
Can clients edit the report before it goes out?
Not directly — that breaks the white-label illusion. The AM is the editor. The Slack review step gives them a 60-90 minute window to tweak the headline, drop a concern bullet, or hold the report entirely. In practice, after the first month of tuning, AMs edit fewer than 5% of drafts before clicking send.
What about GDPR and client data residency?
The pipeline only handles aggregated marketing analytics — no personal data flows through Claude. Email and revenue numbers are aggregates, not individual records. For EU clients, run the n8n + Postgres stack on a Frankfurt VM, use Anthropic’s EU endpoint with zero-data-retention enabled, and document the data flow in a one-page DPA addendum. The same compliance posture appears across our broader AI automation services.
How fast can we onboard a new client once the system is live?
8-10 minutes per client, done by an AM with no engineering help. Fill the Airtable row, drag in the logo, paste GA4 property ID + GSC site URL + Ads customer ID, choose the KPI multi-select and the brand tone. The next Monday morning, that client receives a full report. The slowest part is usually OAuth: the AM needs the client to grant view access on GA4 and GSC, which takes the client about 4 minutes if you send them a step-by-step.
Does this work for paid-only or SEO-only retainers?
Yes. The KPI multi-select in Airtable controls which sections render. A paid-only client gets a report with no SEO section; an SEO-only retainer gets no Ads section. The Claude prompt also adapts — if there are no Ads inputs, it never invents an Ads observation. Single-channel clients still get a full-quality report with the same insight-first structure.
What's the failure mode if Claude is down?
Three layers. First, n8n retries with exponential backoff (3 attempts, 30s-5min). Second, after 3 failures the workflow falls back to a deterministic template that prints the headline as “Performance summary for {{ client_name }} — {{ range_label }}” and renders the KPIs without commentary. Third, the AM gets a Slack ping flagging the degraded report so they can write a short manual headline before send. In 18 months we’ve never had a delivery missed entirely.

Want this built for your agency’s reporting?

SEOKRU deploys this exact system in 4 weeks. We audit your current reporting, design the HTML template against your house brand, wire GA4/GSC/Ads/Meta for every active retainer, tune the Claude prompt against 4 weeks of your historical data and train your AMs on the new flow. You keep ownership of every component — workflows, prompts, templates, Airtable, the lot.

Talk to an agency automation engineer