This workflow corresponds to n8n.io template #12289 — we link there as the canonical source.
This workflow follows the Agent → Datatable 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": "dPckkM52faCVdhYi",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "Automate sales forecasts visual report and Q&A on WhatsApp using OpenAI Agent",
"tags": [],
"nodes": [
{
"id": "6a05f550-9612-4ecc-9439-92194df42c22",
"name": "Monthly Schedule",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
-928,
784
],
"parameters": {
"rule": {
"interval": [
{
"field": "months",
"triggerAtHour": 9
}
]
}
},
"typeVersion": 1.3
},
{
"id": "1d400ae4-4b7f-4ec0-b8fb-51675e94521c",
"name": "Structured Output Parser",
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"position": [
384,
976
],
"parameters": {
"schemaType": "manual",
"inputSchema": "{\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\n \"forecastHorizon\",\n \"forecastedSales\",\n \"forecastingTechnique\",\n \"confidenceLevel\",\n \"rmse\",\n \"mape\",\n \"mapeFormatted\",\n \"trend\",\n \"seasonality\",\n \"keyFactors\",\n \"reasoning\"\n ],\n \"properties\": {\n \"forecastHorizon\": {\n \"type\": \"string\",\n \"description\": \"Target period (e.g., '2020-04').\"\n },\n \"forecastedSales\": {\n \"type\": \"number\",\n \"description\": \"Projected sales figure.\"\n },\n \"forecastingTechnique\": {\n \"type\": \"string\",\n \"description\": \"Internal method name (keep technical for records).\"\n },\n \"confidenceLevel\": {\n \"type\": \"string\",\n \"enum\": [\"high\", \"medium\", \"low\"],\n \"description\": \"Business confidence level.\"\n },\n \"rmse\": {\n \"type\": \"number\",\n \"description\": \"Technical error metric (kept for database, do not use in text).\"\n },\n \"mape\": {\n \"type\": \"number\",\n \"description\": \"Technical error % (kept for database, do not use in text).\"\n },\n \"mapeFormatted\": {\n \"type\": \"string\",\n \"description\": \"Formatted error %.\"\n },\n \"trend\": {\n \"type\": \"string\",\n \"enum\": [\"increasing\", \"decreasing\", \"stable\"],\n \"description\": \"Direction of the business.\"\n },\n \"seasonality\": {\n \"type\": \"object\",\n \"properties\": {\n \"detected\": { \"type\": \"boolean\" },\n \"pattern\": { \"type\": \"string\" }\n }\n },\n \"keyFactors\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"3-6 bullet points using business language (no jargon).\"\n },\n \"reasoning\": {\n \"type\": \"string\",\n \"description\": \"Executive Summary paragraph for a business director.\"\n }\n }\n}"
},
"typeVersion": 1.3
},
{
"id": "f884c524-4a97-45fa-ad2f-bf21592b63b6",
"name": "Workflow Configuration",
"type": "n8n-nodes-base.set",
"position": [
-752,
784
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "id-1",
"name": "spreadsheetId",
"type": "string",
"value": "SalesSheet"
},
{
"id": "5532d7e6-3e56-42b3-8140-197eb4e71446",
"name": "sheetName",
"type": "string",
"value": "Sales"
}
]
},
"includeOtherFields": true
},
"typeVersion": 3.4
},
{
"id": "63b081c2-8a77-48e7-988d-dea5183162cf",
"name": "Forecast Engine",
"type": "n8n-nodes-base.code",
"position": [
-128,
784
],
"parameters": {
"jsCode": "/**\n * Forecast Engine (Deterministic) \u2014 Monthly Version\n *\n * INPUT (expects one item from Data Cleaning):\n * {\n * data: [\n * { Year: 2015, Month: 1, Sales: 138523, period: \"2015-01\" },\n * ...\n * ]\n * }\n *\n * OUTPUT (one item):\n * {\n * series: [{ period: \"2015-01\", Sales: ... }, ...],\n * candidates: [ ... ],\n * recommended: { ... forecastHorizon: \"2020-04\" ... }\n * }\n */\n\nfunction num(x) {\n const n = typeof x === \"number\" ? x : parseFloat(String(x).replace(/,/g, \"\").trim());\n return Number.isFinite(n) ? n : NaN;\n}\n\nfunction buildPeriod(y, m) {\n return `${y}-${String(m).padStart(2, '0')}`;\n}\n\nfunction parsePeriod(p) {\n // Regex for YYYY-MM\n const m = /^(\\d{4})-(\\d{2})$/.exec(String(p).trim());\n if (!m) return null;\n return { year: parseInt(m[1], 10), month: parseInt(m[2], 10) };\n}\n\nfunction nextPeriodLabel(lastPeriod) {\n const parsed = parsePeriod(lastPeriod);\n if (!parsed) return \"next_period\";\n let { year, month } = parsed;\n month += 1;\n if (month === 13) {\n month = 1;\n year += 1;\n }\n return buildPeriod(year, month);\n}\n\nfunction rmse(actual, pred) {\n const errs = [];\n for (let i = 0; i < actual.length; i++) {\n const a = actual[i], p = pred[i];\n if (!Number.isFinite(a) || !Number.isFinite(p)) continue;\n errs.push((a - p) ** 2);\n }\n if (errs.length === 0) return null;\n return Math.sqrt(errs.reduce((s, e) => s + e, 0) / errs.length);\n}\n\nfunction mapePct(actual, pred) {\n const errs = [];\n for (let i = 0; i < actual.length; i++) {\n const a = actual[i], p = pred[i];\n if (!Number.isFinite(a) || !Number.isFinite(p)) continue;\n if (a === 0) continue; // skip zeros\n errs.push(Math.abs((a - p) / a));\n }\n if (errs.length === 0) return null;\n return (errs.reduce((s, e) => s + e, 0) / errs.length) * 100;\n}\n\nfunction formatPct(x, decimals = 1) {\n if (x === null || x === undefined || !Number.isFinite(x)) return null;\n return `${x.toFixed(decimals)}%`;\n}\n\nfunction dot(a, b) {\n let s = 0;\n for (let i = 0; i < a.length; i++) s += a[i] * b[i];\n return s;\n}\n\n// Linear regression y = a + b*t\nfunction fitLinear(ts, ys) {\n const n = ts.length;\n if (n < 2) return null;\n\n const tMean = ts.reduce((s, v) => s + v, 0) / n;\n const yMean = ys.reduce((s, v) => s + v, 0) / n;\n\n let nume = 0;\n let deno = 0;\n for (let i = 0; i < n; i++) {\n const dt = ts[i] - tMean;\n nume += dt * (ys[i] - yMean);\n deno += dt * dt;\n }\n if (deno === 0) return null;\n\n const b = nume / deno;\n const a = yMean - b * tMean;\n return { a, b };\n}\n\n// Simple exponential smoothing (level-only)\nfunction sesForecastOneStep(history, alpha) {\n if (history.length === 0) return null;\n let level = history[0];\n for (let i = 1; i < history.length; i++) {\n level = alpha * history[i] + (1 - alpha) * level;\n }\n return level;\n}\n\n// ----- OLS helpers (small-matrix Gauss-Jordan) -----\nfunction solveLinearSystem(A, b) {\n const n = A.length;\n const M = A.map((row, i) => row.slice().concat([b[i]]));\n\n for (let col = 0; col < n; col++) {\n let pivotRow = col;\n for (let r = col + 1; r < n; r++) {\n if (Math.abs(M[r][col]) > Math.abs(M[pivotRow][col])) pivotRow = r;\n }\n if (Math.abs(M[pivotRow][col]) < 1e-12) return null;\n\n if (pivotRow !== col) {\n const tmp = M[col];\n M[col] = M[pivotRow];\n M[pivotRow] = tmp;\n }\n\n const pivot = M[col][col];\n for (let c = col; c <= n; c++) M[col][c] /= pivot;\n\n for (let r = 0; r < n; r++) {\n if (r === col) continue;\n const factor = M[r][col];\n for (let c = col; c <= n; c++) M[r][c] -= factor * M[col][c];\n }\n }\n\n return M.map((row) => row[n]);\n}\n\nfunction fitOLS(X, y, ridge = 1e-8) {\n const n = X.length;\n const k = X[0].length;\n\n const XtX = Array.from({ length: k }, () => Array(k).fill(0));\n const Xty = Array(k).fill(0);\n\n for (let i = 0; i < n; i++) {\n const xi = X[i];\n const yi = y[i];\n for (let a = 0; a < k; a++) {\n Xty[a] += xi[a] * yi;\n for (let b = 0; b < k; b++) {\n XtX[a][b] += xi[a] * xi[b];\n }\n }\n }\n\n for (let d = 0; d < k; d++) XtX[d][d] += ridge;\n\n return solveLinearSystem(XtX, Xty);\n}\n\n// Monthly Dummies: 11 dummies relative to January base\nfunction monthDummies(m) {\n // m is 1..12\n // Jan (1) -> all zeros\n // Feb (2) -> index 0 is 1\n // ...\n // Dec (12) -> index 10 is 1\n const dummies = Array(11).fill(0);\n if (m > 1 && m <= 12) {\n dummies[m - 2] = 1;\n }\n return dummies;\n}\n\n// -------------------- MAIN --------------------\nconst input = items[0]?.json ?? {};\nconst raw = Array.isArray(input.data) ? input.data : [];\n\n// Require at least 2 full years (24 months) for reliable seasonality\nif (raw.length < 24) {\n // Warn but proceed if > 13, else throw\n if (raw.length < 13) {\n throw new Error(\"Not enough data. Provide at least 13 months for monthly seasonality (ideally 24+).\");\n }\n}\n\n// Normalize + sort\nconst series = raw\n .map((r) => {\n const y = r.Year ?? r.year;\n const m = r.Month ?? r.month; // Read Month\n const s = num(r.Sales ?? r.sales);\n const period = r.period ? String(r.period) : buildPeriod(y, m);\n const parsed = parsePeriod(period);\n return {\n period,\n year: parsed?.year ?? y,\n month: parsed?.month ?? m,\n Sales: s,\n };\n })\n .filter((r) => Number.isFinite(r.year) && Number.isFinite(r.month) && Number.isFinite(r.Sales))\n .sort((a, b) => (a.year - b.year) || (a.month - b.month));\n\nconst y = series.map((r) => r.Sales);\nconst periods = series.map((r) => r.period);\n\nconst seasonLag = 12; // Monthly seasonality\nconst maWindow = 3; // Short-term moving average (3 months)\nconst alphas = [0.2, 0.4, 0.6, 0.8];\nconst minBacktestPoints = 6;\n\n// Rolling one-step backtest: train on [0..t-1], predict y[t]\nfunction backtest(methodName) {\n const actual = [];\n const pred = [];\n\n for (let t = 0; t < y.length; t++) {\n const hist = y.slice(0, t);\n let yhat = null;\n\n if (methodName === \"seasonal_naive\") {\n if (t - seasonLag >= 0) yhat = y[t - seasonLag];\n }\n\n if (methodName === \"seasonal_average\") {\n const m = series[t].month;\n const vals = series\n .slice(0, t)\n .filter((r) => r.month === m) // Filter by same month\n .map((r) => r.Sales);\n if (vals.length >= 1) yhat = vals.reduce((s, v) => s + v, 0) / vals.length;\n }\n\n if (methodName === \"moving_average\") {\n if (hist.length >= 1) {\n const window = hist.slice(-Math.min(maWindow, hist.length));\n yhat = window.reduce((s, v) => s + v, 0) / window.length;\n }\n }\n\n if (methodName === \"trend_linear\") {\n if (hist.length >= 3) {\n const ts = hist.map((_, i) => i);\n const fit = fitLinear(ts, hist);\n if (fit) {\n const nextT = hist.length;\n yhat = fit.a + fit.b * nextT;\n }\n }\n }\n\n if (methodName === \"exp_smoothing\") {\n if (hist.length >= 2) {\n let bestAlpha = null;\n let bestErr = Infinity;\n\n for (const a of alphas) {\n const aAct = [];\n const aPred = [];\n for (let i = 1; i < hist.length; i++) {\n const h = hist.slice(0, i);\n const f = sesForecastOneStep(h, a);\n if (Number.isFinite(f)) {\n aAct.push(hist[i]);\n aPred.push(f);\n }\n }\n const e = rmse(aAct, aPred);\n if (e !== null && e < bestErr) {\n bestErr = e;\n bestAlpha = a;\n }\n }\n\n if (bestAlpha !== null) {\n yhat = sesForecastOneStep(hist, bestAlpha);\n }\n }\n }\n\n if (methodName === \"seasonal_regression\") {\n // intercept + 11 month dummies = 12 params. Need > 13 points.\n if (hist.length >= 15) {\n const X = [];\n const Y = [];\n for (let i = 0; i < hist.length; i++) {\n const d = monthDummies(series[i].month);\n X.push([1, ...d]);\n Y.push(hist[i]);\n }\n const beta = fitOLS(X, Y);\n if (beta) {\n const dT = monthDummies(series[t].month);\n yhat = dot(beta, [1, ...dT]);\n }\n }\n }\n\n if (methodName === \"seasonal_regression_trend\") {\n // intercept + time + 11 dummies = 13 params. Need > 14 points.\n if (hist.length >= 16) {\n const X = [];\n const Y = [];\n for (let i = 0; i < hist.length; i++) {\n const d = monthDummies(series[i].month);\n X.push([1, i, ...d]);\n Y.push(hist[i]);\n }\n const beta = fitOLS(X, Y);\n if (beta) {\n const dT = monthDummies(series[t].month);\n yhat = dot(beta, [1, hist.length, ...dT]);\n }\n }\n }\n\n if (Number.isFinite(yhat)) {\n actual.push(y[t]);\n pred.push(yhat);\n }\n }\n\n if (actual.length < minBacktestPoints) return null;\n\n const r = rmse(actual, pred);\n const m = mapePct(actual, pred);\n\n return {\n method: methodName,\n backtest_points: actual.length,\n rmse: r,\n mape: m,\n mapeFormatted: formatPct(m, 1),\n };\n}\n\nconst methods = [\n \"seasonal_naive\",\n \"seasonal_average\",\n \"moving_average\",\n \"trend_linear\",\n \"exp_smoothing\",\n \"seasonal_regression\",\n \"seasonal_regression_trend\",\n];\n\nconst candidates = methods.map((m) => backtest(m)).filter(Boolean);\n\nif (candidates.length === 0) {\n throw new Error(\"No method had enough backtest points. Need more historical monthly data.\");\n}\n\n// Choose best: lowest MAPE, tie-breaker lowest RMSE\ncandidates.sort((a, b) => {\n const aM = a.mape, bM = b.mape;\n const aR = a.rmse, bR = b.rmse;\n\n if (Number.isFinite(aM) && Number.isFinite(bM)) {\n if (aM !== bM) return aM - bM;\n return (aR ?? Infinity) - (bR ?? Infinity);\n }\n if (Number.isFinite(aM) && !Number.isFinite(bM)) return -1;\n if (!Number.isFinite(aM) && Number.isFinite(bM)) return 1;\n return (aR ?? Infinity) - (bR ?? Infinity);\n});\n\nconst best = candidates[0];\n\n// Forecast next month using best method on full history\nfunction forecastNext(methodName) {\n const n = y.length;\n\n if (methodName === \"seasonal_naive\") {\n return n - seasonLag >= 0 ? y[n - seasonLag] : null;\n }\n\n if (methodName === \"seasonal_average\") {\n const lastM = series[n - 1].month;\n let nextM = lastM + 1;\n if (nextM === 13) nextM = 1;\n\n const vals = series.filter((r) => r.month === nextM).map((r) => r.Sales);\n return vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : null;\n }\n\n if (methodName === \"moving_average\") {\n const window = y.slice(-Math.min(maWindow, n));\n return window.reduce((s, v) => s + v, 0) / window.length;\n }\n\n if (methodName === \"trend_linear\") {\n const ts = y.map((_, i) => i);\n const fit = fitLinear(ts, y);\n if (!fit) return null;\n return fit.a + fit.b * n;\n }\n\n if (methodName === \"exp_smoothing\") {\n let bestAlpha = null;\n let bestErr = Infinity;\n\n for (const a of alphas) {\n const aAct = [];\n const aPred = [];\n for (let i = 1; i < y.length; i++) {\n const h = y.slice(0, i);\n const f = sesForecastOneStep(h, a);\n if (Number.isFinite(f)) {\n aAct.push(y[i]);\n aPred.push(f);\n }\n }\n const e = rmse(aAct, aPred);\n if (e !== null && e < bestErr) {\n bestErr = e;\n bestAlpha = a;\n }\n }\n\n return bestAlpha === null ? null : sesForecastOneStep(y, bestAlpha);\n }\n\n if (methodName === \"seasonal_regression\") {\n if (y.length < 15) return null;\n\n const X = [];\n const Y = [];\n for (let i = 0; i < y.length; i++) {\n const d = monthDummies(series[i].month);\n X.push([1, ...d]);\n Y.push(y[i]);\n }\n const beta = fitOLS(X, Y);\n if (!beta) return null;\n\n let nextM = series[series.length - 1].month + 1;\n if (nextM === 13) nextM = 1;\n const dN = monthDummies(nextM);\n return dot(beta, [1, ...dN]);\n }\n\n if (methodName === \"seasonal_regression_trend\") {\n if (y.length < 16) return null;\n\n const X = [];\n const Y = [];\n for (let i = 0; i < y.length; i++) {\n const d = monthDummies(series[i].month);\n X.push([1, i, ...d]);\n Y.push(y[i]);\n }\n const beta = fitOLS(X, Y);\n if (!beta) return null;\n\n let nextM = series[series.length - 1].month + 1;\n if (nextM === 13) nextM = 1;\n const dN = monthDummies(nextM);\n\n const nextIndex = y.length;\n return dot(beta, [1, nextIndex, ...dN]);\n }\n\n return null;\n}\n\nconst forecastedSales = forecastNext(best.method);\nif (!Number.isFinite(forecastedSales)) {\n throw new Error(`Best method (${best.method}) could not produce a next-month forecast.`);\n}\n\nconst forecastHorizon = nextPeriodLabel(periods[periods.length - 1]);\n\n// ---- Presentation rounding ----\nconst forecastedSalesRounded = Math.round(forecastedSales);\nconst rmseRounded = Number.isFinite(best.rmse) ? Math.round(best.rmse) : best.rmse;\nconst mapeRounded = Number.isFinite(best.mape) ? Number(best.mape.toFixed(1)) : best.mape;\nconst mapeFormattedRounded = Number.isFinite(mapeRounded) ? `${mapeRounded}%` : best.mapeFormatted;\n\nreturn [\n {\n json: {\n series: series.map((r) => ({ period: r.period, Sales: r.Sales })),\n candidates,\n recommended: {\n forecastHorizon,\n forecastedSales: forecastedSalesRounded,\n forecastingTechnique: best.method,\n rmse: rmseRounded,\n mape: mapeRounded,\n mapeFormatted: mapeFormattedRounded,\n backtestPoints: best.backtest_points,\n },\n selectionRule: \"lowest MAPE; tie-breaker RMSE\",\n },\n },\n];"
},
"typeVersion": 2
},
{
"id": "492ffd7e-204b-4efc-9504-8c881b15e808",
"name": "Data Cleaning",
"type": "n8n-nodes-base.code",
"position": [
-400,
784
],
"parameters": {
"jsCode": "// n8n Code node: Run Once for All Items\n\nfunction toNumber(v) {\n const n = typeof v === 'number' ? v : parseFloat(String(v).trim());\n return Number.isFinite(n) ? n : NaN;\n}\n\nconst rows = items\n .map(i => i.json)\n .filter(r =>\n Number.isFinite(toNumber(r.Year)) &&\n Number.isFinite(toNumber(r.Month)) && // Changed: Check for Month\n Number.isFinite(toNumber(r.MonthlySales)) // Changed: Check for MonthlySales\n )\n .map(r => ({\n ...r,\n Year: toNumber(r.Year),\n Month: toNumber(r.Month), // Changed: Store Month\n Sales: toNumber(r.MonthlySales), // Changed: Map input 'MonthlySales' to generic 'Sales' for downstream compatibility\n period: `${toNumber(r.Year)}-${String(toNumber(r.Month)).padStart(2, '0')}` // Changed: Format period as YYYY-MM (e.g., 2015-01)\n }))\n .sort((a, b) => (a.Year - b.Year) || (a.Month - b.Month)); // Changed: Sort by Month\n\n// Output ONE item that contains the FULL dataset as an array:\nreturn [\n {\n json: {\n data: rows,\n rowCount: rows.length\n }\n }\n];"
},
"typeVersion": 2
},
{
"id": "91e7c95e-b0c3-432b-9a1e-c1ce95ab28ef",
"name": "OpenAI Chat Model1",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"position": [
160,
976
],
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-5",
"cachedResultName": "gpt-5"
},
"options": {
"timeout": 180000
},
"builtInTools": {}
},
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.3
},
{
"id": "f513b665-26c8-4297-988e-368a8883a1e2",
"name": "OpenAI Chat Model2",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"position": [
464,
1632
],
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-4.1-nano",
"cachedResultName": "gpt-4.1-nano"
},
"options": {},
"builtInTools": {}
},
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.3
},
{
"id": "c484ca60-b162-4bc1-bba9-e65fa0299236",
"name": "Q&A AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"position": [
496,
1376
],
"parameters": {
"text": "={{ $('Normalize Whatsapp Input').item.json.chatInput }}",
"options": {
"systemMessage": "=You are a Strategic Business Advisor that answers questions about the latest monthly sales forecast. You speak in a clear, confident, executive tone.\n\nContext:\n- Latest forecast target: {{$json.forecastPeriod}}\n- Forecasted sales: {{$json.forecastSales}}\n- Executive Summary: {{$json.summaryReport}}\n\nHistorical sales data (Year-Month: Sales):\n{{ $json.historySalesParsed.map(i => `${i.year}-${String(i.month).padStart(2, '0')}: ${i.sales}`).join(', ') }}"
},
"promptType": "define"
},
"typeVersion": 3
},
{
"id": "d920386c-40b1-4f80-9ee4-ffb7a0aba30c",
"name": "Normalize Whatsapp Input",
"type": "n8n-nodes-base.set",
"position": [
-144,
1376
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "6814165f-1ff0-4654-8ec5-0fad100c3c95",
"name": "chatInput",
"type": "string",
"value": "={{\n (\n ($json.messages && $json.messages[0] && $json.messages[0].text && $json.messages[0].text.body) ||\n ($json.value && $json.value.messages && $json.value.messages[0] && $json.value.messages[0].text && $json.value.messages[0].text.body) ||\n ($json.entry && $json.entry[0] && $json.entry[0].changes && $json.entry[0].changes[0] && $json.entry[0].changes[0].value &&\n $json.entry[0].changes[0].value.messages && $json.entry[0].changes[0].value.messages[0] &&\n $json.entry[0].changes[0].value.messages[0].text && $json.entry[0].changes[0].value.messages[0].text.body) ||\n \"\"\n )\n}}\n"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "8878ba94-af04-4331-80a1-e86da10aa423",
"name": "Q&A Message",
"type": "n8n-nodes-base.whatsApp",
"position": [
800,
1376
],
"parameters": {
"textBody": "={{ $json.output }}",
"operation": "send",
"phoneNumberId": "978279622026190",
"additionalFields": {},
"recipientPhoneNumber": "1234"
},
"credentials": {
"whatsAppApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.1
},
{
"id": "ad26c654-567d-4f12-b2ac-ee08dfaef020",
"name": "Get Sales Data",
"type": "n8n-nodes-base.googleSheets",
"position": [
-560,
784
],
"parameters": {
"options": {},
"sheetName": {
"__rl": true,
"mode": "list",
"value": 402921456,
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1GqPjqmHcl-WZcadEoFy8idUnOsRiYTQce0Y0fC6uyYk/edit#gid=402921456",
"cachedResultName": "Sample_Monthly_Sales.csv"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "1GqPjqmHcl-WZcadEoFy8idUnOsRiYTQce0Y0fC6uyYk",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1GqPjqmHcl-WZcadEoFy8idUnOsRiYTQce0Y0fC6uyYk/edit?usp=drivesdk",
"cachedResultName": "Sample_Monthly_Sales"
},
"authentication": "serviceAccount"
},
"credentials": {
"googleApi": {
"name": "<your credential>"
}
},
"typeVersion": 4.7
},
{
"id": "15859013-73a8-40ce-9a19-23b2ad67bdc0",
"name": "QuickChart",
"type": "n8n-nodes-base.httpRequest",
"position": [
960,
784
],
"parameters": {
"url": "https://quickchart.io/chart",
"method": "POST",
"options": {
"response": {
"response": {
"responseFormat": "file",
"outputPropertyName": "chart"
}
}
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "chart",
"value": "={{ JSON.stringify($json.chartConfig) }}"
},
{
"name": "format",
"value": "png"
},
{
"name": "width",
"value": "1200"
},
{
"name": "height",
"value": "600"
},
{
"name": "backgroundColor",
"value": "white"
}
]
}
},
"typeVersion": 4.3,
"alwaysOutputData": false
},
{
"id": "2d969ee0-70f1-467d-bb25-55fb58ea6f76",
"name": "Upsert Latest Forecast",
"type": "n8n-nodes-base.dataTable",
"position": [
1248,
672
],
"parameters": {
"columns": {
"value": {
"key": "latest",
"historySales": "={{ JSON.stringify($items(\"Get Sales Data\").map(i => ({\n year: i.json.Year,\n month: i.json.Month,\n sales: i.json.MonthlySales\n}))) }}",
"forecastSales": "={{ $json.output.forecastedSales }}",
"summaryReport": "={{ $json.summaryReport }}",
"forecastPeriod": "={{ $json.output.forecastHorizon }}"
},
"schema": [
{
"id": "forecastPeriod",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "forecastPeriod",
"defaultMatch": false
},
{
"id": "forecastSales",
"type": "number",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "forecastSales",
"defaultMatch": false
},
{
"id": "summaryReport",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "summaryReport",
"defaultMatch": false
},
{
"id": "key",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "key",
"defaultMatch": false
},
{
"id": "historySales",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "historySales",
"defaultMatch": false
}
],
"mappingMode": "defineBelow",
"matchingColumns": [],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"filters": {
"conditions": [
{
"keyName": "key",
"keyValue": "latest"
}
]
},
"options": {},
"operation": "upsert",
"dataTableId": {
"__rl": true,
"mode": "list",
"value": "mTbTawxCGmZ8kp0A",
"cachedResultUrl": "/projects/HgU76KeJzQXHrABf/datatables/mTbTawxCGmZ8kp0A",
"cachedResultName": "Latest_Forecast"
}
},
"typeVersion": 1
},
{
"id": "ccfe9476-d0d9-44b7-bea9-fd1912ae9ea7",
"name": "Get Latest Forecast",
"type": "n8n-nodes-base.dataTable",
"position": [
80,
1376
],
"parameters": {
"filters": {
"conditions": [
{
"keyName": "key",
"keyValue": "latest"
}
]
},
"operation": "get",
"dataTableId": {
"__rl": true,
"mode": "list",
"value": "mTbTawxCGmZ8kp0A",
"cachedResultUrl": "/projects/HgU76KeJzQXHrABf/datatables/mTbTawxCGmZ8kp0A",
"cachedResultName": "Latest_Forecast"
}
},
"typeVersion": 1
},
{
"id": "a4ef98c6-2055-49a4-ae4e-0d99fbe5040b",
"name": "Parsing Input",
"type": "n8n-nodes-base.code",
"position": [
304,
1376
],
"parameters": {
"jsCode": "const row = $json;\n\nlet history = [];\ntry {\n history = JSON.parse(row.historySales || \"[]\");\n} catch (e) {\n history = [];\n}\n\n// SAFETY STEP: Normalize keys to ensure lowercase 'year', 'month', 'sales'\n// This prevents errors if the database accidentally has \"Year\" or \"MonthlySales\"\nconst historyNormalized = history.map(item => ({\n year: Number(item.year || item.Year),\n month: Number(item.month || item.Month), \n sales: Number(item.sales || item.Sales || item.MonthlySales)\n}));\n\nreturn [\n {\n json: {\n ...row,\n historySalesParsed: historyNormalized,\n },\n },\n];"
},
"typeVersion": 2
},
{
"id": "96ad6de4-4551-4f14-8f00-cd88e1033bb4",
"name": "WhatsApp Trigger",
"type": "n8n-nodes-base.whatsAppTrigger",
"position": [
-528,
1392
],
"parameters": {
"options": {},
"updates": [
"messages"
]
},
"credentials": {
"whatsAppTriggerApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "86c29b81-b78d-4fb8-b2a7-ec5905dff0bb",
"name": "Sticky Note15",
"type": "n8n-nodes-base.stickyNote",
"position": [
-992,
-112
],
"parameters": {
"width": 1376,
"height": 608,
"content": "## How It Works \n***Top Branch Workflow***\n\n**1. The Data Scientist:** \n - **Ingest:** Pulls historical sales data from Google Sheets.\n - **Math Engine:** Runs 7 statistical algorithms (e.g., Seasonal Naive, Linear Trend, Regression). It backtests them against your history and scientifically selects the winner with the lowest error rate.\n\n\n**2. The Data Analyst:** \n - **Interpret:** The AI Agent takes the mathematical output and translates it into business insights, assigning confidence scores based on error margins.\n - **Report:** Generates a visual trend chart (PNG) and sends a complete briefing to your phone.\n\n\n***Bottom Branch Workflow***\n\n**3. The Consultant:** AI Agent 2 handles the follow-up questions. It pulls the latest analysis context and checks historical rate data to give an informed answer.\n - **Recall:** When you ask a question via WhatsApp, the bot retrieves the saved forecast state.\n - **Answer:** It acts as an on-demand analyst, comparing current forecasts against historical actuals to give you instant answers.\n\n\n\n## Setup Steps\n1) Google Sheet: Prepare columns: Year, Month, Sales. Map the Sheet ID in the \"Workflow Configuration\" node.\n2) Forecast Engine: No config needed. It automatically detects seasonality vs. linear trends.\n3) Database: Create a table latest_forecast to store the JSON output.\n4) Credentials: Connect Google Sheets, OpenAI, and WhatsApp"
},
"typeVersion": 1
},
{
"id": "6d5564ae-4e40-4a02-8b06-278cb7ddea86",
"name": "Sticky Note17",
"type": "n8n-nodes-base.stickyNote",
"position": [
432,
336
],
"parameters": {
"color": 3,
"width": 368,
"height": 96,
"content": "## Prerequisites\nData: At least 12 periods of historical sales data."
},
"typeVersion": 1
},
{
"id": "81dd5053-bbca-4b21-8757-844d770d0af6",
"name": "Sticky Note18",
"type": "n8n-nodes-base.stickyNote",
"position": [
432,
-112
],
"parameters": {
"color": 5,
"width": 992,
"height": 416,
"content": "## Use Cases & Benefits\n**Business Owners:** \nGain enterprise-grade forecasting on autopilot. Always have a sophisticated financial outlook running in the background 24/7.\n\n**Sales Leaders:** \nGet immediate visibility into future revenue trends. Bypass the wait for end-of-month manual reports and get a strategic \"pulse check\" delivered instantly to your phone.\n\n\n\ud83e\udd16***Virtual Data Team:*** Instantly add the capabilities of a Data Scientist and Data Analyst to your business or division. It works alongside your existing team to handle the heavy lifting, or stands in as your dedicated automated department.\n\n\ud83e\udde0***Precision & Trust:*** Combines the best of both worlds: rigorous, deterministic code for the math (no hallucinations) and advanced AI for the strategic explanation. You get numbers you can trust with context you can use.\n\n\u26a1***Decision-Ready Insights:*** Stop digging through dashboards. High-level intelligence is pushed directly to you on WhatsApp, allowing you to make faster, data-driven decisions from anywhere.\n\n"
},
"typeVersion": 1
},
{
"id": "594c9672-202b-4b4b-921e-20251df60552",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-992,
528
],
"parameters": {
"color": 7,
"width": 736,
"height": 656,
"content": "## 1. Data Pipeline\n**ETL & Normalization** Fetches raw Google Sheets data and normalizes formats to ensure the statistical engine receives clean inputs."
},
"typeVersion": 1
},
{
"id": "6e719e61-b625-477f-ac99-464bf192d377",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-224,
528
],
"parameters": {
"color": 7,
"width": 288,
"height": 656,
"content": "## 2. Forecasting\n**Statistical Tournament** Runs 7 distinct models (Seasonal Regression, Exp Smoothing, Moving Avg, etc.). It performs a rolling backtest to calculate RMSE & MAPE, strictly selecting the mathematically superior model."
},
"typeVersion": 1
},
{
"id": "eda9e598-4b70-4793-a08b-092a933bb820",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
112,
528
],
"parameters": {
"color": 7,
"width": 1008,
"height": 656,
"content": "## 3. AI Intelligence & Visualization\n**Synthesis & Rendering** The AI interprets the math into a business narrative, while QuickChart renders the data points into a visual trend graph PNG."
},
"typeVersion": 1
},
{
"id": "84485933-8556-442e-ad7b-209bf8350ed0",
"name": "Analyst AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"position": [
208,
784
],
"parameters": {
"text": "={{ $json }}",
"options": {
"systemMessage": "You are a Strategic Business Advisor presenting a sales forecast to a Sales Director. You will receive technical forecasting data, and your job is to translate it into a clear, actionable Executive Summary.\n\nInput fields:\n- series: historical data [{ period: \"YYYY-MM\", Sales: number }, ...]\n- candidates: technical model evaluation metrics\n- recommended: the best model's output { forecastHorizon, forecastedSales, rmse, mape, mapeFormatted, ... }\n\nCritical rules:\n1) DO NOT change the forecastedSales number. Use it exactly as provided.\n2) STRICTLY AVOID technical jargon in the text fields (\"keyFactors\" and \"reasoning\").\n - \ud83d\udeab Do not use: \"RMSE\", \"MAPE\", \"OLS\", \"Regression\", \"Backtesting\", \"Dummy variables\".\n - \u2705 Do use: \"Historical accuracy\", \"Margin of error\", \"Recurring seasonal patterns\", \"Growth trend\".\n3) Translate metrics into business terms:\n - Instead of \"MAPE of 4.8%\", say \"expected accuracy of ~95%\" or \"margin of error of \u00b15%\".\n4) Output ONLY a single JSON object matching the schema.\n\nYour tasks:\nA) Populate technical fields (forecastHorizon, forecastedSales, etc.) directly from 'recommended'.\nB) Determine 'confidenceLevel' (High/Medium/Low) based on MAPE (High if <5%, Med if <10%, Low if >10%).\nC) Determine 'trend' (Increasing/Decreasing/Stable) by comparing the average of the last 12 months vs the previous 12 months.\nD) 'seasonality': detected = true if method contains \"seasonal\"; pattern = \"monthly\".\n\nE) 'keyFactors': Provide 3\u20136 business-oriented bullet points explaining the drivers.\n - Focus on visible behaviors in the data (e.g., \"Sales consistently peak in May,\" \"We are seeing steady Year-Over-Year growth\").\n - Explain *why* the forecast is reliable (e.g., \"This projection accounts for the strong seasonal cycles observed in historical performance\").\n\nF) 'reasoning': Write this as a cohesive executive summary.\n - Start with the headline numbers (Forecast Target and Date).\n - Briefly explain the primary driver (e.g., \"Driven by strong seasonal momentum...\").\n - State the confidence level in plain English (e.g., \"We have high confidence in this number due to a historical accuracy rate of X%\").\n\nReturn the final JSON object only."
},
"promptType": "define",
"hasOutputParser": true
},
"typeVersion": 3
},
{
"id": "dec6aeb6-ae13-4abf-a4b1-4884ebf755a6",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1152,
528
],
"parameters": {
"color": 7,
"width": 288,
"height": 656,
"content": "## 4. Delivery & Storage\n**Notification System** Saves the forecast context to the database for future reference and pushes the final Report + Chart to WhatsApp."
},
"typeVersion": 1
},
{
"id": "9425a977-d1cd-42a1-bd46-aa2d06a0d262",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
-592,
1248
],
"parameters": {
"color": 7,
"width": 1664,
"height": 560,
"content": "## 5. Follow-Up Q&A with Sales Forecast AI Agent \n**Data Consultant** Retrieves the stored forecast data to answer user questions, acting as an on-demand analyst with full knowledge of the report."
},
"typeVersion": 1
},
{
"id": "6d207b4b-4960-4a4e-ac45-4c05b75f37bf",
"name": "Format AI Output",
"type": "n8n-nodes-base.set",
"position": [
528,
784
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "e0d9a155-f6b2-44cf-bc61-e754ba3144bf",
"name": "summaryReport",
"type": "string",
"value": "={{ $json.output.reasoning }}"
}
]
},
"includeOtherFields": true
},
"typeVersion": 3.4
},
{
"id": "9497eaf3-d9ef-42ab-873b-4305daadf8ee",
"name": "Filter Text Messages",
"type": "n8n-nodes-base.if",
"position": [
-352,
1392
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "be96ecef-2db9-48a8-b178-3d48112eae7b",
"operator": {
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.messages[0].from}}",
"rightValue": "16727551224"
}
]
}
},
"typeVersion": 2.3
},
{
"id": "824dbfe8-bfb1-4083-9268-9817dcc73397",
"name": "Combine Data for Chart",
"type": "n8n-nodes-base.merge",
"position": [
640,
976
],
"parameters": {
"mode": "combine",
"options": {},
"combineBy": "combineByPosition"
},
"typeVersion": 3.2
},
{
"id": "b3597f68-b5d2-406b-b87b-bc6b2e3f3f7a",
"name": "Send WhatsApp Report",
"type": "n8n-nodes-base.whatsApp",
"position": [
1248,
928
],
"parameters": {
"mediaPath": "useMedian8n",
"operation": "send",
"messageType": "image",
"phoneNumberId": "978279622026190",
"additionalFields": {
"mediaCaption": "={{ $json.summaryReport }}"
},
"mediaPropertyName": "chart",
"recipientPhoneNumber": "1234"
},
"credentials": {
"whatsAppApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.1
},
{
"id": "5fafef97-73b1-400c-a397-fef285ef9046",
"name": "Prepare Chart Config",
"type": "n8n-nodes-base.code",
"position": [
784,
784
],
"parameters": {
"jsCode": "const data = items[0].json.data;\nconst forecast = items[0].json.output;\n\nif (!Array.isArray(data) || data.length === 0) {\n throw new Error(\"json.data is missing or empty.\");\n}\n\nconst labels = data.map(d => d.period);\nconst actual = data.map(d => Number(d.Sales));\n\n// --- CHANGE START ---\n// Get the very last historical sales value\nconst lastActualValue = actual[actual.length - 1];\nconst forecastValue = Number(forecast.forecastedSales);\n\n// Create forecast series:\n// 1. Fill with 'null' for all periods except the last two.\n// 2. Place the last historical value at the second-to-last position.\n// 3. Place the forecast value at the very last position.\nconst forecastSeries = Array(actual.length - 1).fill(null);\nforecastSeries.push(lastActualValue);\nforecastSeries.push(forecastValue);\n// --- CHANGE END ---\n\nconst nextPeriod = forecast?.forecastHorizon || \"Next\";\nconst labelsWithForecast = labels.concat([nextPeriod]);\n\n// The actual series still needs to end with 'null' so the blue line stops.\nconst actualWithNull = actual.concat([null]);\n\nconst chartConfig = {\n type: \"line\",\n data: {\n labels: labelsWithForecast,\n datasets: [\n {\n label: \"Historical Sales\",\n data: actualWithNull,\n fill: false,\n tension: 0.2,\n borderColor: \"rgb(54, 162, 235)\",\n backgroundColor: \"rgba(54, 162, 235, 0.5)\",\n pointRadius: 3 // Standard size for historical points\n },\n {\n label: \"Forecast (Next Month)\",\n data: forecastSeries,\n fill: false,\n tension: 0.2,\n borderDash: [6, 6], // The connecting line will be dashed\n borderColor: \"rgb(255, 99, 132)\", // Pink line color\n backgroundColor: \"rgba(255, 99, 132, 0.5)\",\n pointRadius: 5, // Make the forecast dot larger\n pointHoverRadius: 7\n }\n ]\n },\n options: {\n responsive: true,\n plugins: {\n title: { display: true, text: \"Monthly Sales (Historical + Forecast)\" },\n legend: { display: true },\n // Optional: Add a tooltip callback to label the forecast clearly\n tooltip: {\n callbacks: {\n label: function(context) {\n let label = context.dataset.label || '';\n if (label) {\n label += ': ';\n }\n if (context.parsed.y !== null) {\n label += new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(context.parsed.y);\n }\n return label;\n }\n }\n }\n },\n scales: {\n y: {\n title: { display: true, text: \"Sales\" },\n beginAtZero: false\n },\n x: {\n title: { display: true, text: \"Month\" },\n ticks: {\n autoSkip: true,\n maxTicksLimit: 20\n }\n }\n }\n }\n};\n\nreturn [{ json: { ...items[0].json, chartConfig } }];"
},
"typeVersion": 2
},
{
"id": "29b1ae06-e5f4-464f-9ff7-f08c52acfae2",
"name": "Simple Memory",
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"position": [
624,
1632
],
"parameters": {
"sessionKey": "={{ $('WhatsApp Trigger').item.json.messages[0].from }}",
"sessionIdType": "customKey"
},
"typeVersion": 1.3
}
],
"active": false,
"settings": {
"availableInMCP": false,
"executionOrder": "v1"
},
"versionId": "c282cf4e-c15f-4056-be0f-a3e56b97be7c",
"connections": {
"QuickChart": {
"main": [
[
{
"node": "Upsert Latest Forecast",
"type": "main",
"index": 0
},
{
"node": "Send WhatsApp Report",
"type": "main",
"index": 0
}
]
]
},
"Q&A AI Agent": {
"main": [
[
{
"node": "Q&A Message",
"type": "main",
"index": 0
}
]
]
},
"Data Cleaning": {
"main": [
[
{
"node": "Combine Data for Chart",
"type": "main",
"index": 1
},
{
"node": "Forecast Engine",
"type": "main",
"index": 0
}
]
]
},
"Parsing Input": {
"main": [
[
{
"node": "Q&A AI Agent",
"type": "main",
"index": 0
}
]
]
},
"Simple Memory": {
"ai_memory": [
[
{
"node": "Q&A AI Agent",
"type": "ai_memory",
"index": 0
}
]
]
},
"Get Sales Data": {
"main": [
[
{
"node": "Data Cleaning",
"type": "main",
"index": 0
}
]
]
},
"Forecast Engine": {
"main": [
[
{
"node": "Analyst AI Agent",
"type": "main",
"index": 0
}
]
]
},
"Analyst AI Agent": {
"main": [
[
{
"node": "Format AI Output",
"type": "main",
"index": 0
}
]
]
},
"Format AI Output": {
"main": [
[
{
"node": "Combine Data for Chart",
"type": "main",
"index": 0
}
]
]
},
"Monthly Schedule": {
"main": [
[
{
"node": "Workflow Configuration",
"type": "main",
"index": 0
}
]
]
},
"WhatsApp Trigger": {
"main": [
[
{
"node": "Filter Text Messages",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model1": {
"ai_languageModel": [
[
{
"node": "Analyst AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"OpenAI Chat Model2": {
"ai_languageModel": [
[
{
"node": "Q&A AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Get Latest Forecast": {
"main": [
[
{
"node": "Parsing Input",
"type": "main",
"index": 0
}
]
]
},
"Filter Text Messages": {
"main": [
[
{
"node": "Normalize Whatsapp Input",
"type": "main",
"index": 0
}
]
]
},
"Prepare Chart Config": {
"main": [
[
{
"node": "QuickChart",
"type": "main",
"index": 0
}
]
]
},
"Combine Data for Chart": {
"main": [
[
{
"node": "Prepare Chart Config",
"type": "main",
"index": 0
}
]
]
},
"Upsert Latest Forecast": {
"main": [
[]
]
},
"Workflow Configuration": {
"main": [
[
{
"node": "Get Sales Data",
"type": "main",
"index": 0
}
]
]
},
"Normalize Whatsapp Input": {
"main": [
[
{
"node": "Get Latest Forecast",
"type": "main",
"index": 0
}
]
]
},
"Structured Output Parser": {
"ai_outputParser": [
[
{
"node": "Analyst AI Agent",
"type": "ai_outputParser",
"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.
googleApiopenAiApiwhatsAppApiwhatsAppTriggerApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Top Branch Workflow The Data Scientist: Ingest: Pulls historical sales data from Google Sheets. Math Engine: Runs 7 statistical algorithms (e.g., Seasonal Naive, Linear Trend, Regression). It backtests them against your history and scientifically selects the winner with the…
Source: https://n8n.io/workflows/12289/ — 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 was born out of a very real problem.
This n8n-powered workflow automates the entire lifecycle of real estate lead intake, qualification, routing, assignment, and reporting across multiple channels. It brings WhatsApp inquiries and websit
kisisel asistan. Uses toolWorkflow, toolHttpRequest, toolCalculator, toolThink. Scheduled trigger; 43 nodes.
This workflow automates end-to-end sustainability lifecycle management for corporate sustainability teams, ESG governance officers, and circular economy programme leads. It addresses the challenge of
This workflow automates end-to-end ESG (Environmental, Social, and Governance) sustainability reporting for enterprise sustainability teams, compliance officers, and green governance leads. It solves