{
  "name": "Lumeor | Weekly operations report",
  "nodes": [
    {
      "parameters": {
        "content": "## Weekly operations report (Postgres, Claude, Slack, Gmail)\n\n**What it does**\nEvery Monday at 7am this workflow reads the last six weeks of KPIs from a Postgres view, compares last week with the prior week, the four-week average and target, and flags unusual moves. If any KPI is missing, delivery stops. Claude writes a short narrative using only the supplied figures, code checks that every number in the text exists in the KPI table, and a manager approves in Slack before the report goes to leadership by Slack and email.\n\n**Setup**\n1. Build a view named `kpi_weekly` with columns: kpi, label, unit, week_start (Monday, date), value, target, higher_is_better. Load it from your sources (QuickBooks, store, help desk, timesheets) with separate extract workflows.\n2. Credentials: Postgres, Anthropic (API key), Slack, Gmail OAuth2.\n3. Replace placeholders: REPLACE_WITH_SLACK_REVIEW_CHANNEL_ID, REPLACE_WITH_SLACK_LEADERSHIP_CHANNEL_ID, REPLACE_WITH_SLACK_EXCEPTIONS_CHANNEL_ID, REPLACE_WITH_LEADERSHIP_EMAIL, REPLACE_WITH_DASHBOARD_URL (in *Verify numbers*).\n4. Set the time zone (TZ), flag band and optional REQUIRED_KPIS list in *Compare and flag*, and the workflow time zone in Workflow settings.\n\n**Notes**\n- Model: claude-opus-5-5 with structured outputs. claude-haiku-5-5 is a cheaper option for high-volume, simple classification.\n- The number check is strict on purpose. If it blocks a report, read the reason in the exceptions channel before loosening it.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/automate-weekly-ops-reporting/",
        "height": 800,
        "width": 560,
        "color": 5
      },
      "id": "4da641e6-a67a-42a3-9a0e-0302031678ff",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -760,
        40
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 * * 1"
            }
          ]
        }
      },
      "id": "de01b451-2973-42de-bf4c-fd57dcc51500",
      "name": "Every Monday 7am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT kpi, label, unit, week_start, value, target, higher_is_better\nFROM kpi_weekly\nWHERE week_start >= (date_trunc('week', now()) - interval '6 weeks')\nORDER BY kpi, week_start;",
        "options": {}
      },
      "id": "8add3f6e-94e7-4cf0-962e-a956f0844025",
      "name": "Load weekly KPIs",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        220,
        300
      ],
      "credentials": {
        "postgres": {
          "id": "",
          "name": "Postgres account"
        }
      },
      "alwaysOutputData": true,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Compares each KPI for the report week with the prior week, the trailing\n// four-week average and target, flags unusual moves, and checks completeness.\nconst TZ = 'America/New_York';\nconst BAND = 0.15;          // flag moves larger than 15% versus the trailing 4-week average\nconst REQUIRED_KPIS = [];   // optional: list KPI keys that must always be present, e.g. ['on_time_ship_rate']\n\nconst reportWeek = $now.setZone(TZ).startOf('week').minus({ weeks: 1 }).toISODate(); // last Monday-to-Sunday week\nconst toDate = (v) => (v instanceof Date ? v.toISOString() : String(v)).slice(0, 10);\nconst num = (v) => (v === null || v === undefined || v === '' ? null : Number(v));\nconst round = (v, d = 2) => (v === null || Number.isNaN(v) ? null : Math.round(v * 10 ** d) / 10 ** d);\n\nconst byKpi = {};\nfor (const { json: r } of $input.all()) {\n  if (!r.kpi) continue;\n  (byKpi[r.kpi] ??= { label: r.label || r.kpi, unit: r.unit || '', higher_is_better: r.higher_is_better !== false, weeks: {} });\n  byKpi[r.kpi].weeks[toDate(r.week_start)] = { value: num(r.value), target: num(r.target) };\n}\n\nconst kpis = [];\nconst missing = [];\nfor (const [key, k] of Object.entries(byKpi)) {\n  const weeks = Object.keys(k.weeks).sort();\n  const current = k.weeks[reportWeek];\n  const history = weeks.filter((w) => w < reportWeek).slice(-4).map((w) => k.weeks[w].value).filter((v) => v !== null);\n  if (!current || current.value === null) { missing.push(k.label); continue; }\n  const prior = history.length ? history[history.length - 1] : null;\n  const avg4 = history.length ? history.reduce((a, b) => a + b, 0) / history.length : null;\n  const vsAvg = avg4 ? (current.value - avg4) / Math.abs(avg4) : null;\n  const vsPrior = prior ? (current.value - prior) / Math.abs(prior) : null;\n  const reasons = [];\n  if (vsAvg !== null && Math.abs(vsAvg) > BAND) reasons.push(`${round(vsAvg * 100, 1)}% vs 4-week average`);\n  if (current.target !== null) {\n    const miss = k.higher_is_better ? current.value < current.target : current.value > current.target;\n    if (miss) reasons.push('off target');\n  }\n  kpis.push({\n    key, label: k.label, unit: k.unit,\n    value: round(current.value), prior: round(prior), avg4: round(avg4), target: round(current.target),\n    change_vs_prior_pct: vsPrior === null ? null : round(vsPrior * 100, 1),\n    change_vs_avg_pct: vsAvg === null ? null : round(vsAvg * 100, 1),\n    flagged: reasons.length > 0, flag_reason: reasons.join('; '),\n  });\n}\nfor (const key of REQUIRED_KPIS) if (!byKpi[key]) missing.push(key);\n\nconst show = (v, unit) => (v === null ? 'n/a' : `${v}${unit === '%' ? '%' : unit ? ' ' + unit : ''}`);\nconst table_text = kpis.map((k) =>\n  `${k.flagged ? '[FLAG] ' : ''}${k.label}: ${show(k.value, k.unit)} | prior ${show(k.prior, k.unit)} | 4-wk avg ${show(k.avg4, k.unit)} | target ${show(k.target, k.unit)} | vs prior ${k.change_vs_prior_pct ?? 'n/a'}% | vs avg ${k.change_vs_avg_pct ?? 'n/a'}%${k.flag_reason ? ' | ' + k.flag_reason : ''}`\n).join('\\n');\n\nconst complete = kpis.length > 0 && missing.length === 0;\nreturn [{ json: {\n  report_week: reportWeek, kpis, missing, complete, table_text,\n  reason: complete ? '' : (kpis.length ? `Missing data for: ${missing.join(', ')}` : `No KPI rows found for week ${reportWeek}`),\n  context_line: `Weekly ops report, week of ${reportWeek}`,\n} }];\n"
      },
      "id": "a13acf5f-cde5-4e4b-9f3c-3186cb1ea25f",
      "name": "Compare and flag",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "e38c7827-938e-4bf2-944a-0bbc80ad880a",
              "leftValue": "={{ $json.complete }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "f9d376a2-e9e5-48c6-bb4b-dec7d40626ad",
      "name": "Data complete?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        660,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"claude-opus-5-5\",\n  \"max_tokens\": 3000,\n  \"system\": \"You write the narrative section of a weekly operations report for company leadership. Use only the figures in the KPI table you are given, copied exactly as shown. Do not write dates or any other numbers. Mention every metric marked [FLAG] by its label. Do not state a cause for any change unless a manager note supports it; if the cause is unknown, say so. Plain, direct sentences. 150 to 250 words.\",\n  \"messages\": [\n    {\n      \"role\": \"user\",\n      \"content\": {{ JSON.stringify('Report week starting ' + $json.report_week + '. KPI table:\\n' + $json.table_text + '\\n\\nManager notes: none supplied.') }}\n    }\n  ],\n  \"output_config\": {\n    \"effort\": \"medium\",\n    \"format\": {\n      \"type\": \"json_schema\",\n      \"schema\": {\n        \"type\": \"object\",\n        \"additionalProperties\": false,\n        \"required\": [\n          \"headline\",\n          \"narrative\",\n          \"flagged_metrics_covered\",\n          \"questions_for_leadership\"\n        ],\n        \"properties\": {\n          \"headline\": {\n            \"type\": \"string\",\n            \"description\": \"One sentence on the most important change last week.\"\n          },\n          \"narrative\": {\n            \"type\": \"string\",\n            \"description\": \"150 to 250 words. Uses only figures from the KPI table.\"\n          },\n          \"flagged_metrics_covered\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            },\n            \"description\": \"Labels of the flagged metrics discussed.\"\n          },\n          \"questions_for_leadership\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            },\n            \"description\": \"Up to three questions worth asking, based on the data.\"\n          }\n        }\n      }\n    }\n  }\n}",
        "options": {
          "timeout": 180000
        }
      },
      "id": "ab7d890a-0c74-48de-ae4d-2b46e8b019b2",
      "name": "Write narrative with Claude",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        280
      ],
      "credentials": {
        "anthropicApi": {
          "id": "",
          "name": "Anthropic account"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Parses the narrative, then blocks delivery unless every number in it matches\n// a figure in the KPI table and every flagged metric is mentioned.\nconst ctx = $('Compare and flag').first().json;\nconst res = $input.first().json;\nconst fail = (reason) => [{ json: { ok: false, reason, context_line: ctx.context_line } }];\n\nif (res.stop_reason === 'refusal') return fail('Claude declined to write the narrative (stop_reason refusal)');\nif (res.stop_reason === 'max_tokens') return fail('Narrative was cut off (stop_reason max_tokens)');\n// Opus 5.5 always thinks, so a thinking block can come first: find the text block.\nconst block = (res.content || []).find((b) => b.type === 'text');\nif (!block) return fail('Claude response has no text block');\nlet data;\ntry { data = JSON.parse(block.text); } catch (e) { return fail('Could not parse Claude output: ' + e.message); }\n\n// Every figure the narrative may use, in common rounded forms.\nconst allowed = new Set();\nconst addNum = (v) => {\n  if (v === null || v === undefined || Number.isNaN(Number(v))) return;\n  for (const n of [Number(v), Math.abs(Number(v))]) for (const d of [0, 1, 2]) allowed.add(Number(n.toFixed(d)));\n};\nfor (const k of ctx.kpis) {\n  [k.value, k.prior, k.avg4, k.target, k.change_vs_prior_pct, k.change_vs_avg_pct].forEach(addNum);\n  if (k.prior !== null) addNum(k.value - k.prior);\n  if (k.avg4 !== null) addNum(k.value - k.avg4);\n  if (k.target !== null) addNum(k.value - k.target);\n}\nfor (let i = 0; i <= 10; i++) allowed.add(i); // small counts such as \"3 metrics\"\n\nconst text = `${data.headline}\\n${data.narrative}`;\nconst found = (text.match(/-?\\d[\\d,]*(\\.\\d+)?/g) || []).map((s) => Number(s.replace(/,/g, '')));\nconst unmatched = found.filter((n) => ![0, 1, 2].some((d) => allowed.has(Number(n.toFixed(d)))));\nif (unmatched.length) return fail(`Narrative contains numbers not in the KPI table: ${[...new Set(unmatched)].join(', ')}`);\n\nconst lower = text.toLowerCase();\nconst uncovered = ctx.kpis.filter((k) => k.flagged && !lower.includes(k.label.toLowerCase())).map((k) => k.label);\nif (uncovered.length) return fail(`Narrative does not mention flagged metrics: ${uncovered.join(', ')}`);\n\nconst report_text = [\n  `*Weekly operations report, week of ${ctx.report_week}*`,\n  `*${data.headline}*`,\n  data.narrative,\n  data.questions_for_leadership.length ? '\\n*Questions to discuss*\\n' + data.questions_for_leadership.map((q) => `- ${q}`).join('\\n') : '',\n  '\\n*KPI table*',\n  ctx.table_text,\n  '\\nDashboard: REPLACE_WITH_DASHBOARD_URL',\n].join('\\n');\n\nreturn [{ json: { ok: true, report_week: ctx.report_week, headline: data.headline, report_text } }];\n"
      },
      "id": "f94234dc-f845-4b8a-9895-cde63747d59f",
      "name": "Verify numbers",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        280
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "f1ba034d-f622-4432-8967-86249b2183f2",
              "leftValue": "={{ $json.ok }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "4a9738f3-8b9b-42ba-b13a-a5b160a7fab3",
      "name": "Report verified?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1320,
        280
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "sendAndWait",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_SLACK_REVIEW_CHANNEL_ID",
          "mode": "id"
        },
        "message": "={{ $json.report_text }}",
        "responseType": "approval",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "Send to leadership",
            "disapproveLabel": "Hold"
          }
        },
        "options": {
          "limitWaitTime": {
            "values": {
              "limitType": "afterTimeInterval",
              "resumeAmount": 8,
              "resumeUnit": "hours"
            }
          }
        }
      },
      "id": "5282f94f-741e-4163-8d99-34284f219967",
      "name": "Manager approval in Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1540,
        260
      ],
      "webhookId": "0ef4656b-645a-433c-bb65-4c49ace4b05e",
      "credentials": {
        "slackApi": {
          "id": "",
          "name": "Slack account"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "7eb8e16e-ad9f-4cd7-a067-e31b14aa93ed",
              "leftValue": "={{ !!($json.data && $json.data.approved === true) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "4325adac-f73b-40fa-977e-8dbccfec45a7",
      "name": "Approved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1760,
        260
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_SLACK_LEADERSHIP_CHANNEL_ID",
          "mode": "id"
        },
        "messageType": "text",
        "text": "={{ $('Verify numbers').first().json.report_text }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "e05e4bd4-cbbc-4b33-9559-d1a2dada9694",
      "name": "Post report to leadership",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        160
      ],
      "credentials": {
        "slackApi": {
          "id": "",
          "name": "Slack account"
        }
      }
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "REPLACE_WITH_LEADERSHIP_EMAIL",
        "subject": "=Weekly operations report, week of {{ $('Verify numbers').first().json.report_week }}",
        "emailType": "text",
        "message": "={{ $('Verify numbers').first().json.report_text.replace(/\\*/g, '') }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "2476d2bf-6d54-4017-9279-ebd652603e99",
      "name": "Email report",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1980,
        360
      ],
      "credentials": {
        "gmailOAuth2": {
          "id": "",
          "name": "Gmail account"
        }
      }
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_SLACK_EXCEPTIONS_CHANNEL_ID",
          "mode": "id"
        },
        "messageType": "text",
        "text": "=:warning: *Weekly ops report* needs attention\n{{ $json.reason || ($json.error ? ($json.error.message || JSON.stringify($json.error)) : 'Unknown error') }}\n{{ $json.context_line || '' }}\nExecution: {{ $execution.id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "e931fe21-1815-48ff-a04c-7ed2d0c6dcae",
      "name": "Post exception to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1100,
        580
      ],
      "credentials": {
        "slackApi": {
          "id": "",
          "name": "Slack account"
        }
      }
    }
  ],
  "connections": {
    "Every Monday 7am": {
      "main": [
        [
          {
            "node": "Load weekly KPIs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load weekly KPIs": {
      "main": [
        [
          {
            "node": "Compare and flag",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post exception to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare and flag": {
      "main": [
        [
          {
            "node": "Data complete?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Data complete?": {
      "main": [
        [
          {
            "node": "Write narrative with Claude",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post exception to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write narrative with Claude": {
      "main": [
        [
          {
            "node": "Verify numbers",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post exception to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify numbers": {
      "main": [
        [
          {
            "node": "Report verified?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Report verified?": {
      "main": [
        [
          {
            "node": "Manager approval in Slack",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post exception to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Manager approval in Slack": {
      "main": [
        [
          {
            "node": "Approved?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approved?": {
      "main": [
        [
          {
            "node": "Post report to leadership",
            "type": "main",
            "index": 0
          },
          {
            "node": "Email report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
