{
  "name": "Lumeor | Weekly pipeline report (HubSpot snapshots + Postgres + Claude)",
  "nodes": [
    {
      "parameters": {
        "content": "## Automated pipeline reporting\nEvery night this workflow snapshots open and recently closed HubSpot deals into Postgres. On Mondays it compares today's snapshot with the one from seven days earlier in SQL, has Claude write a short narrative from the computed figures only, checks that every number in the narrative exists in the data, and posts the report to Slack.\n\n**Nightly at 1am:** HubSpot search (paginated) > snapshot rows > Postgres upsert.\n**Mondays:** movement SQL (new, advanced, moved back, slipped, won, lost) > formatted payload > Claude narrative > number check > Slack.\n\nIf the number check fails, the report posts as a table with no commentary and an alert goes to the owner.\n\n**Setup**\n1. Run the SQL in the yellow note.\n2. Credentials: HubSpot Service Key (crm.objects.deals.read), Postgres, Anthropic, Slack.\n3. Replace REPLACE_WITH_REPORT_CHANNEL and REPLACE_WITH_ALERTS_CHANNEL.\n4. Optional: limit the search to one pipeline by adding a `pipeline` filter in the HubSpot node.\n\n\"Advanced\" and \"moved back\" use HubSpot's stage probability, so keep probabilities increasing through your stages.\n\nModel: claude-opus-5-5. For simpler, high-volume classification work, claude-haiku-5-5 is a cheaper option; for a weekly narrative the cost difference is negligible.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/automate-pipeline-reporting/",
        "height": 820,
        "width": 560,
        "color": 7
      },
      "id": "ea97a6d1-cf7d-402b-965f-d8a65f25aba9",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -440
      ]
    },
    {
      "parameters": {
        "content": "### Postgres table (run once)\n```sql\nCREATE TABLE deal_snapshots (\n  snapshot_date date NOT NULL,\n  deal_id text NOT NULL,\n  deal_name text,\n  pipeline text,\n  stage_id text,\n  stage_probability numeric,\n  amount numeric,\n  close_date date,\n  create_date date,\n  owner_id text,\n  is_closed boolean,\n  is_won boolean,\n  last_activity timestamptz,\n  PRIMARY KEY (snapshot_date, deal_id)\n);\n```\nSnapshots are cheap. Keep them all: you cannot backfill a day you never captured.",
        "height": 560,
        "width": 460,
        "color": 6
      },
      "id": "94f41a6d-7c7b-4061-af7f-bdd52560d613",
      "name": "Database setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        520,
        -440
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 1 * * *"
            }
          ]
        }
      },
      "id": "b93f9852-3e51-4b51-934d-1680ff844abd",
      "name": "Nightly at 1am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.hubapi.com/crm/v3/objects/deals/search",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hubspotAppToken",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filterGroups: [ { filters: [ { propertyName: 'hs_is_closed', operator: 'EQ', value: 'false' } ] }, { filters: [ { propertyName: 'hs_is_closed', operator: 'EQ', value: 'true' }, { propertyName: 'closedate', operator: 'GTE', value: String(Date.now() - 30 * 24 * 60 * 60 * 1000) } ] } ], properties: ['dealname', 'pipeline', 'dealstage', 'hs_deal_stage_probability', 'amount', 'closedate', 'createdate', 'hubspot_owner_id', 'hs_is_closed', 'hs_is_closed_won', 'notes_last_updated'], sorts: [ { propertyName: 'createdate', direction: 'ASCENDING' } ], limit: 100 }) }}",
        "options": {
          "pagination": {
            "pagination": {
              "paginationMode": "updateAParameterInEachRequest",
              "parameters": {
                "parameters": [
                  {
                    "type": "body",
                    "name": "after",
                    "value": "={{ $response.body.paging && $response.body.paging.next ? $response.body.paging.next.after : undefined }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ !$response.body.paging || !$response.body.paging.next }}",
              "limitPagesFetched": true,
              "maxRequests": 100,
              "requestInterval": 250
            }
          }
        }
      },
      "id": "b7920b81-bbf3-4449-a343-044157b7c82b",
      "name": "Get open and recently closed deals",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Flattens every page of the HubSpot search into one batch of snapshot rows.\nconst toDate = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);\nconst toNum = (v) => (v === null || v === undefined || v === '' ? null : Number(v));\nconst seen = new Set();\nconst rows = [];\nfor (const page of $input.all()) {\n  for (const d of page.json.results || []) {\n    if (seen.has(d.id)) continue;\n    seen.add(d.id);\n    const p = d.properties || {};\n    rows.push({\n      deal_id: String(d.id),\n      deal_name: p.dealname || '',\n      pipeline: p.pipeline || '',\n      stage_id: p.dealstage || '',\n      stage_probability: toNum(p.hs_deal_stage_probability),\n      amount: toNum(p.amount),\n      close_date: toDate(p.closedate),\n      create_date: toDate(p.createdate),\n      owner_id: p.hubspot_owner_id || null,\n      is_closed: p.hs_is_closed === 'true',\n      is_won: p.hs_is_closed_won === 'true',\n      last_activity: p.notes_last_updated || null,\n    });\n  }\n}\nconst snapshotDate = new Date().toISOString().slice(0, 10);\nreturn [{ json: { snapshot_date: snapshotDate, deal_count: rows.length, rows } }];"
      },
      "id": "0973bffc-b0e4-4648-8090-0d3cd3578cd8",
      "name": "Build snapshot rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH upserted AS (\n  INSERT INTO deal_snapshots\n    (snapshot_date, deal_id, deal_name, pipeline, stage_id, stage_probability, amount, close_date, create_date, owner_id, is_closed, is_won, last_activity)\n  SELECT $1::date, r.deal_id, r.deal_name, r.pipeline, r.stage_id, r.stage_probability, r.amount, r.close_date, r.create_date,\n         r.owner_id, r.is_closed, r.is_won, r.last_activity\n  FROM json_to_recordset($2::json) AS r(\n    deal_id text, deal_name text, pipeline text, stage_id text, stage_probability numeric, amount numeric,\n    close_date date, create_date date, owner_id text, is_closed boolean, is_won boolean, last_activity timestamptz)\n  ON CONFLICT (snapshot_date, deal_id) DO UPDATE SET\n    deal_name = EXCLUDED.deal_name, pipeline = EXCLUDED.pipeline, stage_id = EXCLUDED.stage_id,\n    stage_probability = EXCLUDED.stage_probability, amount = EXCLUDED.amount, close_date = EXCLUDED.close_date,\n    owner_id = EXCLUDED.owner_id, is_closed = EXCLUDED.is_closed, is_won = EXCLUDED.is_won, last_activity = EXCLUDED.last_activity\n  RETURNING 1\n)\nSELECT count(*)::int AS rows_written FROM upserted;",
        "options": {
          "queryReplacement": "={{ [ $json.snapshot_date, JSON.stringify($json.rows) ] }}"
        }
      },
      "id": "6f83429b-bfcd-4469-9a41-7894234131ac",
      "name": "Write snapshot",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        660,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "ee5cb151-ce22-43ff-a6e7-88c93e7f7844",
              "leftValue": "={{ $now.weekday === 1 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "5673a0c9-d75e-46c6-b52a-dde1412e4f8c",
      "name": "Is it Monday?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        880,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH latest AS (SELECT max(snapshot_date) AS d FROM deal_snapshots),\nprior AS (SELECT max(snapshot_date) AS d FROM deal_snapshots WHERE snapshot_date <= (SELECT d FROM latest) - 7),\ncur AS (SELECT * FROM deal_snapshots WHERE snapshot_date = (SELECT d FROM latest)),\nprev AS (SELECT * FROM deal_snapshots WHERE snapshot_date = (SELECT d FROM prior)),\njoined AS (\n  SELECT coalesce(c.deal_id, p.deal_id) AS deal_id,\n         coalesce(c.deal_name, p.deal_name) AS deal_name,\n         coalesce(c.owner_id, p.owner_id) AS owner_id,\n         p.deal_id IS NULL AS is_new, c.deal_id IS NULL AS is_missing,\n         c.amount AS amount_now, p.amount AS amount_prev,\n         c.stage_probability AS prob_now, p.stage_probability AS prob_prev,\n         c.close_date AS close_now, p.close_date AS close_prev,\n         c.is_closed AS closed_now, c.is_won AS won_now, coalesce(p.is_closed, false) AS closed_prev\n  FROM cur c FULL OUTER JOIN prev p ON p.deal_id = c.deal_id\n),\nlabeled AS (\n  SELECT *, CASE\n    WHEN is_missing THEN 'missing_check_merged_or_deleted'\n    WHEN closed_now AND won_now AND NOT closed_prev THEN 'won'\n    WHEN closed_now AND NOT won_now AND NOT closed_prev THEN 'lost'\n    WHEN closed_now THEN 'closed_earlier'\n    WHEN is_new THEN 'new'\n    WHEN prob_now > prob_prev THEN 'advanced'\n    WHEN prob_now < prob_prev THEN 'moved_back'\n    WHEN close_now > close_prev THEN 'slipped'\n    ELSE 'unchanged' END AS movement,\n    coalesce(amount_now, amount_prev, 0) AS amount\n  FROM joined\n)\nSELECT json_build_object(\n  'week_ending', (SELECT d FROM latest),\n  'compared_with', (SELECT d FROM prior),\n  'open_deals', (SELECT count(*) FROM cur WHERE NOT is_closed),\n  'open_pipeline', (SELECT round(coalesce(sum(amount), 0)) FROM cur WHERE NOT is_closed),\n  'weighted_pipeline', (SELECT round(coalesce(sum(amount * coalesce(stage_probability, 0)), 0)) FROM cur WHERE NOT is_closed),\n  'won_last_30_days', (SELECT count(*) FROM cur WHERE is_closed AND is_won),\n  'lost_last_30_days', (SELECT count(*) FROM cur WHERE is_closed AND NOT is_won),\n  'stale_open_deals', (SELECT count(*) FROM cur WHERE NOT is_closed AND (last_activity IS NULL OR last_activity < now() - interval '14 days')),\n  'movement', (SELECT coalesce(json_agg(m ORDER BY m.amount DESC), '[]'::json) FROM (\n      SELECT movement, count(*) AS deals, round(sum(amount)) AS amount FROM labeled WHERE movement <> 'unchanged' GROUP BY movement) m),\n  'top_changes', (SELECT coalesce(json_agg(t), '[]'::json) FROM (\n      SELECT deal_name, movement, round(amount) AS amount, close_prev, close_now FROM labeled\n      WHERE movement NOT IN ('unchanged', 'closed_earlier') ORDER BY amount DESC LIMIT 10) t)\n) AS report;",
        "options": {}
      },
      "id": "93311c7b-163a-4070-8cbe-813001ddb4f8",
      "name": "Compute weekly movement",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1100,
        280
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Pre-formats every figure so the model can only quote strings we produced.\nconst r = $input.first().json.report;\nconst usd = (n) => '$' + Math.round(Number(n || 0)).toLocaleString('en-US');\nconst date = (d) => (d ? new Date(d + 'T12:00:00Z').toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) : 'none');\nconst closed = Number(r.won_last_30_days) + Number(r.lost_last_30_days);\nconst payload = {\n  week_ending: date(r.week_ending),\n  compared_with: r.compared_with ? date(r.compared_with) : 'no earlier snapshot yet',\n  open_deals: String(r.open_deals),\n  open_pipeline: usd(r.open_pipeline),\n  weighted_pipeline: usd(r.weighted_pipeline),\n  won_last_30_days: String(r.won_last_30_days),\n  lost_last_30_days: String(r.lost_last_30_days),\n  win_rate_last_30_days: closed ? Math.round((100 * Number(r.won_last_30_days)) / closed) + '%' : 'n/a',\n  stale_open_deals: String(r.stale_open_deals),\n  movement: (r.movement || []).map((m) => ({ movement: m.movement, deals: String(m.deals), amount: usd(m.amount) })),\n  top_changes: (r.top_changes || []).map((t) => ({\n    deal: t.deal_name,\n    movement: t.movement,\n    amount: usd(t.amount),\n    close_date_before: date(t.close_prev),\n    close_date_now: date(t.close_now),\n  })),\n};\nconst lines = [\n  `*Pipeline report, week ending ${payload.week_ending}* (compared with ${payload.compared_with})`,\n  `Open pipeline ${payload.open_pipeline} across ${payload.open_deals} deals, weighted ${payload.weighted_pipeline}.`,\n  `Won ${payload.won_last_30_days}, lost ${payload.lost_last_30_days} in the last 30 days (win rate ${payload.win_rate_last_30_days}). Open deals with no activity in 14 days: ${payload.stale_open_deals}.`,\n  '',\n  '*Movement*',\n  ...payload.movement.map((m) => `- ${m.movement}: ${m.deals} deals, ${m.amount}`),\n  '',\n  '*Largest changes*',\n  ...payload.top_changes.map((t) => `- ${t.deal}: ${t.movement}, ${t.amount}` + (t.movement === 'slipped' ? ` (close ${t.close_date_before} to ${t.close_date_now})` : '')),\n];\nreturn [{ json: { payload, table_text: lines.join('\\n') } }];"
      },
      "id": "66e49132-1ed0-41d8-88bf-29ad7b1ca35b",
      "name": "Format report payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        280
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'claude-opus-5-5', max_tokens: 2000, system: \"You write the weekly pipeline commentary for a sales leader.\\nRules:\\n- Write 5 to 8 plain sentences. No bullet points, no headings.\\n- Use only figures that appear in the JSON payload, copied exactly as written (same currency format, same dates). Do not compute new numbers, totals, averages or percentages.\\n- Call out the largest movements by amount, slipped deals and stale deals.\\n- Do not guess why numbers moved. Only state causes that appear in the data.\\n- Refer to deals by the name given.\", output_config: { \"format\": { \"type\": \"json_schema\", \"schema\": { \"type\": \"object\", \"properties\": { \"headline\": { \"type\": \"string\", \"description\": \"One sentence headline\" }, \"narrative\": { \"type\": \"string\", \"description\": \"5 to 8 sentences of commentary\" }, \"watch_list\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"Up to 3 deal names worth a look this week\" } }, \"required\": [ \"headline\", \"narrative\", \"watch_list\" ], \"additionalProperties\": false } } }, messages: [ { role: 'user', content: 'Weekly pipeline payload (JSON):\\n' + JSON.stringify($json.payload) } ] }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "5e63da37-bb20-46bb-a316-454fce888c9c",
      "name": "Write narrative with Claude",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1540,
        280
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Every number in the commentary must already exist in the payload. If not, post the table only and alert.\nconst { payload, table_text: table } = $('Format report payload').first().json;\nconst res = $input.first().json;\nconst out = [];\n// Keep the $ and % markers so a date like \"Oct 12\" cannot vouch for \"12%\".\nconst norm = (t) => t.replace(/,/g, '').replace(/\\.0+(%?)$/, '$1');\nconst tokens = (s) => (String(s).match(/\\$?\\d[\\d,]*(?:\\.\\d+)?%?/g) || []).map(norm);\nconst allowed = new Set(tokens(JSON.stringify(payload)));\n\nlet text = null;\nlet problem = null;\nif (res.error) {\n  problem = `Claude API call failed: ${typeof res.error === 'string' ? res.error : res.error.message}`;\n} else if (res.stop_reason === 'refusal' || res.stop_reason === 'max_tokens') {\n  problem = `Claude stopped with stop_reason=${res.stop_reason}`;\n} else if (!(res.content || []).some((b) => b.type === 'text')) {\n  // Opus 5.5 may return a thinking block first; a missing text block is an exception.\n  problem = 'Claude response had no text block';\n} else {\n  try {\n    const block = res.content.find((b) => b.type === 'text');\n    const n = JSON.parse(block.text);\n    const unknown = [...new Set(tokens(`${n.headline} ${n.narrative}`))].filter((t) => !allowed.has(t));\n    if (unknown.length) problem = `Narrative used numbers not in the data: ${unknown.join(', ')}`;\n    else text = `*${n.headline}*\\n${n.narrative}` + (n.watch_list.length ? `\\n_Watch: ${n.watch_list.join(', ')}_` : '');\n  } catch (e) {\n    problem = 'Claude narrative was not valid JSON';\n  }\n}\nif (problem) out.push({ json: { error: problem, details: { week_ending: payload.week_ending } } });\nout.push({ json: { verified: !problem, message: text ? `${text}\\n\\n${table}` : `${table}\\n\\n_Commentary withheld: it did not pass the number check._` } });\nreturn out;"
      },
      "id": "cdadb595-5705-446c-9f44-d1fc981730e0",
      "name": "Check every number",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1760,
        280
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_REPORT_CHANNEL",
          "mode": "name"
        },
        "text": "={{ $json.message }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "8dd5a904-e874-478e-85c9-80ff0738f5ee",
      "name": "Post pipeline report",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        260
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_ALERTS_CHANNEL",
          "mode": "name"
        },
        "text": "=:warning: *Pipeline report needs attention*\n*Reason:* {{ typeof $json.error === 'string' ? $json.error : (($json.error && $json.error.message) || 'Unknown error') }}\n*Details:* {{ JSON.stringify($json.details || $json.error || {}).slice(0, 600) }}\n*Execution:* {{ $execution.id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "668e7664-583d-4f23-9e20-9f0a014b7d68",
      "name": "Alert: report needs attention",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        480
      ]
    }
  ],
  "connections": {
    "Nightly at 1am": {
      "main": [
        [
          {
            "node": "Get open and recently closed deals",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get open and recently closed deals": {
      "main": [
        [
          {
            "node": "Build snapshot rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build snapshot rows": {
      "main": [
        [
          {
            "node": "Write snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write snapshot": {
      "main": [
        [
          {
            "node": "Is it Monday?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is it Monday?": {
      "main": [
        [
          {
            "node": "Compute weekly movement",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute weekly movement": {
      "main": [
        [
          {
            "node": "Format report payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format report payload": {
      "main": [
        [
          {
            "node": "Write narrative with Claude",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write narrative with Claude": {
      "main": [
        [
          {
            "node": "Check every number",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check every number",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check every number": {
      "main": [
        [
          {
            "node": "Post pipeline report",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: report needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
