{
  "name": "Create monthly report slides from Google Chat with Claude, Google Sheets and Slides",
  "nodes": [
    {
      "id": "c4000000-0000-4000-8000-000000000001",
      "name": "Request form",
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.6,
      "position": [
        0,
        0
      ],
      "parameters": {
        "formTitle": "Make a monthly report",
        "formDescription": "Write which month, e.g. \"September sales report\" or \"先月の売上報告\". A copy of your Slides template is filled in; you get the link.",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Request",
              "fieldName": "Request",
              "fieldType": "textarea",
              "requiredField": true,
              "placeholder": "Sales report for September"
            }
          ]
        },
        "responseMode": "lastNode",
        "options": {
          "path": "monthly-report-request"
        }
      },
      "webhookId": "3a8e1f62-7b4c-4d29-9e15-6c2a8b0f4d71"
    },
    {
      "id": "c4000000-0000-4000-8000-000000000002",
      "name": "Google Chat event",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -660,
        400
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "report-chat-REPLACE-WITH-A-LONG-RANDOM-STRING",
        "responseMode": "responseNode",
        "options": {}
      },
      "webhookId": "5c9d2e84-1f6a-4b3e-8d70-2e4f6a9c1b38"
    },
    {
      "id": "c4000000-0000-4000-8000-000000000003",
      "name": "Chat settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -440,
        400
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": true,
        "assignments": {
          "assignments": [
            {
              "id": "h01",
              "name": "allowed_chat_users",
              "value": "you@example.com",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000004",
      "name": "Read the chat event",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -220,
        400
      ],
      "parameters": {
        "jsCode": "// Works for both Chat app types: \"Workspace add-on\" events (body.chat) and classic Chat API events (body.type).\nconst body = $json.body || {};\nconst allowed = String($json.allowed_chat_users || '').toLowerCase().split(',').map((s) => s.trim()).filter(Boolean);\nconst addon = !!body.chat;\nlet kind, text, email;\nif (addon) {\n  const p = body.chat.messagePayload;\n  kind = p ? 'message' : 'other';\n  text = p ? (p.message.argumentText || p.message.text || '') : '';\n  email = (body.chat.user && body.chat.user.email) || (p && p.message.sender && p.message.sender.email) || '';\n} else {\n  kind = body.type === 'MESSAGE' ? 'message' : 'other';\n  text = (body.message && (body.message.argumentText || body.message.text)) || '';\n  email = (body.user && body.user.email) || '';\n}\nconst wrap = (t) => (addon ? { hostAppDataAction: { chatDataAction: { createMessageAction: { message: { text: t } } } } } : { text: t });\nconst help = 'Write which month you want, for example \"September sales report\" or \"先月の売上報告\". I make the slides and send you the link.';\nlet reply = null;\nif (kind !== 'message') reply = wrap(help);\nelse if (!allowed.includes(String(email).toLowerCase())) reply = wrap('Sorry, ' + (email || 'this account') + ' is not on the list of people who can make reports.');\nelse if (!text.trim()) reply = wrap(help);\nreturn [{ json: { ok: !reply, reply, Request: text.trim(), source: 'chat', addon, sender: email } }];"
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000005",
      "name": "Is it a request?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        0,
        400
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "ok",
              "leftValue": "={{ $json.ok }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000006",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "c00",
              "name": "request",
              "value": "={{ $json.Request }}",
              "type": "string"
            },
            {
              "id": "c01",
              "name": "source",
              "value": "={{ $json.source || 'form' }}",
              "type": "string"
            },
            {
              "id": "c02",
              "name": "addon",
              "value": "={{ $json.addon === true }}",
              "type": "boolean"
            },
            {
              "id": "c03",
              "name": "spreadsheet_id",
              "value": "PASTE_YOUR_SPREADSHEET_ID",
              "type": "string"
            },
            {
              "id": "c04",
              "name": "template_id",
              "value": "PASTE_YOUR_SLIDES_TEMPLATE_ID",
              "type": "string"
            },
            {
              "id": "c05",
              "name": "report_title",
              "value": "Monthly sales report",
              "type": "string"
            },
            {
              "id": "c06",
              "name": "data_tab",
              "value": "Sales",
              "type": "string"
            },
            {
              "id": "c07",
              "name": "date_column",
              "value": "date",
              "type": "string"
            },
            {
              "id": "c08",
              "name": "group_column",
              "value": "product",
              "type": "string"
            },
            {
              "id": "c09",
              "name": "value_column",
              "value": "amount",
              "type": "string"
            },
            {
              "id": "c10",
              "name": "distinct_column",
              "value": "customer",
              "type": "string"
            },
            {
              "id": "c11",
              "name": "value_format",
              "value": "money",
              "type": "string"
            },
            {
              "id": "c12",
              "name": "value_label",
              "value": "Sales",
              "type": "string"
            },
            {
              "id": "c13",
              "name": "group_label",
              "value": "Product",
              "type": "string"
            },
            {
              "id": "c14",
              "name": "entry_label",
              "value": "Orders",
              "type": "string"
            },
            {
              "id": "c15",
              "name": "average_label",
              "value": "Average order",
              "type": "string"
            },
            {
              "id": "c16",
              "name": "distinct_label",
              "value": "Customers",
              "type": "string"
            },
            {
              "id": "c17",
              "name": "currency",
              "value": "JPY",
              "type": "string"
            },
            {
              "id": "c18",
              "name": "decimals",
              "value": 0,
              "type": "number"
            },
            {
              "id": "c19",
              "name": "number_locale",
              "value": "en-US",
              "type": "string"
            },
            {
              "id": "c20",
              "name": "timezone",
              "value": "Asia/Tokyo",
              "type": "string"
            },
            {
              "id": "c21",
              "name": "top_n",
              "value": 3,
              "type": "number"
            },
            {
              "id": "c22",
              "name": "summary_language",
              "value": "English",
              "type": "string"
            },
            {
              "id": "c23",
              "name": "report_tab",
              "value": "Report",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000007",
      "name": "Read the data",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        440,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheet_id }}/values/{{ encodeURIComponent($json.data_tab) }}?valueRenderOption=UNFORMATTED_VALUE",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api",
        "options": {}
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput"
    },
    {
      "id": "c4000000-0000-4000-8000-000000000008",
      "name": "Parse the data",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        0
      ],
      "parameters": {
        "jsCode": "// Data tab -> records with a date, a group, a value (1 per row when value_column is empty) and an optional distinct key.\nconst cfg = $('Config').first().json;\nconst rows = $json.values || [];\nconst head = (rows[0] || []).map((h) => String(h).trim());\nconst need = [cfg.date_column, cfg.group_column, cfg.value_column, cfg.distinct_column].filter(Boolean);\nconst missing = need.filter((h) => !head.includes(h));\nif (missing.length) throw new Error('The ' + cfg.data_tab + ' tab needs the columns ' + need.join(', ') + ' (set in Config). Missing: ' + missing.join(', ') + '. Found: ' + head.join(', '));\nconst toDate = (v) => (typeof v === 'number' ? new Date(Date.UTC(1899, 11, 30) + v * 86400000).toISOString().slice(0, 10) : String(v).trim().slice(0, 10).replace(/\\//g, '-'));\nconst records = rows.slice(1).filter((r) => r.some((c) => String(c).trim() !== '')).map((r) => {\n  const o = Object.fromEntries(head.map((h, i) => [h, r[i] ?? '']));\n  return {\n    date: toDate(o[cfg.date_column]),\n    group: String(o[cfg.group_column]).trim() || '(blank)',\n    value: cfg.value_column ? Number(String(o[cfg.value_column]).replace(/[,\\s¥$€£]/g, '')) : 1,\n    key: cfg.distinct_column ? String(o[cfg.distinct_column]).trim() : '',\n  };\n});\nconst bad = records.filter((s) => !/^\\d{4}-\\d{2}-\\d{2}$/.test(s.date) || !Number.isFinite(s.value));\nif (bad.length) throw new Error(bad.length + ' rows in ' + cfg.data_tab + ' have a date or value that cannot be read, e.g. ' + JSON.stringify(bad[0]));\nreturn [{ json: { records } }];"
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000009",
      "name": "Claude Haiku 4.5",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.5,
      "position": [
        880,
        220
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-haiku-4-5-20251001",
          "cachedResultName": "Claude Haiku 4.5"
        },
        "options": {}
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000010",
      "name": "Read the request",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.9,
      "position": [
        880,
        0
      ],
      "parameters": {
        "promptType": "define",
        "text": "=Today: {{ $now.setZone($('Config').first().json.timezone).toFormat('yyyy-MM-dd') }}\n\nRequest:\n{{ $('Config').first().json.request }}",
        "messages": {
          "messageValues": [
            {
              "type": "SystemMessagePromptTemplate",
              "message": "You read a request for a monthly report. Do not compute any numbers.\nReturn only JSON with these keys:\n- \"month\": the month the report is for, as YYYY-MM, or null\n- \"month_words\": the exact words in the request that give the month (for example \"September\", \"9月\", \"先月\", \"last month\", \"2026-09\"), or null\nCopy month_words exactly as it appears in the request."
            }
          ]
        }
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000011",
      "name": "Check the reading",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        0
      ],
      "parameters": {
        "jsCode": "// Code computes the month from the words and compares it with what Claude read.\nconst cfg = $('Config').first().json;\nconst request = String(cfg.request || '').trim();\nconst { records } = $('Parse the data').first().json;\nlet r;\ntry { r = JSON.parse(String($json.text ?? '').match(/\\{[\\s\\S]*\\}/)[0]); }\ncatch (e) { return [{ json: { ok: false, problem: 'Could not read the AI answer as JSON. Try again or write the month more simply.' } }]; }\nconst norm = (s) => String(s ?? '').toLowerCase().normalize('NFKC').replace(/\\s+/g, ' ').trim();\nconst req = norm(request);\nconst today = DateTime.now().setZone(cfg.timezone);\nconst monthNames = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'];\nconst pick = (mm) => { let y = today.year; if (mm > today.month) y -= 1; return y + '-' + String(mm).padStart(2, '0'); };\nfunction monthFromWords(w) {\n  const s = norm(w);\n  let m;\n  if ((m = s.match(/(\\d{4})\\s*[-\\/年.]\\s*(\\d{1,2})/))) return m[1] + '-' + m[2].padStart(2, '0');\n  if ((m = s.match(/(\\d{1,2})\\s*月/))) return pick(Number(m[1]));\n  for (let i = 0; i < 12; i++) if (s.includes(monthNames[i]) || new RegExp('\\\\b' + monthNames[i].slice(0, 3) + '\\\\b').test(s)) return pick(i + 1);\n  if (/先月|前月|last month/.test(s)) return today.minus({ months: 1 }).toFormat('yyyy-MM');\n  if (/今月|this month/.test(s)) return today.toFormat('yyyy-MM');\n  return null;\n}\nconst problems = [];\nlet month = null;\nif (!r.month_words) problems.push('The request does not say which month. Write it, for example \"September\", \"9月\" or \"2026-09\".');\nelse if (!req.includes(norm(r.month_words))) problems.push('The month words \"' + r.month_words + '\" are not in the request.');\nelse {\n  month = monthFromWords(r.month_words);\n  if (!month) problems.push('Could not read a month from \"' + r.month_words + '\".');\n  else if (month !== r.month) problems.push('The AI read the month as ' + r.month + ', but \"' + r.month_words + '\" means ' + month + '.');\n  else if (month > today.toFormat('yyyy-MM')) problems.push(month + ' is in the future.');\n  else if (!records.some((s) => s.date.startsWith(month))) problems.push('There is no data for ' + month + ' in the ' + cfg.data_tab + ' tab.');\n}\nreturn [{ json: { ok: problems.length === 0, problem: problems.join(' '), month, read: r } }];"
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000012",
      "name": "Reading OK?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1320,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "ok",
              "leftValue": "={{ $json.ok }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000013",
      "name": "Compute the numbers",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        -100
      ],
      "parameters": {
        "jsCode": "// Every number on the slides comes from here. A month still in progress is compared with the same days of the month before.\nconst cfg = $('Config').first().json;\nconst { records } = $('Parse the data').first().json;\nconst { month } = $json;\nconst today = DateTime.now().setZone(cfg.timezone);\nconst start = DateTime.fromFormat(month, 'yyyy-MM', { zone: cfg.timezone });\nconst prevStart = start.minus({ months: 1 });\nconst prevMonth = prevStart.toFormat('yyyy-MM');\nconst inProgress = month === today.toFormat('yyyy-MM');\nconst lastDay = inProgress ? today.day : start.endOf('month').day;\nconst dayOf = (s) => Number(s.date.slice(8, 10));\nconst cur = records.filter((s) => s.date.startsWith(month) && dayOf(s) <= lastDay);\nconst prev = records.filter((s) => s.date.startsWith(prevMonth) && (!inProgress || dayOf(s) <= lastDay));\nconst dec = Number(cfg.decimals);\nconst fmt = (x) => {\n  if (cfg.value_format === 'money') return new Intl.NumberFormat(cfg.number_locale, { style: 'currency', currency: cfg.currency, minimumFractionDigits: dec, maximumFractionDigits: dec }).format(x);\n  const s = new Intl.NumberFormat(cfg.number_locale, { minimumFractionDigits: dec, maximumFractionDigits: dec }).format(x);\n  return cfg.value_format === 'hours' ? s + ' h' : s;\n};\nconst pct = (a, b) => (b ? (a / b - 1) * 100 : null);\nconst sign = (p) => (p === null ? 'n/a' : (p >= 0 ? '+' : '') + p.toFixed(1) + '%');\nconst sum = (arr) => arr.reduce((t, s) => t + s.value, 0);\nconst total = sum(cur), prevTotal = sum(prev);\nconst entries = cur.length;\nconst counting = !cfg.value_column; // one row = one record: total and entries are the same number\nconst distinct = cfg.distinct_column ? new Set(cur.map((s) => s.key).filter(Boolean)).size : null;\nconst average = entries && !counting ? total / entries : null;\nconst vs = pct(total, prevTotal);\nconst groups = [...new Set(records.map((s) => s.group))];\nconst byGroup = groups.map((g) => ({ group: g, cur: sum(cur.filter((s) => s.group === g)), prev: sum(prev.filter((s) => s.group === g)) }));\nconst top = [...byGroup].filter((x) => x.cur > 0).sort((a, b) => b.cur - a.cur).slice(0, Number(cfg.top_n));\nconst label = (d, last) => d.toFormat('LLL d') + '–' + d.set({ day: last }).toFormat('d') + ', ' + d.toFormat('yyyy');\nconst period = inProgress ? label(start, lastDay) + ' (month to date)' : start.toFormat('LLLL yyyy');\nconst prevPeriod = inProgress ? label(prevStart, lastDay) : prevStart.toFormat('LLLL yyyy');\nconst share = (x) => (total ? Math.round((x / total) * 100) : 0);\nconst topLines = top.map((x, i) => (i + 1) + '. ' + x.group + ': ' + fmt(x.cur) + ' (' + share(x.cur) + '% of the total, ' + sign(pct(x.cur, x.prev)) + ' vs ' + prevStart.toFormat('LLL') + ')');\nconst values = {\n  report_title: cfg.report_title, period, prev_period: prevPeriod, prepared_on: today.toFormat('yyyy-MM-dd'),\n  value_label: cfg.value_label, group_label: cfg.group_label, entry_label: cfg.entry_label, average_label: cfg.average_label, distinct_label: cfg.distinct_label,\n  total: fmt(total), vs_last_month: sign(vs), entries: String(entries),\n  average: average === null ? '-' : fmt(average), distinct: distinct === null ? '-' : String(distinct),\n  top_groups: topLines.join('\\n'),\n};\n// The facts the AI may use for its summary, written exactly as they appear on the slides.\nconst facts = [\n  'Report: ' + cfg.report_title + '. Period: ' + period + '; compared with ' + prevPeriod,\n  cfg.value_label + ': ' + fmt(total) + ' (' + prevPeriod + ': ' + fmt(prevTotal) + ', change ' + sign(vs) + ')',\n  ...(counting ? [] : [cfg.entry_label + ': ' + entries + (average === null ? '' : '; ' + cfg.average_label + ': ' + fmt(average))]),\n  ...(distinct === null ? [] : [cfg.distinct_label + ': ' + distinct]),\n  ...topLines.map((l) => 'Top ' + cfg.group_label + ' ' + l),\n];\n// Every number string the summary is allowed to contain.\nconst allowed = new Set();\n(facts.join('\\n').match(/[+-]?[¥$€£]?\\d+(?:,\\d{3})*(?:\\.\\d+)?%?/g) || []).forEach((t) => allowed.add(t.replace(/^[+]/, '')));\nconst colName = (d) => d.toFormat('LLL yyyy') + (inProgress ? ' (to ' + lastDay + ')' : '');\nconst report = [[cfg.group_label, colName(start), colName(prevStart)], ...byGroup.map((x) => [x.group, x.cur, x.prev])];\n// blank rows below clear what an earlier, longer report left behind\nwhile (report.length < Math.max(30, groups.length + 1)) report.push(['', '', '']);\nreturn [{ json: { month, values, facts, allowed: [...allowed], report, file_name: cfg.report_title + ' ' + month } }];"
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000014",
      "name": "Write the Report tab",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        1760,
        -100
      ],
      "parameters": {
        "method": "PUT",
        "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Config').first().json.spreadsheet_id }}/values/{{ encodeURIComponent($('Config').first().json.report_tab + '!A1') }}?valueInputOption=RAW",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ values: $json.report }) }}",
        "options": {}
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput"
    },
    {
      "id": "c4000000-0000-4000-8000-000000000015",
      "name": "Claude Haiku 4.5 summary",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.5,
      "position": [
        1980,
        120
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-haiku-4-5-20251001",
          "cachedResultName": "Claude Haiku 4.5"
        },
        "options": {}
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000016",
      "name": "Write the summary",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.9,
      "position": [
        1980,
        -100
      ],
      "parameters": {
        "promptType": "define",
        "text": "={{ 'Language: ' + $('Config').first().json.summary_language + '\\n\\nFacts:\\n' + $('Compute the numbers').first().json.facts.map(f => '- ' + f).join('\\n') }}",
        "messages": {
          "messageValues": [
            {
              "type": "SystemMessagePromptTemplate",
              "message": "You write the summary slide of a monthly report.\nUse only the facts given. Do not compute, round or convert any number: copy each number exactly as it is written in the facts, with its currency sign or % sign.\nWrite 3 short lines in the given language. No greetings, no advice, no guesses about causes.\nReturn only JSON: {\"lines\": [\"...\", \"...\", \"...\"]}"
            }
          ]
        }
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000017",
      "name": "Find the chart",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        1760,
        120
      ],
      "parameters": {
        "method": "GET",
        "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Config').first().json.spreadsheet_id }}?fields=sheets(properties(title),charts(chartId))",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api",
        "options": {}
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput"
    },
    {
      "id": "c4000000-0000-4000-8000-000000000018",
      "name": "Check the summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2200,
        -100
      ],
      "parameters": {
        "jsCode": "// Every number in the AI summary must be one of the computed numbers. If not, the summary is replaced by a plain one made by code.\nconst c = $('Compute the numbers').first().json;\nconst allowed = new Set(c.allowed);\nlet lines = [];\ntry { lines = JSON.parse(String($json.text ?? '').match(/\\{[\\s\\S]*\\}/)[0]).lines || []; } catch (e) { lines = []; }\nlines = lines.map((l) => String(l).trim()).filter(Boolean).slice(0, 3);\nconst found = lines.flatMap((l) => (l.match(/[+-]?[¥$€£]?\\d+(?:,\\d{3})*(?:\\.\\d+)?%?/g) || []).map((t) => t.replace(/^[+]/, '')));\nconst unknown = found.filter((t) => !allowed.has(t));\nconst ok = lines.length > 0 && unknown.length === 0;\nconst plain = [\n  c.values.value_label + ': ' + c.values.total + ' (' + c.values.vs_last_month + ' vs ' + c.values.prev_period + ').',\n  c.values.entries !== c.values.total ? c.values.entry_label + ': ' + c.values.entries + '.' : '',\n  c.values.top_groups ? 'Top ' + c.values.group_label + ': ' + c.values.top_groups.split('\\n')[0].replace(/^1\\. /, '') + '.' : '',\n].filter(Boolean);\nconst summary = (ok ? lines : plain).map((l) => '• ' + l.replace(/^[•\\-\\*]\\s*/, '')).join('\\n');\nconst note = ok ? '' : (lines.length ? 'The AI summary used numbers that are not in the data (' + unknown.slice(0, 3).join(', ') + '), so a plain summary made by code was used.' : 'The AI summary could not be read, so a plain summary made by code was used.');\nconst values = { ...c.values, summary };\nconst requests = Object.entries(values).map(([k, v]) => ({ replaceAllText: { containsText: { text: '{{' + k + '}}', matchCase: true }, replaceText: String(v ?? '') } }));\nreturn [{ json: { ok: true, values, requests, ai_summary_used: ok, summary_note: note, unknown, file_name: c.file_name } }];"
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000019",
      "name": "Copy the template",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        2420,
        -200
      ],
      "parameters": {
        "resource": "file",
        "operation": "copy",
        "fileId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Config').first().json.template_id }}"
        },
        "name": "={{ $json.file_name }}",
        "sameFolder": true,
        "options": {}
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput"
    },
    {
      "id": "c4000000-0000-4000-8000-000000000020",
      "name": "Read the copy",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        2640,
        -200
      ],
      "parameters": {
        "method": "GET",
        "url": "=https://slides.googleapis.com/v1/presentations/{{ $json.id }}?fields=slides(objectId,pageElements(objectId,size,transform,shape(text(textElements(textRun(content))))))",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSlidesOAuth2Api",
        "options": {}
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput"
    },
    {
      "id": "c4000000-0000-4000-8000-000000000021",
      "name": "Plan the slide changes",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        -200
      ],
      "parameters": {
        "jsCode": "// Put the chart where the template has a text box saying {{chart}}, as a plain image (not linked),\n// so these slides do not change when the Report tab is rewritten for another month.\nconst cfg = $('Config').first().json;\nconst s = $('Check the summary').first().json;\nconst sheets = $('Find the chart').all(0)[0].json.sheets || [];\nconst tab = sheets.find((x) => x.properties && x.properties.title === cfg.report_tab);\nconst chartId = tab && tab.charts && tab.charts[0] ? tab.charts[0].chartId : null;\nlet box = null;\nfor (const slide of $json.slides || []) {\n  for (const el of slide.pageElements || []) {\n    const text = ((el.shape && el.shape.text && el.shape.text.textElements) || []).map((t) => (t.textRun ? t.textRun.content : '')).join('');\n    if (text.includes('{{chart}}')) { box = { page: slide.objectId, el }; break; }\n  }\n  if (box) break;\n}\nconst requests = [...s.requests];\nlet chart_note = '';\nif (box && chartId !== null) {\n  requests.push({ createSheetsChart: { spreadsheetId: cfg.spreadsheet_id, chartId, linkingMode: 'NOT_LINKED_IMAGE',\n    elementProperties: { pageObjectId: box.page, size: box.el.size, transform: box.el.transform } } });\n  requests.push({ deleteObject: { objectId: box.el.objectId } });\n} else if (box) {\n  requests.push({ replaceAllText: { containsText: { text: '{{chart}}', matchCase: true }, replaceText: '' } });\n  chart_note = 'No chart was found on the ' + cfg.report_tab + ' tab, so the slides have no chart.';\n} else if (chartId !== null) {\n  chart_note = 'The template has no text box saying {{chart}}, so the chart was not added.';\n}\nreturn [{ json: { presentationId: $('Copy the template').all(0)[0].json.id, requests, chart_note } }];"
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000022",
      "name": "Fill in the slides",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        3080,
        -200
      ],
      "parameters": {
        "method": "POST",
        "url": "=https://slides.googleapis.com/v1/presentations/{{ $json.presentationId }}:batchUpdate",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSlidesOAuth2Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ requests: $json.requests }) }}",
        "options": {}
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput"
    },
    {
      "id": "c4000000-0000-4000-8000-000000000023",
      "name": "Explain the failure",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3080,
        160
      ],
      "parameters": {
        "jsCode": "// The error comes from the node that failed after its retries.\nconst labels = { 'Read the data': 'reading the data tab', 'Write the Report tab': 'writing the Report tab', 'Find the chart': 'looking for the chart', 'Copy the template': 'copying the Slides template', 'Read the copy': 'reading the copied slides', 'Fill in the slides': 'filling in the slides' };\nconst node = $prevNode.name;\nconst err = $json.error && ($json.error.message || $json.error);\nreturn [{ json: { problem: 'Google returned an error while ' + (labels[node] || node) + (err ? ' (' + String(err).slice(0, 200) + ')' : '') + '.'\n  + (['Read the copy', 'Fill in the slides'].includes(node) ? ' An unfinished copy of the template is left in Google Drive; delete it.' : '') } }];"
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000024",
      "name": "Answer where asked",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.4,
      "position": [
        3300,
        -200
      ],
      "parameters": {
        "mode": "expression",
        "numberOutputs": 2,
        "output": "={{ $('Config').first().json.source === 'chat' ? 1 : 0 }}"
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000025",
      "name": "Problem: answer where asked",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.4,
      "position": [
        3300,
        160
      ],
      "parameters": {
        "mode": "expression",
        "numberOutputs": 2,
        "output": "={{ $('Config').first().json.source === 'chat' ? 1 : 0 }}"
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000026",
      "name": "Write the Chat reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3520,
        400
      ],
      "parameters": {
        "jsCode": "// The link is a button: a URL in the text gets a preview made before the copy was filled in.\nconst addon = $('Config').first().json.addon;\nlet message;\nif ($json.problem) message = { text: 'No slides were made. ' + $json.problem };\nelse {\n  const s = $('Check the summary').all(0)[0].json;\n  const chartNote = $('Plan the slide changes').all(0)[0].json.chart_note;\n  const url = 'https://docs.google.com/presentation/d/' + $('Copy the template').all(0)[0].json.id + '/edit';\n  message = {\n    text: 'Report ready: ' + s.values.period + '\\n' + s.values.value_label + ' ' + s.values.total + ' (' + s.values.vs_last_month + ' vs ' + s.values.prev_period + ').' + (s.summary_note ? '\\nNote: ' + s.summary_note : '') + (chartNote ? '\\nNote: ' + chartNote : ''),\n    cardsV2: [{ cardId: 'report', card: { sections: [{ widgets: [{ buttonList: { buttons: [{ text: 'Open the slides', onClick: { openLink: { url } } }] } }] }] } }],\n  };\n}\nconst reply = addon ? { hostAppDataAction: { chatDataAction: { createMessageAction: { message } } } } : message;\nreturn [{ json: { reply } }];"
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000027",
      "name": "Reply in Chat",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        3740,
        400
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify($json.reply) }}",
        "options": {}
      }
    },
    {
      "id": "c4000000-0000-4000-8000-000000000028",
      "name": "Done",
      "type": "n8n-nodes-base.form",
      "typeVersion": 2.5,
      "position": [
        3520,
        -300
      ],
      "parameters": {
        "operation": "completion",
        "respondWith": "text",
        "completionTitle": "={{ 'Report ready: ' + $('Check the summary').all(0)[0].json.values.period }}",
        "completionMessage": "={{ $('Check the summary').all(0)[0].json.values.value_label + ' ' + $('Check the summary').all(0)[0].json.values.total + ' (' + $('Check the summary').all(0)[0].json.values.vs_last_month + '). Slides: https://docs.google.com/presentation/d/' + $('Copy the template').all(0)[0].json.id + '/edit' + ($('Check the summary').all(0)[0].json.summary_note ? ' Note: ' + $('Check the summary').all(0)[0].json.summary_note : '') }}"
      },
      "webhookId": "7e2b4c91-3d5f-4a68-b1e0-9c8d2f6a4e13"
    },
    {
      "id": "c4000000-0000-4000-8000-000000000029",
      "name": "Show the problem",
      "type": "n8n-nodes-base.form",
      "typeVersion": 2.5,
      "position": [
        3520,
        120
      ],
      "parameters": {
        "operation": "completion",
        "respondWith": "text",
        "completionTitle": "No slides were made",
        "completionMessage": "={{ $json.problem }}"
      },
      "webhookId": "1f6c8a3e-9b2d-4e75-a0c4-6d3e8b1f2a97"
    },
    {
      "parameters": {
        "content": "## Monthly report slides from one sentence, without letting AI do the math\nAsk in Google Chat or on a form: \"September sales report\" or \"先月の売上報告\". **Claude only reads which month.** **Code computes every number** from one tab of your sheet (total, change vs last month, records, average, top groups), draws the chart from the Report tab, and fills a copy of your Google Slides template.\n\nWorks for any table with a date, a group and a number (or one row per record): sales by product, inquiries by channel, expenses by category, overtime by team. Set the columns and the words in **Config**.\n\nClaude also writes a 3-line summary from those numbers. **Code checks every number in it**; if one is not in the data (in my test, Claude added two shares into a new \"45%\"), a plain summary made by code is used instead.\n\n### Setup\n1. Credentials: Google Sheets, Google Drive, Google Slides (OAuth2) and Anthropic.\n2. A spreadsheet with a data tab (the sample uses **Sales**: date, customer, product, amount) and a **Report** tab with one chart on it.\n3. A Slides template with the {{placeholders}} from the description and a text box saying **{{chart}}** where the chart goes.\n4. Paste both IDs into **Config**. Try the form first.",
        "height": 1160,
        "width": 560,
        "color": 1
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1300,
        -260
      ],
      "id": "c4000000-0000-4000-9000-000000000029",
      "name": "Overview"
    },
    {
      "parameters": {
        "content": "## 1. The request comes in\n**Form**: open the form URL and write the request.\n**Google Chat** (optional, Workspace): create a Chat app in Google Cloud (Chat API > Configuration), choose **HTTP endpoint URL** and paste the production URL of **Google Chat event**. Change its path to a long random string and list who may use it in **Chat settings**. Chat waits 30 seconds; my runs took about 10 to 15 seconds.",
        "height": 820,
        "width": 1040,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -720,
        -260
      ],
      "id": "c4000000-0000-4000-9000-000000000030",
      "name": "Section 1"
    },
    {
      "parameters": {
        "content": "## 2. Claude reads the month, code checks it\nClaude returns the month and the exact words it used. Code checks that the words are in the request, computes the month from them itself (\"9月\", \"September\", \"last month\", \"2026-09\"), and stops on a future month or a month with no data.",
        "height": 620,
        "width": 1040,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        400,
        -260
      ],
      "id": "c4000000-0000-4000-9000-000000000031",
      "name": "Section 2"
    },
    {
      "parameters": {
        "content": "## 3. Code computes\nAll numbers on the slides. A month still in progress is compared with the same days of the month before. The Report tab is rewritten so its chart shows this month and last month.",
        "height": 560,
        "width": 460,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1500,
        -260
      ],
      "id": "c4000000-0000-4000-9000-000000000032",
      "name": "Section 3"
    },
    {
      "parameters": {
        "content": "## 4. AI summary, checked\nClaude gets only the computed facts. Every number in its 3 lines must be one of them, or a plain summary is used.",
        "height": 560,
        "width": 460,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1940,
        -260
      ],
      "id": "c4000000-0000-4000-9000-000000000033",
      "name": "Section 4"
    },
    {
      "parameters": {
        "content": "## 5. Slides\nCopy the template, replace the {{placeholders}}, and put the Report tab chart where the {{chart}} box is, as a plain image (not linked), so old reports do not change next month. Google calls are retried 3 times; if one still fails, **Explain the failure** says which step stopped.",
        "height": 740,
        "width": 920,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2380,
        -380
      ],
      "id": "c4000000-0000-4000-9000-000000000034",
      "name": "Section 5"
    },
    {
      "parameters": {
        "content": "## 6. The answer\nA result page on the form, or a Google Chat message with an \"Open the slides\" button (both Chat app types).",
        "height": 1020,
        "width": 700,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3260,
        -460
      ],
      "id": "c4000000-0000-4000-9000-000000000035",
      "name": "Section 6"
    }
  ],
  "connections": {
    "Google Chat event": {
      "main": [
        [
          {
            "node": "Chat settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chat settings": {
      "main": [
        [
          {
            "node": "Read the chat event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the chat event": {
      "main": [
        [
          {
            "node": "Is it a request?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Request form": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is it a request?": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reply in Chat",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Read the data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the data": {
      "main": [
        [
          {
            "node": "Parse the data",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Explain the failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse the data": {
      "main": [
        [
          {
            "node": "Read the request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude Haiku 4.5": {
      "ai_languageModel": [
        [
          {
            "node": "Read the request",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Read the request": {
      "main": [
        [
          {
            "node": "Check the reading",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check the reading": {
      "main": [
        [
          {
            "node": "Reading OK?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reading OK?": {
      "main": [
        [
          {
            "node": "Compute the numbers",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Problem: answer where asked",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute the numbers": {
      "main": [
        [
          {
            "node": "Write the Report tab",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write the Report tab": {
      "main": [
        [
          {
            "node": "Find the chart",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Explain the failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find the chart": {
      "main": [
        [
          {
            "node": "Write the summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Explain the failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude Haiku 4.5 summary": {
      "ai_languageModel": [
        [
          {
            "node": "Write the summary",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Write the summary": {
      "main": [
        [
          {
            "node": "Check the summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check the summary": {
      "main": [
        [
          {
            "node": "Copy the template",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Copy the template": {
      "main": [
        [
          {
            "node": "Read the copy",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Explain the failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the copy": {
      "main": [
        [
          {
            "node": "Plan the slide changes",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Explain the failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Plan the slide changes": {
      "main": [
        [
          {
            "node": "Fill in the slides",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fill in the slides": {
      "main": [
        [
          {
            "node": "Answer where asked",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Explain the failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Explain the failure": {
      "main": [
        [
          {
            "node": "Problem: answer where asked",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Answer where asked": {
      "main": [
        [
          {
            "node": "Done",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Write the Chat reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Problem: answer where asked": {
      "main": [
        [
          {
            "node": "Show the problem",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Write the Chat reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write the Chat reply": {
      "main": [
        [
          {
            "node": "Reply in Chat",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}