{
  "name": "Lumeor | Monthly client report drafts (GA4 + Google Ads)",
  "nodes": [
    {
      "parameters": {
        "content": "## Agency client reporting starter\n\nOn the 1st of each month the workflow reads your client list, pulls last month, the month before and the same month last year from GA4 (Data API) and Google Ads (GAQL), and computes KPIs in code: spend, conversions, cost per conversion, ROAS, CTR, sessions, users and key events, with month-over-month, year-over-year and target status. Claude drafts the commentary from the KPI table, the account team's context notes and the client's tone notes. Code checks that every number in the draft exists in the KPI table. The account manager approves in Slack before the email goes to the client.\n\n### Setup\n1. Make a Google Sheet tab **Clients** with: client_name, ga4_property_id, google_ads_customer_id, recipients (comma-separated), tone_notes, target_conversions, target_cpa, context_notes (what happened this month), active (TRUE/FALSE).\n2. Replace `REPLACE_WITH_SHEET_ID`, `REPLACE_WITH_GOOGLE_ADS_DEVELOPER_TOKEN` (or an expression that reads it from your secret store), `REPLACE_WITH_MANAGER_ACCOUNT_ID` (your MCC, digits only), `REPLACE_WITH_APPROVALS_CHANNEL_ID`, `REPLACE_WITH_EXCEPTIONS_CHANNEL_ID` and `REPLACE_WITH_AGENCY_NAME`.\n3. Credentials: Google Sheets OAuth2, Google Analytics OAuth2, Google Ads OAuth2 (approved developer token), Anthropic API, Slack, Gmail OAuth2.\n\nThe Google Ads API version in the URL (v25) is supported until August 2027; update it before then.\n\nModel: `claude-opus-5-5` for client-facing writing. `claude-haiku-5-5` is a cheaper option for high-volume, simple classification, not for this narrative step.\n\nNot included: Meta, LinkedIn and Search Console pulls, PDF rendering and Looker Studio links. Add one extractor per platform.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-marketing-agency-client-reporting/",
        "height": 900,
        "width": 580,
        "color": 7
      },
      "id": "50776cdb-9b6d-41f5-baf5-7237a873374e",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -600,
        -300
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 1 * *"
            }
          ]
        }
      },
      "id": "6c1e114e-88e8-48aa-a1cb-c9fe2d4e1246",
      "name": "Monthly on the 1st, 7am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Clients",
          "mode": "name"
        },
        "options": {}
      },
      "id": "2a5d774f-8904-4ff3-a790-ad9221f8d139",
      "name": "Read client config",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Reporting window: last full month, the month before, and the same month last year.\nconst end = $now.startOf('month').minus({ days: 1 });\nconst cur = { start: end.startOf('month'), end };\nconst prior = { start: cur.start.minus({ months: 1 }), end: cur.start.minus({ days: 1 }) };\nconst yoy = { start: cur.start.minus({ years: 1 }), end: cur.start.minus({ years: 1 }).endOf('month') };\nconst d = (x) => x.toISODate();\n\nreturn $input.all()\n  .filter((item) => String(item.json.active).toUpperCase() !== 'FALSE' && item.json.client_name)\n  .map((item, i) => {\n    const c = item.json;\n    return {\n      json: {\n        client: {\n          name: c.client_name,\n          ga4PropertyId: String(c.ga4_property_id || '').replace(/\\D/g, ''),\n          adsCustomerId: String(c.google_ads_customer_id || '').replace(/\\D/g, ''),\n          recipients: String(c.recipients || '').split(',').map((s) => s.trim()).filter(Boolean),\n          tone: c.tone_notes || 'Plain, confident, no jargon.',\n          targets: { conversions: Number(c.target_conversions) || null, cpa: Number(c.target_cpa) || null },\n          context: c.context_notes || '',\n        },\n        periods: { current: [d(cur.start), d(cur.end)], prior: [d(prior.start), d(prior.end)], yoy: [d(yoy.start), d(yoy.end)] },\n        monthKeys: { current: d(cur.start), prior: d(prior.start), yoy: d(yoy.start) },\n        ga4Body: {\n          dateRanges: [\n            { startDate: d(cur.start), endDate: d(cur.end), name: 'current' },\n            { startDate: d(prior.start), endDate: d(prior.end), name: 'prior' },\n            { startDate: d(yoy.start), endDate: d(yoy.end), name: 'yoy' },\n          ],\n          metrics: [{ name: 'sessions' }, { name: 'totalUsers' }, { name: 'keyEvents' }, { name: 'engagementRate' }],\n        },\n        adsQuery: \"SELECT segments.month, metrics.cost_micros, metrics.clicks, metrics.impressions, metrics.conversions, metrics.conversions_value FROM customer WHERE segments.date BETWEEN '\" + d(yoy.start) + \"' AND '\" + d(cur.end) + \"'\",\n      },\n      pairedItem: { item: i },\n    };\n  });\n"
      },
      "id": "54a8832c-ba7e-4b63-9488-75b5090fea6a",
      "name": "Build date ranges and queries",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://analyticsdata.googleapis.com/v1beta/properties/{{ $json.client.ga4PropertyId }}:runReport",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleAnalyticsOAuth2",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.ga4Body) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "524d7945-b073-4429-b368-5e6b5b09626d",
      "name": "GA4: run report",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://googleads.googleapis.com/v25/customers/{{ $('Build date ranges and queries').item.json.client.adsCustomerId }}/googleAds:search",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleAdsOAuth2Api",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "developer-token",
              "value": "REPLACE_WITH_GOOGLE_ADS_DEVELOPER_TOKEN"
            },
            {
              "name": "login-customer-id",
              "value": "REPLACE_WITH_MANAGER_ACCOUNT_ID"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: $('Build date ranges and queries').item.json.adsQuery }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "24864493-f6ce-4a92-a1a7-db57eb374800",
      "name": "Google Ads: monthly metrics",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const pct = (a, b) => (a == null || b == null || b === 0 ? null : Math.round(((a - b) / b) * 1000) / 10);\nconst r2 = (x) => (x == null || !isFinite(x) ? null : Math.round(x * 100) / 100);\n\nreturn $input.all().map((item, i) => {\n  const base = $('Build date ranges and queries').itemMatching(i).json;\n  const ga = $('GA4: run report').itemMatching(i).json;\n  const ads = item.json;\n  const problems = [];\n\n  // GA4: with named date ranges, the first dimension value is the range name.\n  const gaBy = {};\n  if (ga.error) problems.push('GA4 request failed: ' + (ga.error.message || 'unknown'));\n  for (const row of ga.rows || []) {\n    const name = row.dimensionValues?.[0]?.value;\n    const [sessions, users, keyEvents, engagementRate] = row.metricValues.map((m) => Number(m.value));\n    gaBy[name] = { sessions, users, keyEvents, engagementRate: r2(engagementRate * 100) };\n  }\n\n  // Google Ads: one row per month in the range; keep the three months we report on.\n  const adsBy = {};\n  if (ads.error) problems.push('Google Ads request failed: ' + (ads.error.message || 'unknown'));\n  for (const r of ads.results || []) {\n    const m = r.segments?.month;\n    const key = Object.keys(base.monthKeys).find((k) => base.monthKeys[k] === m);\n    if (!key) continue;\n    const cost = Number(r.metrics.costMicros || 0) / 1e6;\n    const conv = Number(r.metrics.conversions || 0);\n    const clicks = Number(r.metrics.clicks || 0);\n    const impr = Number(r.metrics.impressions || 0);\n    adsBy[key] = { spend: r2(cost), conversions: r2(conv), cpa: conv ? r2(cost / conv) : null, roas: cost ? r2(Number(r.metrics.conversionsValue || 0) / cost) : null, ctr: impr ? r2((clicks / impr) * 100) : null, clicks, impressions: impr };\n  }\n\n  const kpis = [];\n  const add = (name, src, field, unit) => {\n    const cur = src.current?.[field] ?? null;\n    kpis.push({ kpi: name, unit, current: cur, prior: src.prior?.[field] ?? null, last_year: src.yoy?.[field] ?? null, mom_pct: pct(cur, src.prior?.[field]), yoy_pct: pct(cur, src.yoy?.[field]) });\n  };\n  add('Ad spend', adsBy, 'spend', 'USD');\n  add('Conversions (Ads)', adsBy, 'conversions', 'count');\n  add('Cost per conversion', adsBy, 'cpa', 'USD');\n  add('ROAS', adsBy, 'roas', 'x');\n  add('CTR', adsBy, 'ctr', '%');\n  add('Sessions', gaBy, 'sessions', 'count');\n  add('Users', gaBy, 'users', 'count');\n  add('Key events (GA4)', gaBy, 'keyEvents', 'count');\n  add('Engagement rate', gaBy, 'engagementRate', '%');\n\n  const t = base.client.targets;\n  const conv = kpis[1].current, cpa = kpis[2].current;\n  const status = {\n    conversions: t.conversions && conv != null ? (conv >= t.conversions ? 'ahead' : conv >= t.conversions * 0.9 ? 'on track' : 'behind') : 'no target',\n    cpa: t.cpa && cpa != null ? (cpa <= t.cpa ? 'ahead' : cpa <= t.cpa * 1.1 ? 'on track' : 'behind') : 'no target',\n  };\n  // Anomalies worth a human look before anything is written.\n  if (adsBy.current && adsBy.current.spend > 0 && adsBy.current.conversions === 0) problems.push('Spend with zero conversions: check conversion tracking');\n  if (!adsBy.current) problems.push('No Google Ads data for the reporting month');\n  if (!gaBy.current) problems.push('No GA4 data for the reporting month');\n\n  return {\n    json: {\n      client: base.client,\n      periods: base.periods,\n      kpis,\n      targetStatus: status,\n      problems,\n      claudeRequest: {\n        model: 'claude-opus-5-5',\n        max_tokens: 3000,\n        system: 'You draft monthly performance commentary for a marketing agency client. Use only the KPI table and the context notes. Every number you write must appear in the KPI table exactly as given (you may round percentages to whole numbers). If the context notes do not explain a change, say what changed without inventing a cause. Follow the tone notes. No jargon, no hype.',\n        messages: [{ role: 'user', content: 'Client: ' + base.client.name + '\\nReporting period: ' + base.periods.current.join(' to ') + '\\nTone notes: ' + base.client.tone + '\\nTarget status: ' + JSON.stringify(status) + '\\n<kpi_table>\\n' + JSON.stringify(kpis, null, 1) + '\\n</kpi_table>\\n<context_notes>\\n' + (base.client.context || 'None provided.') + '\\n</context_notes>' }],\n        output_config: {\n          format: {\n            type: 'json_schema',\n            schema: {\n              type: 'object',\n              additionalProperties: false,\n              required: ['executive_summary', 'wins', 'concerns', 'next_steps'],\n              properties: {\n                executive_summary: { type: 'string', description: 'Three to four sentences.' },\n                wins: { type: 'array', items: { type: 'string' } },\n                concerns: { type: 'array', items: { type: 'string' } },\n                next_steps: { type: 'array', items: { type: 'string' } },\n              },\n            },\n          },\n        },\n      },\n    },\n    pairedItem: { item: i },\n  };\n});\n"
      },
      "id": "58623315-7a33-4ffd-869a-021f9cca000d",
      "name": "Compute KPIs and build prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.claudeRequest) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "91f3c0c5-dfac-461a-9a21-869ed6b3abda",
      "name": "Claude: draft commentary",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1320,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Pull the structured JSON out of a Messages API response.\n// Opus 5.5 always thinks, so a (possibly empty) thinking block can come before the text block.\nfunction parseClaude(res) {\n  if (!res || typeof res !== 'object') return { error: 'Empty response from the Messages API' };\n  if (res.stop_reason === 'refusal') {\n    return { error: 'Model declined the request (refusal' + (res.stop_details?.category ? ': ' + res.stop_details.category : '') + ')' };\n  }\n  if (res.stop_reason === 'max_tokens') return { error: 'Response hit max_tokens before the JSON was complete' };\n  const block = (res.content || []).find((b) => b.type === 'text');\n  if (!block) return { error: 'No text block in the response' };\n  try {\n    return { data: JSON.parse(block.text) };\n  } catch (e) {\n    return { error: 'Could not parse model JSON: ' + e.message };\n  }\n}\n\nreturn $input.all().map((item, i) => {\n  const k = $('Compute KPIs and build prompt').itemMatching(i).json;\n  const { data, error } = parseClaude(item.json.error ? null : item.json);\n  const problems = [...k.problems];\n  if (error) problems.push('Draft failed: ' + (item.json.error?.message || error));\n  let text = '';\n  if (data) {\n    text = [data.executive_summary, '', 'Wins:', ...data.wins.map((x) => '- ' + x), '', 'Watch:', ...data.concerns.map((x) => '- ' + x), '', 'Next steps:', ...data.next_steps.map((x) => '- ' + x)].join('\\n');\n    // Every number in the draft must exist in the KPI table (allowing whole-number rounding).\n    const allowed = new Set();\n    for (const row of k.kpis) for (const v of [row.current, row.prior, row.last_year, row.mom_pct, row.yoy_pct]) {\n      if (v == null) continue;\n      const n = Math.abs(Number(v));\n      [n, Math.round(n), Math.round(n * 10) / 10, Math.round(n * 100) / 100].forEach((x) => allowed.add(String(x)));\n    }\n    const found = (text.match(/\\d[\\d,]*(\\.\\d+)?/g) || []).map((s) => s.replace(/,/g, ''));\n    const isYear = (s) => /^(19|20)\\d\\d$/.test(s); // \"September 2026\" is fine\n    const unknown = [...new Set(found.filter((s) => !isYear(s) && !allowed.has(String(Number(s)))))];\n    if (unknown.length) problems.push('Numbers not in the KPI table: ' + unknown.join(', '));\n  }\n  const table = k.kpis.map((r) => r.kpi + ': ' + (r.current ?? 'n/a') + ' (MoM ' + (r.mom_pct ?? 'n/a') + '%, YoY ' + (r.yoy_pct ?? 'n/a') + '%)').join('\\n');\n  return {\n    json: {\n      client: k.client,\n      period: k.periods.current.join(' to '),\n      draftOk: problems.length === 0,\n      problems,\n      draft: text,\n      table,\n    },\n    pairedItem: { item: i },\n  };\n});\n"
      },
      "id": "ca49aa91-9ddf-4d6e-a39c-3dd9323a8656",
      "name": "Check numbers and format draft",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "478b3964-1d00-4e43-9381-b87e4a860c84",
              "leftValue": "={{ $json.draftOk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "6d633f88-46b4-4a82-a35e-435daaf689ac",
      "name": "Draft passes checks?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1760,
        300
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "sendAndWait",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_APPROVALS_CHANNEL_ID",
          "mode": "id"
        },
        "message": "=*{{ $json.client.name }}* report for {{ $json.period }} is ready for review.\nTo: {{ $json.client.recipients.join(', ') }}\n\n{{ $json.draft }}\n\nKPI table:\n{{ $json.table }}\n\nApprove to email it as is. Decline to edit and send it yourself.",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "Approve and send",
            "disapproveLabel": "I will edit it"
          }
        },
        "options": {
          "limitWaitTime": {
            "values": {
              "limitType": "afterTimeInterval",
              "resumeAmount": 72,
              "resumeUnit": "hours"
            }
          }
        }
      },
      "id": "46cfc6a7-3979-4951-a631-1bbe71652359",
      "name": "Slack: account manager approval",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        200
      ],
      "webhookId": "55c90237-b50d-470b-bc81-c20547ab5327"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "cb92f80d-9731-4c5b-8afc-3725e8cb44eb",
              "leftValue": "={{ $json.data?.approved === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "a3b2fd66-198c-433f-848c-2513de8f1749",
      "name": "Approved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2200,
        200
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Check numbers and format draft').item.json.client.recipients.join(',') }}",
        "subject": "={{ $('Check numbers and format draft').item.json.client.name }} performance summary, {{ $('Check numbers and format draft').item.json.period }}",
        "emailType": "text",
        "message": "=Hi,\n\nHere is your performance summary for {{ $('Check numbers and format draft').item.json.period }}.\n\n{{ $('Check numbers and format draft').item.json.draft }}\n\nKey numbers:\n{{ $('Check numbers and format draft').item.json.table }}\n\nREPLACE_WITH_AGENCY_NAME",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "8b6aa32b-356b-4fe2-8b8b-93c3b819f109",
      "name": "Gmail: send report to client",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2420,
        100
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_EXCEPTIONS_CHANNEL_ID",
          "mode": "id"
        },
        "text": "=:warning: Report for *{{ $json.client.name }}* ({{ $json.period }}) was not sent for approval.\n{{ $json.problems.join('\\n') }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "bdc89b50-0f2e-4547-8273-82db2842b05e",
      "name": "Slack: reporting exceptions",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        480
      ]
    }
  ],
  "connections": {
    "Monthly on the 1st, 7am": {
      "main": [
        [
          {
            "node": "Read client config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read client config": {
      "main": [
        [
          {
            "node": "Build date ranges and queries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build date ranges and queries": {
      "main": [
        [
          {
            "node": "GA4: run report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GA4: run report": {
      "main": [
        [
          {
            "node": "Google Ads: monthly metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Ads: monthly metrics": {
      "main": [
        [
          {
            "node": "Compute KPIs and build prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute KPIs and build prompt": {
      "main": [
        [
          {
            "node": "Claude: draft commentary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: draft commentary": {
      "main": [
        [
          {
            "node": "Check numbers and format draft",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check numbers and format draft",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check numbers and format draft": {
      "main": [
        [
          {
            "node": "Draft passes checks?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Draft passes checks?": {
      "main": [
        [
          {
            "node": "Slack: account manager approval",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: reporting exceptions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack: account manager approval": {
      "main": [
        [
          {
            "node": "Approved?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approved?": {
      "main": [
        [
          {
            "node": "Gmail: send report to client",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
