{
  "name": "Lumeor | AI sales follow-up sequences (HubSpot + Gmail)",
  "nodes": [
    {
      "parameters": {
        "content": "## AI sales follow-up sequences\nEnrolls contacts on HubSpot deals that reach a trigger stage, drafts each follow-up with Claude from the deal notes, sends it as a threaded reply from the rep's Gmail, and stops the moment the contact (or anyone at their company domain) writes back.\n\n**Every 15 minutes (weekdays, 8am to 5pm):**\n1. Top branch: find deals in the trigger stage, look up their contacts, enroll new ones in Postgres (suppression list and one-active-sequence rules are enforced in SQL).\n2. Bottom branch: read recent inbound mail, stop sequences that got a reply, add \"unsubscribe\" replies to suppression, then draft, validate and send the steps that are due.\n3. Refusals, truncated drafts, missing threads and failed checks go to Slack.\n\n**Setup**\n1. Run the SQL in the yellow note against your Postgres database.\n2. Credentials: HubSpot Service Key (private app token with crm.objects.deals.read and crm.objects.contacts.read), Postgres, Gmail OAuth2 (the rep's mailbox), Anthropic, Slack.\n3. Replace REPLACE_WITH_TRIGGER_STAGE_ID, REPLACE_WITH_REP_SIGNATURE, REPLACE_WITH_COMPANY_POSTAL_ADDRESS and REPLACE_WITH_SLACK_CHANNEL.\n4. Set your instance time zone so the schedule matches business hours.\n\nSequences only run where the rep has already emailed the contact, so every follow-up lands in a real thread. Model: claude-opus-5-5. For high-volume reply classification you can switch to claude-haiku-5-5, which is cheaper.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/automate-sales-follow-up-sequences/",
        "height": 820,
        "width": 560,
        "color": 7
      },
      "id": "a97f877a-5ff2-4645-984b-f1903c8880b0",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -420
      ]
    },
    {
      "parameters": {
        "content": "### Postgres tables (run once)\n```sql\nCREATE TABLE follow_up_enrollments (\n  id bigserial PRIMARY KEY,\n  deal_id text NOT NULL,\n  deal_name text,\n  deal_notes text,\n  owner_id text,\n  contact_id text,\n  contact_email text NOT NULL,\n  contact_domain text,\n  first_name text,\n  sequence_key text NOT NULL DEFAULT 'post_demo',\n  step int NOT NULL DEFAULT 1,\n  max_steps int NOT NULL DEFAULT 4,\n  status text NOT NULL DEFAULT 'active',\n  next_send_at timestamptz NOT NULL,\n  last_sent_at timestamptz,\n  last_message_id text,\n  thread_id text,\n  stopped_at timestamptz,\n  created_at timestamptz NOT NULL DEFAULT now(),\n  UNIQUE (deal_id, sequence_key)\n);\nCREATE UNIQUE INDEX one_active_sequence_per_contact\n  ON follow_up_enrollments (contact_email) WHERE status = 'active';\nCREATE TABLE follow_up_suppression (\n  email text PRIMARY KEY,\n  reason text,\n  created_at timestamptz NOT NULL DEFAULT now()\n);\n```",
        "height": 820,
        "width": 520,
        "color": 6
      },
      "id": "cc609546-ec4c-472b-8ca2-4aaae910bbba",
      "name": "Database setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        520,
        -420
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "*/15 8-17 * * 1-5"
            }
          ]
        }
      },
      "id": "d1d14753-c777-4809-b81c-5e17e9ac60fa",
      "name": "Every 15 min on weekdays",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        520
      ]
    },
    {
      "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: 'dealstage', operator: 'EQ', value: 'REPLACE_WITH_TRIGGER_STAGE_ID' }, { propertyName: 'hs_lastmodifieddate', operator: 'GTE', value: String(Date.now() - 2 * 24 * 60 * 60 * 1000) } ] } ], properties: ['dealname', 'dealstage', 'amount', 'hubspot_owner_id', 'description', 'closedate'], sorts: [ { propertyName: 'hs_lastmodifieddate', direction: 'DESCENDING' } ], limit: 100 }) }}",
        "options": {}
      },
      "id": "38567932-cfa4-4d08-8024-c93999c78bb7",
      "name": "Find deals in trigger stage",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        240,
        420
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "bd7eba2c-65f7-4ef9-ad78-404a1dbced04",
              "leftValue": "={{ $json.total }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "3cf5d634-a6c2-4037-be9c-d60b529ce825",
      "name": "Any deals found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        460,
        420
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.hubapi.com/crm/v4/associations/deals/contacts/batch/read",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hubspotAppToken",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ inputs: $json.results.map(d => ({ id: d.id })) }) }}",
        "options": {}
      },
      "id": "1c32cf79-5606-47d3-b9cf-5f65e7f2612c",
      "name": "Get deal contacts",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        680,
        400
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.hubapi.com/crm/v3/objects/contacts/batch/read",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hubspotAppToken",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ properties: ['email', 'firstname', 'lastname'], inputs: [...new Set($json.results.flatMap(r => (r.to || []).map(t => String(t.toObjectId))))].map(id => ({ id })) }) }}",
        "options": {}
      },
      "id": "967a6c8d-4b48-4cdd-acd5-9ae613951c59",
      "name": "Get contact details",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        900,
        400
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "-- Joins deals, deal-contact links and contacts, then enrolls each deal's first contact.\n-- ON CONFLICT skips deals already enrolled and contacts already in an active sequence.\nWITH deals AS (\n  SELECT d->>'id' AS deal_id,\n         d->'properties'->>'dealname' AS deal_name,\n         d->'properties'->>'description' AS deal_notes,\n         d->'properties'->>'hubspot_owner_id' AS owner_id\n  FROM json_array_elements($1::json) d\n), links AS (\n  SELECT a->'from'->>'id' AS deal_id,\n         (SELECT min(t->>'toObjectId') FROM json_array_elements(a->'to') t) AS contact_id\n  FROM json_array_elements($2::json) a\n), contacts AS (\n  SELECT c->>'id' AS contact_id,\n         lower(c->'properties'->>'email') AS email,\n         c->'properties'->>'firstname' AS first_name\n  FROM json_array_elements($3::json) c\n)\nINSERT INTO follow_up_enrollments\n  (deal_id, deal_name, deal_notes, owner_id, contact_id, contact_email, contact_domain, first_name, sequence_key, step, next_send_at, status)\nSELECT d.deal_id, d.deal_name, d.deal_notes, d.owner_id, c.contact_id, c.email, split_part(c.email, '@', 2), c.first_name,\n       'post_demo', 1, now() + interval '1 day', 'active'\nFROM deals d\nJOIN links l ON l.deal_id = d.deal_id\nJOIN contacts c ON c.contact_id = l.contact_id\nWHERE c.email IS NOT NULL\n  AND NOT EXISTS (SELECT 1 FROM follow_up_suppression s WHERE s.email = c.email)\nON CONFLICT DO NOTHING\nRETURNING id, deal_id, contact_email;",
        "options": {
          "queryReplacement": "={{ [ JSON.stringify($('Find deals in trigger stage').first().json.results), JSON.stringify($('Get deal contacts').first().json.results), JSON.stringify($json.results) ] }}"
        }
      },
      "id": "3a768c8b-18ab-4c73-baf6-390fe268beb6",
      "name": "Enroll new contacts",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1120,
        400
      ]
    },
    {
      "parameters": {
        "operation": "getAll",
        "returnAll": false,
        "limit": 200,
        "simple": true,
        "filters": {
          "q": "in:inbox newer_than:2d -from:me"
        }
      },
      "id": "1a0019a4-1a01-4458-8264-a419a0015a86",
      "name": "Read recent inbound email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        240,
        680
      ],
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "-- 1) Any inbound mail from a contact (or their company domain) stops their sequence.\n-- 2) Replies that ask to unsubscribe go on the suppression list.\n-- 3) Return active steps that are due, skipping anyone who just wrote in.\nWITH inbound AS (\n  SELECT lower(substring(m->>'from' from '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+')) AS email,\n         coalesce(m->>'snippet', '') AS snippet\n  FROM json_array_elements($1::json) m\n), inbound_clean AS (\n  SELECT email, split_part(email, '@', 2) AS domain, snippet FROM inbound WHERE email IS NOT NULL\n), new_suppressions AS (\n  INSERT INTO follow_up_suppression (email, reason)\n  SELECT DISTINCT email, 'unsubscribe_reply' FROM inbound_clean\n  WHERE snippet ~* '(unsubscribe|opt out|remove me|stop emailing)'\n  ON CONFLICT (email) DO NOTHING\n  RETURNING email\n), stopped AS (\n  UPDATE follow_up_enrollments e\n  SET status = 'stopped_reply', stopped_at = now()\n  WHERE e.status = 'active'\n    AND (e.contact_email IN (SELECT email FROM inbound_clean)\n      OR e.contact_domain IN (\n        SELECT domain FROM inbound_clean\n        WHERE domain NOT IN ('gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'yahoo.com', 'icloud.com', 'aol.com')))\n  RETURNING e.id\n)\nSELECT e.id, e.deal_id, e.deal_name, e.deal_notes, e.contact_email, e.first_name, e.sequence_key, e.step, e.max_steps,\n       e.last_sent_at\nFROM follow_up_enrollments e\nWHERE e.status = 'active'\n  AND e.next_send_at <= now()\n  AND e.id NOT IN (SELECT id FROM stopped)\n  AND NOT EXISTS (SELECT 1 FROM follow_up_suppression s WHERE s.email = e.contact_email)\n  AND NOT EXISTS (SELECT 1 FROM inbound_clean i WHERE i.email = e.contact_email)\nORDER BY e.next_send_at\nLIMIT 25;",
        "options": {
          "queryReplacement": "={{ [ JSON.stringify($input.all().map(i => ({ from: String(i.json.From || ''), snippet: String(i.json.snippet || '') }))) ] }}"
        }
      },
      "id": "6409b9c4-e2e0-41aa-8803-9e75c081e543",
      "name": "Stop replied, get due steps",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        460,
        680
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://gmail.googleapis.com/gmail/v1/users/me/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "gmailOAuth2",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "q",
              "value": "=in:sent to:{{ $json.contact_email }}"
            },
            {
              "name": "maxResults",
              "value": "1"
            }
          ]
        },
        "options": {}
      },
      "id": "dfb94d46-ddde-4c9a-aab0-cdc40e8b8d0c",
      "name": "Find last thread with contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        680,
        680
      ]
    },
    {
      "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 short sales follow-up emails for a B2B rep, sent as a reply in an existing email thread.\\nRules:\\n- 40 to 140 words. Plain text, no subject line, no greeting beyond the first name, no signature (it is added later).\\n- Use only facts in the deal notes provided. Never invent pricing, discounts, deadlines, features, customers or results.\\n- Each step must add something new. Step 1: recap the agreed next step or answer an open question from the notes. Step 2: share one relevant example or resource mentioned in the notes. Step 3: a direct scheduling nudge. Final step: a polite close-the-loop message that makes it easy to say not now.\\n- Never use the phrases \\\"just bumping\\\", \\\"circling back\\\" or \\\"touching base\\\". Do not mention that this is automated.\\n- End with one clear call to action.\\n- If the notes are too thin to write something specific without inventing facts, set needs_human to true and explain why.\", output_config: { \"format\": { \"type\": \"json_schema\", \"schema\": { \"type\": \"object\", \"properties\": { \"body\": { \"type\": \"string\", \"description\": \"The email body, plain text, without signature\" }, \"call_to_action\": { \"type\": \"string\", \"description\": \"The single call to action used\" }, \"facts_used\": { \"type\": \"array\", \"items\": { \"type\": \"string\" }, \"description\": \"Facts from the deal notes that the email relies on\" }, \"needs_human\": { \"type\": \"boolean\", \"description\": \"True if a person should write this step instead\" }, \"needs_human_reason\": { \"type\": \"string\", \"description\": \"Why a person should write it, or an empty string\" } }, \"required\": [ \"body\", \"call_to_action\", \"facts_used\", \"needs_human\", \"needs_human_reason\" ], \"additionalProperties\": false } } }, messages: [ { role: 'user', content: 'Write follow-up step ' + $('Stop replied, get due steps').item.json.step + ' of ' + $('Stop replied, get due steps').item.json.max_steps + '.\\n\\nDeal context (JSON):\\n' + JSON.stringify({ deal_name: $('Stop replied, get due steps').item.json.deal_name, contact_first_name: $('Stop replied, get due steps').item.json.first_name, deal_notes: $('Stop replied, get due steps').item.json.deal_notes || '', last_follow_up_sent_at: $('Stop replied, get due steps').item.json.last_sent_at }) } ] }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "d634e866-1345-4407-9d68-2b3383ec0733",
      "name": "Draft follow-up with Claude",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        900,
        680
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Checks every Claude draft before anything is sent.\n// Items that fail are returned as { error, details } so n8n routes them to the error output.\nconst MIN_WORDS = 25;\nconst MAX_CHARS = 1200;\nconst BANNED = [/just bumping/i, /circling back/i, /touching base/i, /\\bdiscount/i, /% off/i, /\\$\\s?\\d/, /\\[[^\\]]+\\]/];\nconst SIGNATURE = [\n  '',\n  'REPLACE_WITH_REP_SIGNATURE',\n  'REPLACE_WITH_COMPANY_POSTAL_ADDRESS',\n  'If you would rather not hear from us, reply \"unsubscribe\" and we will stop.',\n].join('\\n');\n\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const res = items[i].json;\n  const enrollment = $('Stop replied, get due steps').itemMatching(i).json;\n  const thread = $('Find last thread with contact').itemMatching(i).json;\n  const details = { enrollment_id: enrollment.id, contact_email: enrollment.contact_email, deal_name: enrollment.deal_name, step: enrollment.step };\n  const fail = (reason, extra = {}) => out.push({ json: { error: reason, details: { ...details, ...extra } } });\n\n  if (res.stop_reason === 'refusal' || res.stop_reason === 'max_tokens') {\n    fail(`Claude stopped with stop_reason=${res.stop_reason}`);\n    continue;\n  }\n  // Opus 5.5 may return a thinking block first, so find the text block explicitly.\n  const block = (res.content || []).find((b) => b.type === 'text');\n  if (!block) {\n    fail('Claude response had no text block');\n    continue;\n  }\n  let draft;\n  try {\n    draft = JSON.parse(block.text);\n  } catch (e) {\n    fail('Claude response was not valid JSON', { raw: String(block && block.text).slice(0, 300) });\n    continue;\n  }\n  if (draft.needs_human) {\n    fail('Claude asked for a human to write this step', { why: draft.needs_human_reason });\n    continue;\n  }\n  const text = String(draft.body || '').trim();\n  const words = text.split(/\\s+/).filter(Boolean).length;\n  if (words < MIN_WORDS || text.length > MAX_CHARS) {\n    fail(`Draft length out of range (${words} words, ${text.length} chars)`);\n    continue;\n  }\n  const hit = BANNED.find((re) => re.test(text));\n  if (hit) {\n    fail(`Draft matched a blocked pattern ${hit}`, { draft: text.slice(0, 300) });\n    continue;\n  }\n  const message = thread.messages && thread.messages[0];\n  if (!message) {\n    fail('No earlier email to this contact in the rep mailbox, so there is no thread to reply in');\n    continue;\n  }\n  out.push({\n    json: {\n      enrollment_id: enrollment.id,\n      contact_email: enrollment.contact_email,\n      thread_id: message.threadId,\n      message_id: message.id,\n      body_text: `${text}\\n${SIGNATURE}`,\n    },\n  });\n}\nreturn out;"
      },
      "id": "bc2394c3-9b7d-4bf0-8add-a2fe6a08791e",
      "name": "Validate draft",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1120,
        680
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "resource": "thread",
        "operation": "reply",
        "threadId": "={{ $json.thread_id }}",
        "messageId": "={{ $json.message_id }}",
        "emailType": "text",
        "message": "={{ $json.body_text }}",
        "options": {
          "replyToRecipientsOnly": true
        }
      },
      "id": "5adb063d-1e3e-4262-9ae5-5681459e8036",
      "name": "Send threaded follow-up",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1340,
        660
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE follow_up_enrollments\nSET step = step + 1,\n    last_sent_at = now(),\n    last_message_id = $2,\n    thread_id = $3,\n    next_send_at = now() + CASE step WHEN 1 THEN interval '3 days' WHEN 2 THEN interval '5 days' ELSE interval '7 days' END,\n    status = CASE WHEN step + 1 > max_steps THEN 'completed' ELSE status END\nWHERE id = $1\nRETURNING id, step, status;",
        "options": {
          "queryReplacement": "={{ [ $('Validate draft').item.json.enrollment_id, $json.id, $json.threadId ] }}"
        }
      },
      "id": "a4a29163-21ae-4e6e-b473-3755c951c120",
      "name": "Advance sequence step",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1560,
        660
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_SLACK_CHANNEL",
          "mode": "name"
        },
        "text": "=:warning: *Sales follow-up 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": "c0d6e39e-2cf2-4cce-a835-80e1d4fc595a",
      "name": "Alert: follow-up needs attention",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1340,
        880
      ]
    }
  ],
  "connections": {
    "Every 15 min on weekdays": {
      "main": [
        [
          {
            "node": "Find deals in trigger stage",
            "type": "main",
            "index": 0
          },
          {
            "node": "Read recent inbound email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find deals in trigger stage": {
      "main": [
        [
          {
            "node": "Any deals found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any deals found?": {
      "main": [
        [
          {
            "node": "Get deal contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get deal contacts": {
      "main": [
        [
          {
            "node": "Get contact details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get contact details": {
      "main": [
        [
          {
            "node": "Enroll new contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read recent inbound email": {
      "main": [
        [
          {
            "node": "Stop replied, get due steps",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stop replied, get due steps": {
      "main": [
        [
          {
            "node": "Find last thread with contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find last thread with contact": {
      "main": [
        [
          {
            "node": "Draft follow-up with Claude",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Draft follow-up with Claude": {
      "main": [
        [
          {
            "node": "Validate draft",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: follow-up needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate draft": {
      "main": [
        [
          {
            "node": "Send threaded follow-up",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: follow-up needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send threaded follow-up": {
      "main": [
        [
          {
            "node": "Advance sequence step",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
