This workflow corresponds to n8n.io template #16538 — we link there as the canonical source.
This workflow follows the Chainllm → Google Drive recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "v41nqKecTo3htFFl",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "Generate monthly BigQuery KPI PDF reports with Claude, Google Docs and Outlook",
"tags": [],
"nodes": [
{
"id": "d440d2d7-9347-4ff8-a92d-bcdc04a57c1d",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
6704,
224
],
"parameters": {
"width": 432,
"height": 864,
"content": "##Generate monthly BigQuery KPI PDF reports with Claude, Google Docs and Outlook\n\n### How it works\n\nThis workflow runs monthly to generate a KPI report for the previous full month. It queries BigQuery for revenue, category, weekly trend, and customer metrics, consolidates the results, and uses Claude to create a narrative summary. It copies a Google Docs template, fills it with KPI data and the AI narrative, exports the document as a PDF, then archives it to OneDrive, emails it through Outlook, and posts a Teams summary.\n\n### Setup steps\n\n- Configure the monthly schedule trigger for the desired reporting time and timezone.\n- Connect Google BigQuery credentials and update the SQL queries, dataset, table names, and date filters used by the KPI query nodes.\n- Configure Anthropic credentials for the Claude chat model used by the LLM chain.\n- Connect Google Drive and Google Docs credentials, and set the report template file ID plus any destination folder IDs needed for copying and exporting.\n- Connect Microsoft OneDrive, Outlook, and Teams credentials, then configure archive locations, email recipients, and Teams channel or chat targets.\n\n### Customization\n\nAdjust the BigQuery queries, template placeholders, Claude prompt, email recipients, and Teams card content to match the KPIs and audience for the report."
},
"typeVersion": 1
},
{
"id": "3449e3d8-f812-4316-ae48-78565793309b",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
7200,
448
],
"parameters": {
"color": 7,
"width": 576,
"height": 384,
"content": "## Start reporting period\n\nTriggers the workflow on the monthly schedule and calculates the previous full month as the reporting window used by all downstream queries."
},
"typeVersion": 1
},
{
"id": "b1c755d5-2a0c-4e6d-8b0d-4fbbc3eb8e99",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
7824,
32
],
"parameters": {
"color": 7,
"width": 416,
"height": 304,
"content": "## Fetch revenue KPIs\n\nRuns the revenue KPI query in BigQuery and tags those rows with their source before merging."
},
"typeVersion": 1
},
{
"id": "64eecea3-b5df-4609-9ef1-04e80145b790",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
7824,
368
],
"parameters": {
"color": 7,
"width": 416,
"height": 320,
"content": "## Fetch category rankings\n\nQueries top product or business categories from BigQuery and labels the returned category results for later aggregation."
},
"typeVersion": 1
},
{
"id": "1a879d20-27c4-4b6a-a60c-a5fdfb7de55c",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
7824,
720
],
"parameters": {
"color": 7,
"width": 416,
"height": 320,
"content": "## Fetch weekly trend\n\nRetrieves week-by-week trend data from BigQuery and tags the weekly results with their source query identifier."
},
"typeVersion": 1
},
{
"id": "58ed5879-7e27-465a-9c61-9a4578dd0393",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
7824,
1072
],
"parameters": {
"color": 7,
"width": 416,
"height": 320,
"content": "## Fetch top customers\n\nQueries top customer metrics from BigQuery and annotates the customer rows so they can be separated after the merge."
},
"typeVersion": 1
},
{
"id": "7c752f98-2c2b-4e64-a8f0-d7f39f120a00",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
8320,
432
],
"parameters": {
"color": 7,
"width": 656,
"height": 400,
"content": "## Consolidate report data\n\nCombines all tagged BigQuery outputs, aggregates them into a structured report payload, and explicitly carries that payload forward for both narrative and document creation branches."
},
"typeVersion": 1
},
{
"id": "1ab5cc2e-23f5-410e-bee4-d154d770d26a",
"name": "Sticky Note7",
"type": "n8n-nodes-base.stickyNote",
"position": [
9040,
64
],
"parameters": {
"color": 7,
"width": 608,
"height": 576,
"content": "## Generate Claude narrative\n\nSends the report payload to Claude through the LLM chain, then parses and merges the generated narrative back into the report data structure.\n\n"
},
"typeVersion": 1
},
{
"id": "21eef0ce-2e07-43c9-9450-81776ba3111c",
"name": "Sticky Note8",
"type": "n8n-nodes-base.stickyNote",
"position": [
9040,
688
],
"parameters": {
"color": 7,
"width": 608,
"height": 352,
"content": "## Copy report template\n\nCreates a new Google Drive copy of the report template in the lower document-preparation branch.\n"
},
"typeVersion": 1
},
{
"id": "43d21119-9042-4890-b589-46ab9423bd0d",
"name": "Sticky Note9",
"type": "n8n-nodes-base.stickyNote",
"position": [
9696,
432
],
"parameters": {
"color": 7,
"width": 416,
"height": 384,
"content": "## Prepare template replacements\n\nWaits for both the Claude-enhanced report data and copied document ID, then builds the Google Docs replacement requests for template placeholders."
},
"typeVersion": 1
},
{
"id": "71b041f9-4b5c-49ee-8070-3348b1859320",
"name": "Sticky Note10",
"type": "n8n-nodes-base.stickyNote",
"position": [
10144,
432
],
"parameters": {
"color": 7,
"width": 448,
"height": 384,
"content": "## Fill and export PDF\n\nApplies the replacement requests to the copied Google Doc and exports the completed report as a PDF through Google Drive.\n\n"
},
"typeVersion": 1
},
{
"id": "21816bb9-9626-48cc-b4b8-b6a0fdf50942",
"name": "Sticky Note11",
"type": "n8n-nodes-base.stickyNote",
"position": [
10640,
368
],
"parameters": {
"color": 7,
"width": 608,
"height": 512,
"content": "## Distribute finished report\n\nArchives the generated PDF to OneDrive, emails it through Outlook, and posts a summary notification to Microsoft Teams.\n\n"
},
"typeVersion": 1
},
{
"id": "af873869-33b2-4bad-abe3-2c6b1b5053fb",
"name": "When First of Month at 07:00",
"type": "n8n-nodes-base.scheduleTrigger",
"notes": "Runs on the 1st of every month at 07:00 UTC. Change expression to weekly (0 7 * * 1) or daily (0 7 * * *) as needed.",
"position": [
7312,
640
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 7 1 * *"
}
]
}
},
"typeVersion": 1.2
},
{
"id": "e54f406c-cc83-4f74-8855-e75e8b8b5011",
"name": "Calculate Previous Month",
"type": "n8n-nodes-base.code",
"notes": "Calculates start/end dates for the previous full calendar month and generates a collision-safe report ID using crypto.randomUUID().",
"position": [
7536,
640
],
"parameters": {
"jsCode": "const now = new Date();\nconst firstOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);\nconst lastOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0);\nconst fmt = (d) => d.toISOString().split('T')[0];\nconst startDate = fmt(firstOfLastMonth);\nconst endDate = fmt(lastOfLastMonth);\nconst periodSlug = startDate.replace(/-/g, '');\nconst reportId = `RPT-${periodSlug}-${crypto.randomUUID().split('-')[0].toUpperCase()}`;\nreturn [{ json: {\n label: firstOfLastMonth.toLocaleString('en-GB', { month: 'long', year: 'numeric' }),\n startDate,\n endDate,\n reportId,\n generatedAt: now.toISOString()\n} }];"
},
"typeVersion": 2
},
{
"id": "e69b456f-8ba7-463b-8f1a-4b01f81daf5e",
"name": "Fetch Revenue KPIs",
"type": "n8n-nodes-base.googleBigQuery",
"notes": "Returns one aggregated row. Credential: Google BigQuery. Env vars: GCP_PROJECT_ID, BQ_DATASET. Update table name if not 'orders'.",
"position": [
7872,
160
],
"parameters": {
"options": {
"location": "EU"
},
"sqlQuery": "SELECT\n COUNT(*) AS total_orders,\n SUM(order_total) AS total_revenue,\n AVG(order_total) AS avg_order_value,\n COUNT(DISTINCT customer_id) AS unique_customers,\n COUNTIF(is_new_customer = TRUE) AS new_customers,\n COUNTIF(is_new_customer = FALSE) AS returning_customers\nFROM `{{ $env.BQ_DATASET }}.orders`\nWHERE DATE(created_at) BETWEEN @start_date AND @end_date",
"projectId": "={{ $env.GCP_PROJECT_ID }}"
},
"credentials": {
"googleBigQueryOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 2
},
{
"id": "23e4cade-1863-4d59-94a2-03beeb73ce42",
"name": "Fetch Top Sales Categories",
"type": "n8n-nodes-base.googleBigQuery",
"notes": "Returns up to 10 rows, one per category. Credential: Google BigQuery.",
"position": [
7872,
528
],
"parameters": {
"options": {
"location": "EU"
},
"sqlQuery": "SELECT\n product_category,\n COUNT(*) AS orders,\n SUM(order_total) AS revenue,\n ROUND(100.0 * SUM(order_total) / SUM(SUM(order_total)) OVER (), 1) AS revenue_pct\nFROM `{{ $env.BQ_DATASET }}.orders`\nWHERE DATE(created_at) BETWEEN @start_date AND @end_date\nGROUP BY product_category\nORDER BY revenue DESC\nLIMIT 10",
"projectId": "={{ $env.GCP_PROJECT_ID }}"
},
"typeVersion": 2
},
{
"id": "8ed3cf3a-be2d-44c1-b85c-b027c6f05b09",
"name": "Fetch Weekly Sales Trend",
"type": "n8n-nodes-base.googleBigQuery",
"notes": "Returns one row per week in the reporting period. Credential: Google BigQuery.",
"position": [
7872,
880
],
"parameters": {
"options": {
"location": "EU"
},
"sqlQuery": "SELECT\n DATE_TRUNC(DATE(created_at), WEEK) AS week_start,\n COUNT(*) AS orders,\n SUM(order_total) AS weekly_revenue\nFROM `{{ $env.BQ_DATASET }}.orders`\nWHERE DATE(created_at) BETWEEN @start_date AND @end_date\nGROUP BY week_start\nORDER BY week_start ASC",
"projectId": "={{ $env.GCP_PROJECT_ID }}"
},
"credentials": {
"googleBigQueryOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 2
},
{
"id": "bce15ffd-f877-4f9e-aab0-cf1896cd10a7",
"name": "Fetch Top Customers",
"type": "n8n-nodes-base.googleBigQuery",
"notes": "Returns up to 10 rows, one per customer. Credential: Google BigQuery.",
"position": [
7872,
1232
],
"parameters": {
"options": {
"location": "EU"
},
"sqlQuery": "SELECT\n customer_name,\n COUNT(*) AS orders,\n SUM(order_total) AS total_spent\nFROM `{{ $env.BQ_DATASET }}.orders`\nWHERE DATE(created_at) BETWEEN @start_date AND @end_date\nGROUP BY customer_name\nORDER BY total_spent DESC\nLIMIT 10",
"projectId": "={{ $env.GCP_PROJECT_ID }}"
},
"typeVersion": 2
},
{
"id": "9a848455-3aee-475e-96fb-2de6d2f33b35",
"name": "Label Revenue Data",
"type": "n8n-nodes-base.code",
"position": [
8096,
160
],
"parameters": {
"jsCode": "return $input.all().map(item => ({ json: { ...item.json, _sourceQuery: 'revenue' } }));"
},
"typeVersion": 2
},
{
"id": "70c8feb8-bdd9-4582-b067-e8440a4ee6a8",
"name": "Label Category Data",
"type": "n8n-nodes-base.code",
"position": [
8096,
528
],
"parameters": {
"jsCode": "return $input.all().map(item => ({ json: { ...item.json, _sourceQuery: 'categories' } }));"
},
"typeVersion": 2
},
{
"id": "55eeee38-576a-4d03-8208-9de2d81320c8",
"name": "Label Weekly Data",
"type": "n8n-nodes-base.code",
"position": [
8096,
880
],
"parameters": {
"jsCode": "return $input.all().map(item => ({ json: { ...item.json, _sourceQuery: 'weekly' } }));"
},
"typeVersion": 2
},
{
"id": "3fc6878b-dccd-402a-8068-ea3fc1075d53",
"name": "Label Customer Data",
"type": "n8n-nodes-base.code",
"position": [
8096,
1232
],
"parameters": {
"jsCode": "return $input.all().map(item => ({ json: { ...item.json, _sourceQuery: 'customers' } }));"
},
"typeVersion": 2
},
{
"id": "c962ffc4-9024-4447-86fb-9d65fa780c58",
"name": "Merge BigQuery Results",
"type": "n8n-nodes-base.merge",
"notes": "mode=append preserves ALL rows from all 4 branches. waitForAll (the default) would truncate multi-row results to 1 row per branch, emptying topCategories, weeklyTrend, and topCustomers arrays.",
"position": [
8384,
592
],
"parameters": {
"numberInputs": 4
},
"typeVersion": 3
},
{
"id": "326deef6-f208-43d8-ba78-1a2ed5ebe942",
"name": "Consolidate BigQuery Data",
"type": "n8n-nodes-base.code",
"position": [
8608,
624
],
"parameters": {
"jsCode": "const period = $('Calculate Previous Month').first().json;\nconst allItems = $input.all().map(i => i.json);\nconst revenueRow = allItems.find(i => i._sourceQuery === 'revenue') || {};\nconst categoryRows = allItems.filter(i => i._sourceQuery === 'categories');\nconst weeklyRows = allItems.filter(i => i._sourceQuery === 'weekly');\nconst customerRows = allItems.filter(i => i._sourceQuery === 'customers');\nconst eur = (v) => `EUR ${Number(v || 0).toLocaleString('en-FI', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;\nconst payload = {\n report: {\n id: period.reportId,\n period: period.label,\n startDate: period.startDate,\n endDate: period.endDate,\n generatedAt: period.generatedAt,\n generatedBy: 'AutomiQ BigQuery Report Agent'\n },\n company: {\n name: 'AutomiQ / KW Catering & Consulting Oy',\n website: 'automiq.fi',\n preparedBy: 'Mychel Garzon'\n },\n kpis: {\n totalOrders: Number(revenueRow.total_orders || 0),\n totalRevenue: eur(revenueRow.total_revenue),\n totalRevenueRaw: Number(revenueRow.total_revenue || 0),\n avgOrderValue: eur(revenueRow.avg_order_value),\n uniqueCustomers: Number(revenueRow.unique_customers || 0),\n newCustomers: Number(revenueRow.new_customers || 0),\n returningCustomers: Number(revenueRow.returning_customers || 0),\n newCustomerPct: revenueRow.unique_customers > 0\n ? Math.round((revenueRow.new_customers / revenueRow.unique_customers) * 100) + '%'\n : '0%'\n },\n topCategories: categoryRows.map(r => ({\n category: r.product_category,\n orders: Number(r.orders),\n revenue: eur(r.revenue),\n revenuePct: r.revenue_pct + '%'\n })),\n weeklyTrend: weeklyRows.map(r => ({\n weekStart: r.week_start,\n orders: Number(r.orders),\n revenue: eur(r.weekly_revenue)\n })),\n topCustomers: customerRows.map(r => ({\n name: r.customer_name,\n orders: Number(r.orders),\n totalSpent: eur(r.total_spent)\n }))\n};\nreturn [{ json: payload }];"
},
"typeVersion": 2
},
{
"id": "a0892539-ffad-4ec0-b4fb-5c89efa2bd0a",
"name": "Transfer Report Data",
"type": "n8n-nodes-base.code",
"notes": "Wraps the full report payload under a 'reportData' key so downstream nodes can reference it via $json.reportData without cross-branch node lookups.",
"position": [
8832,
624
],
"parameters": {
"jsCode": "const reportData = $input.first().json;\nreturn [{ json: { reportData } }];"
},
"typeVersion": 2
},
{
"id": "4887c977-e14b-463d-b6fc-55e22c81f10c",
"name": "Create AI Narrative with Claude",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"notes": "Basic LLM Chain. The Claude Chat Model sub-node must be connected to this node's ai_languageModel input (bottom connector on canvas). Output: { text: '<JSON string>' } parsed by Integrate Narrative with Data.",
"position": [
9088,
272
],
"parameters": {},
"typeVersion": 1.4
},
{
"id": "c2059374-e4c8-4772-8d13-c1fc8b1f35fa",
"name": "Integrate Narrative with Data",
"type": "n8n-nodes-base.code",
"notes": "Parses Claude's JSON response and merges the narrative into the report payload. Falls back to KPI-derived text if Claude returns invalid JSON or is unavailable.",
"position": [
9440,
272
],
"parameters": {
"jsCode": "const chainOutput = $input.first().json;\nconst reportData = $('Transfer Report Data').first().json.reportData;\nconst rawText = chainOutput.text || chainOutput.output || chainOutput.content?.[0]?.text || '';\nlet narrative;\nif (rawText) {\n try {\n const cleaned = rawText.replace(/```json|```/g, '').trim();\n narrative = JSON.parse(cleaned);\n } catch(e) {\n narrative = null;\n }\n}\nif (!narrative) {\n narrative = {\n executiveSummary: `KPI report for ${reportData.report.period}. Total revenue: ${reportData.kpis.totalRevenue}. Total orders: ${reportData.kpis.totalOrders}. AI narrative unavailable this cycle.`,\n revenueAnalysis: `Revenue: ${reportData.kpis.totalRevenue}. Avg order value: ${reportData.kpis.avgOrderValue}.`,\n categoryInsights: 'See category breakdown table.',\n customerInsights: `${reportData.kpis.uniqueCustomers} unique customers. New: ${reportData.kpis.newCustomerPct}.`,\n recommendation: 'AI narrative could not be generated. Review KPI data manually.'\n };\n}\nreportData.narrative = narrative;\nreturn [{ json: reportData }];"
},
"typeVersion": 2
},
{
"id": "9d6b760c-ac53-4f3e-8f61-052210fc5138",
"name": "Claude Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
"notes": "Anthropic Chat Model sub-node. Credential: Anthropic API (native n8n credential \u2014 paste your API key in the credential form). Connect this node's output to the ai_languageModel input (bottom socket) of Create AI Narrative with Claude.",
"position": [
9088,
496
],
"parameters": {
"model": "claude-sonnet-4-6",
"options": {
"temperature": 0.3
}
},
"typeVersion": 1.2
},
{
"id": "c2d77142-57aa-4a6f-930f-feb8a6d541b7",
"name": "Duplicate Report Template",
"type": "n8n-nodes-base.googleDrive",
"notes": "Copies the master Google Docs template to your reports folder. The original is never modified. Credential: Google Drive OAuth2. Env vars: GDOCS_TEMPLATE_FILE_ID (Drive file ID from the Doc URL), GDRIVE_REPORTS_FOLDER_ID (destination folder ID).",
"position": [
9264,
864
],
"parameters": {
"name": "=KPI_Report_{{ $('Integrate Narrative with Data').first().json.report.period.replace(/ /g, '_') }}_{{ $('Integrate Narrative with Data').first().json.report.id }}",
"fileId": "={{ $env.GDOCS_TEMPLATE_FILE_ID }}",
"options": {},
"operation": "copy"
},
"typeVersion": 3
},
{
"id": "933b3767-6e97-413d-99dc-a9287966fef5",
"name": "Combine Narrative and Doc ID",
"type": "n8n-nodes-base.merge",
"notes": "Waits for both concurrent branches: input[0] = narrative-enriched report payload from Integrate Narrative with Data, input[1] = Drive copy response containing the new Doc ID from Duplicate Report Template. Ensures Construct Replacement Requests fires exactly once with both data sets available.",
"position": [
9744,
624
],
"parameters": {
"mode": "waitForAll"
},
"typeVersion": 3
},
{
"id": "69e9d44b-3aaf-4122-97e2-29cf9b09ce69",
"name": "Construct Replacement Requests",
"type": "n8n-nodes-base.code",
"notes": "Builds the Docs API batchUpdate payload. Each {{PLACEHOLDER}} in your template Doc becomes a replaceAllText request. Tables render as monospace plain text. copiedDocId is passed forward for the Populate and Export nodes.",
"position": [
9968,
624
],
"parameters": {
"jsCode": "const allItems = $input.all();\nconst reportData = allItems.find(i => i.json.report)?.json;\nconst copiedDocId = allItems.find(i => i.json.id && !i.json.report)?.json.id;\nif (!reportData) throw new Error('Construct Replacement Requests: reportData not found in merged input');\nif (!copiedDocId) throw new Error('Construct Replacement Requests: copiedDocId not found in merged input');\n\nconst tableText = (headers, rows, cols) => {\n const pad = (s, n) => String(s).slice(0, n).padEnd(n);\n const widths = cols.map((c, i) => Math.max(headers[i].length, ...rows.map(r => String(r[c] || '').length)));\n const hr = widths.map(w => '-'.repeat(w + 2)).join('+');\n const row = (cells) => cells.map((c, i) => ' ' + pad(c, widths[i]) + ' ').join('|');\n return [row(headers), hr, ...rows.map(r => row(cols.map(c => r[c] || '')))].join('\\n');\n};\n\nconst categoriesTable = tableText(\n ['Category', 'Orders', 'Revenue', 'Share'],\n reportData.topCategories,\n ['category', 'orders', 'revenue', 'revenuePct']\n);\nconst weeklyTable = tableText(\n ['Week Starting', 'Orders', 'Revenue'],\n reportData.weeklyTrend,\n ['weekStart', 'orders', 'revenue']\n);\nconst customersTable = tableText(\n ['Customer', 'Orders', 'Total Spent'],\n reportData.topCustomers,\n ['name', 'orders', 'totalSpent']\n);\n\nconst replacements = [\n ['{{REPORT_PERIOD}}', reportData.report.period],\n ['{{REPORT_ID}}', reportData.report.id],\n ['{{GENERATED_AT}}', reportData.report.generatedAt],\n ['{{KPI_TOTAL_REVENUE}}', reportData.kpis.totalRevenue],\n ['{{KPI_TOTAL_ORDERS}}', String(reportData.kpis.totalOrders)],\n ['{{KPI_AVG_ORDER}}', reportData.kpis.avgOrderValue],\n ['{{KPI_UNIQUE_CUSTOMERS}}', String(reportData.kpis.uniqueCustomers)],\n ['{{KPI_NEW_CUSTOMERS}}', String(reportData.kpis.newCustomers)],\n ['{{KPI_NEW_PCT}}', reportData.kpis.newCustomerPct],\n ['{{NARRATIVE_SUMMARY}}', reportData.narrative.executiveSummary],\n ['{{NARRATIVE_REVENUE}}', reportData.narrative.revenueAnalysis],\n ['{{NARRATIVE_CATEGORIES}}', reportData.narrative.categoryInsights],\n ['{{NARRATIVE_CUSTOMERS}}', reportData.narrative.customerInsights],\n ['{{NARRATIVE_RECOMMENDATION}}',reportData.narrative.recommendation],\n ['{{TOP_CATEGORIES_TABLE}}', categoriesTable],\n ['{{WEEKLY_TREND_TABLE}}', weeklyTable],\n ['{{TOP_CUSTOMERS_TABLE}}', customersTable]\n];\n\nconst requests = replacements.map(([placeholder, newText]) => ({\n replaceAllText: {\n containsText: { text: placeholder, matchCase: true },\n replaceText: newText || ''\n }\n}));\n\nreturn [{ json: { copiedDocId, requests } }];"
},
"typeVersion": 2
},
{
"id": "53f1c68f-a798-48e2-96ca-badb7dd354a9",
"name": "Populate Template Fields",
"type": "n8n-nodes-base.googleDocs",
"notes": "Calls Google Docs batchUpdate to replace all {{PLACEHOLDER}} strings in the copied Doc. Requires typeVersion 2 for the requestsJson parameter. Credential: Google Docs OAuth2 (scope: https://www.googleapis.com/auth/documents).",
"position": [
10192,
624
],
"parameters": {
"actionsUi": {
"actionFields": [
{
"action": "insert"
}
]
},
"operation": "update"
},
"credentials": {
"googleDocsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 2
},
{
"id": "eb82940f-2ffc-4a72-88b0-3ce03094929a",
"name": "Export Document to PDF",
"type": "n8n-nodes-base.googleDrive",
"notes": "Exports the populated Google Doc as PDF using Drive's native file conversion. Binary stored as 'report_pdf' \u2014 the same property name expected by Save Report to OneDrive and Send Report via Outlook. Credential: Google Drive OAuth2.",
"position": [
10416,
624
],
"parameters": {
"fileId": "={{ $('Construct Replacement Requests').first().json.copiedDocId }}",
"options": {
"googleFileConversion": {
"conversion": {
"docsToFormat": "application/pdf"
}
}
},
"operation": "download"
},
"typeVersion": 3
},
{
"id": "daf0224e-e93c-4acb-b00a-6e1fcad17bad",
"name": "Save Report to OneDrive",
"type": "n8n-nodes-base.microsoftOneDrive",
"notes": "Archives the generated PDF to OneDrive. Credential: Microsoft OneDrive OAuth2. Env var: ONEDRIVE_REPORTS_FOLDER_ID (OneDrive folder GUID).",
"position": [
10864,
528
],
"parameters": {},
"typeVersion": 1
},
{
"id": "9616bf10-b5cc-4de8-bcd8-54d169f1aa96",
"name": "Send Report via Outlook",
"type": "n8n-nodes-base.microsoftOutlook",
"notes": "Emails the PDF report to all recipients. Credential: Microsoft Outlook OAuth2. Env var: REPORT_RECIPIENTS (comma-separated email addresses).",
"position": [
10752,
720
],
"parameters": {
"subject": "=Monthly KPI Report \u2013 {{ $('Integrate Narrative with Data').first().json.report.period }} | {{ $('Integrate Narrative with Data').first().json.report.id }}",
"bodyContent": "=<h2>Monthly KPI Report \u2013 {{ $('Integrate Narrative with Data').first().json.report.period }}</h2>\n<p>{{ $('Integrate Narrative with Data').first().json.narrative.executiveSummary }}</p>\n<p>Please find the full PDF report attached.</p>\n<hr/>\n<h3>Key Highlights</h3>\n<ul>\n <li><strong>Total Revenue:</strong> {{ $('Integrate Narrative with Data').first().json.kpis.totalRevenue }}</li>\n <li><strong>Total Orders:</strong> {{ $('Integrate Narrative with Data').first().json.kpis.totalOrders }}</li>\n <li><strong>Avg Order Value:</strong> {{ $('Integrate Narrative with Data').first().json.kpis.avgOrderValue }}</li>\n <li><strong>Unique Customers:</strong> {{ $('Integrate Narrative with Data').first().json.kpis.uniqueCustomers }} ({{ $('Integrate Narrative with Data').first().json.kpis.newCustomerPct }} new)</li>\n</ul>\n<p><em>AutomiQ / KW Catering & Consulting Oy — automiq.fi</em></p>",
"toRecipients": "={{ $env.REPORT_RECIPIENTS }}",
"additionalFields": {
"attachments": {
"attachments": [
{
"binaryPropertyName": "report_pdf"
}
]
}
}
},
"typeVersion": 2
},
{
"id": "f804a6a1-a32e-4cb9-8da2-f41750384fe0",
"name": "Share Summary on Teams",
"type": "n8n-nodes-base.microsoftTeams",
"notes": "Posts a KPI summary card to a Teams channel. Credential: Microsoft Teams OAuth2. Env vars: TEAMS_TEAM_ID, TEAMS_CHANNEL_ID (find both via Microsoft Graph Explorer).",
"position": [
10976,
720
],
"parameters": {
"teamId": {
"__rl": true,
"mode": "list",
"value": ""
},
"options": {}
},
"typeVersion": 2
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"executionOrder": "v1"
},
"versionId": "3ccc957b-82d4-477f-9beb-4cb115286633",
"nodeGroups": [],
"connections": {
"Claude Chat Model": {
"ai_languageModel": [
[
{
"node": "Create AI Narrative with Claude",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Label Weekly Data": {
"main": [
[
{
"node": "Merge BigQuery Results",
"type": "main",
"index": 2
}
]
]
},
"Fetch Revenue KPIs": {
"main": [
[
{
"node": "Label Revenue Data",
"type": "main",
"index": 0
}
]
]
},
"Label Revenue Data": {
"main": [
[
{
"node": "Merge BigQuery Results",
"type": "main",
"index": 0
}
]
]
},
"Fetch Top Customers": {
"main": [
[
{
"node": "Label Customer Data",
"type": "main",
"index": 0
}
]
]
},
"Label Category Data": {
"main": [
[
{
"node": "Merge BigQuery Results",
"type": "main",
"index": 1
}
]
]
},
"Label Customer Data": {
"main": [
[
{
"node": "Merge BigQuery Results",
"type": "main",
"index": 3
}
]
]
},
"Transfer Report Data": {
"main": [
[
{
"node": "Create AI Narrative with Claude",
"type": "main",
"index": 0
},
{
"node": "Duplicate Report Template",
"type": "main",
"index": 0
}
]
]
},
"Export Document to PDF": {
"main": [
[
{
"node": "Save Report to OneDrive",
"type": "main",
"index": 0
},
{
"node": "Send Report via Outlook",
"type": "main",
"index": 0
}
]
]
},
"Merge BigQuery Results": {
"main": [
[
{
"node": "Consolidate BigQuery Data",
"type": "main",
"index": 0
}
]
]
},
"Send Report via Outlook": {
"main": [
[
{
"node": "Share Summary on Teams",
"type": "main",
"index": 0
}
]
]
},
"Calculate Previous Month": {
"main": [
[
{
"node": "Fetch Revenue KPIs",
"type": "main",
"index": 0
},
{
"node": "Fetch Top Sales Categories",
"type": "main",
"index": 0
},
{
"node": "Fetch Weekly Sales Trend",
"type": "main",
"index": 0
},
{
"node": "Fetch Top Customers",
"type": "main",
"index": 0
}
]
]
},
"Fetch Weekly Sales Trend": {
"main": [
[
{
"node": "Label Weekly Data",
"type": "main",
"index": 0
}
]
]
},
"Populate Template Fields": {
"main": [
[
{
"node": "Export Document to PDF",
"type": "main",
"index": 0
}
]
]
},
"Consolidate BigQuery Data": {
"main": [
[
{
"node": "Transfer Report Data",
"type": "main",
"index": 0
}
]
]
},
"Duplicate Report Template": {
"main": [
[
{
"node": "Combine Narrative and Doc ID",
"type": "main",
"index": 1
}
]
]
},
"Fetch Top Sales Categories": {
"main": [
[
{
"node": "Label Category Data",
"type": "main",
"index": 0
}
]
]
},
"Combine Narrative and Doc ID": {
"main": [
[
{
"node": "Construct Replacement Requests",
"type": "main",
"index": 0
}
]
]
},
"When First of Month at 07:00": {
"main": [
[
{
"node": "Calculate Previous Month",
"type": "main",
"index": 0
}
]
]
},
"Integrate Narrative with Data": {
"main": [
[
{
"node": "Combine Narrative and Doc ID",
"type": "main",
"index": 0
}
]
]
},
"Construct Replacement Requests": {
"main": [
[
{
"node": "Populate Template Fields",
"type": "main",
"index": 0
}
]
]
},
"Create AI Narrative with Claude": {
"main": [
[
{
"node": "Integrate Narrative with Data",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
googleBigQueryOAuth2ApigoogleDocsOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs monthly to query KPI data from Google BigQuery, generates a narrative with Anthropic Claude, fills a Google Docs report template, exports it as a PDF, archives it to OneDrive, emails it via Microsoft Outlook, and posts a summary to Microsoft Teams. A schedule…
Source: https://n8n.io/workflows/16538/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow runs daily, queries Snowflake for items at or below their reorder level, and groups them by supplier. For each supplier, it uses Anthropic Claude to draft a reorder email, optionally app
This workflow empowers app developers and community management teams by automating the generation and posting of responses to user reviews on the Apple App Store. Designed to streamline the engagement
Categories Content Creation AI Automation Publishing Social Media
This workflow empowers app developers and community management teams by automating the generation and posting of responses to user reviews on the Google Play Store. Designed to streamline the engageme
Job seekers who want to automate their entire job search pipeline — from discovering new listings to generating tailored resumes and cover letters — without manually browsing LinkedIn every day.