{
  "id": "6e7242a13e2dbb6e",
  "name": "05 - Quote Follow-up Cadence (Drafts Only)",
  "nodes": [
    {
      "parameters": {
        "content": "## WHAT THIS LOOP CLOSES\n\n**The leak: sent quotes die in silence.**\n\nYou send a quote, the prospect goes quiet, and nobody follows up because everyone is busy doing the work. The quote is not dead, it is just unattended.\n\nThis loop checks your CRM every morning, finds every quote with no reply at day 3, day 7, and day 14, and writes a stage-appropriate follow-up draft that references the actual quote. You review, you press send. The cadence never sleeps, and nothing leaves without your eyes on it.",
        "height": 420,
        "width": 400,
        "color": 4
      },
      "id": "a1f3c8e2-5b91-4d27-9c44-0e6a2b7f1d05",
      "name": "WHAT THIS LOOP CLOSES",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -520
      ]
    },
    {
      "parameters": {
        "content": "## 60-SECOND SETUP\n\n1. **Airtable nodes (2):** attach your Airtable credential, then pick your CRM base and your Quotes table in both nodes. Your table needs these fields: Status, Quote Sent Date, Reply Received, Follow-up Stage, Contact Name, Contact Email, Company, Quote Amount, Quote Summary, Last Follow-up Date, Last Activity.\n2. **Business Brain Context node:** paste your business brain (who you are, what you sell, voice and style, sign-off) into the `businessContext` value.\n3. **AI node:** attach your OpenAI credential.\n4. **Gmail node:** attach your Gmail OAuth credential.\n5. **Slack node:** attach your Slack credential and set the channel (or set the env var `YOUR_SLACK_CHANNEL`).\n6. Activate the workflow. It runs every morning at 08:00.",
        "height": 420,
        "width": 460,
        "color": 5
      },
      "id": "b2e4d9f3-6ca2-4e38-8d55-1f7b3c8a2e16",
      "name": "60-SECOND SETUP",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        380,
        -520
      ]
    },
    {
      "parameters": {
        "content": "## SAFETY: DRAFTS NOT SENDS\n\nThe AI writes the email. It never sends it.\n\nThe Gmail node creates a **draft** in your Drafts folder, addressed and ready. The CRM gets a log entry, you get a Slack ping, and the final send is a human decision, every time.\n\nIf you ever change the Gmail node from Draft to Send, you have removed the safety. Do not.",
        "height": 300,
        "width": 380,
        "color": 3
      },
      "id": "c3f5eaf4-7db3-4f49-9e66-2a8c4d9b3f27",
      "name": "SAFETY: DRAFTS NOT SENDS",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1040,
        -400
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 8
            }
          ]
        }
      },
      "id": "d4a6fbe5-8ec4-4a5a-af77-3b9d5eac4a38",
      "name": "Daily 8am Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "operation": "search",
        "base": {
          "__rl": true,
          "value": "YOUR_AIRTABLE_BASE_ID",
          "mode": "id"
        },
        "table": {
          "__rl": true,
          "value": "YOUR_QUOTES_TABLE_ID",
          "mode": "id"
        },
        "filterByFormula": "AND({Status} = 'Quote Sent', {Reply Received} = '')",
        "options": {}
      },
      "id": "e5b7acf6-9fd5-4b6b-ba88-4caf6fbd5b49",
      "name": "Get Open Quotes (CRM)",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Find quotes that hit the 3 / 7 / 14 day mark today and have not\n// already received that touch. Outputs one item per due follow-up.\nconst out = [];\nconst now = Date.now();\nconst DAY = 24 * 60 * 60 * 1000;\n\nfor (const item of $input.all()) {\n  const f = item.json.fields || item.json;\n  const sentRaw = f['Quote Sent Date'];\n  if (!sentRaw) continue;\n\n  const sent = new Date(sentRaw).getTime();\n  if (Number.isNaN(sent)) continue;\n\n  const daysSinceSent = Math.floor((now - sent) / DAY);\n  const lastStage = Number(f['Follow-up Stage'] || 0);\n\n  // Cadence ladder: stage 1 at day 3, stage 2 at day 7, stage 3 at day 14.\n  let touchNumber = 0;\n  let tone = '';\n  if (daysSinceSent >= 14 && lastStage < 3) {\n    touchNumber = 3;\n    tone = 'close the loop: polite, direct, ask if the project is shelved or if the quote should be closed out, leave the door open';\n  } else if (daysSinceSent >= 7 && lastStage < 2) {\n    touchNumber = 2;\n    tone = 'add value: answer a likely unspoken objection, share one useful detail or option, make replying easy';\n  } else if (daysSinceSent >= 3 && lastStage < 1) {\n    touchNumber = 1;\n    tone = 'friendly nudge: short, warm, reference the quote, ask one easy question';\n  }\n\n  if (touchNumber === 0) continue;\n\n  out.push({\n    json: {\n      recordId: item.json.id || f.id || '',\n      contactName: f['Contact Name'] || 'there',\n      contactEmail: f['Contact Email'] || '',\n      company: f['Company'] || '',\n      quoteAmount: f['Quote Amount'] || '',\n      quoteSummary: f['Quote Summary'] || '',\n      quoteSentDate: sentRaw,\n      daysSinceSent,\n      touchNumber,\n      tone,\n    },\n  });\n}\n\nreturn out;"
      },
      "id": "f6c8bda7-aae6-4c7c-cb99-5dba7ace6c5a",
      "name": "Filter Quotes Due Today",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "07d1e2f3-1b2c-4d3e-9f40-516273849a0b",
              "leftValue": "={{ $json.touchNumber }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "07d9ceb8-bbf7-4d8d-dcaa-6ecb8bdf7d6b",
      "name": "Due For Follow-up?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "18e2f3a4-2c3d-4e4f-a051-62738495ab1c",
              "name": "businessContext",
              "value": "PASTE YOUR BUSINESS BRAIN CONTEXT HERE. Include: who you are and what you sell (01-about-us), your tone of voice and how you sign emails (02-voice-and-style), your offers and the answers to common objections (03-offers-and-faqs). The AI uses this block to write follow-ups that sound like you, not like a robot.",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "18eadfc9-cca8-4e9e-edbb-7fdc9cea8e7c",
      "name": "Business Brain Context",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-4o",
          "mode": "list",
          "cachedResultName": "gpt-4o"
        },
        "resource": "text",
        "operation": "message",
        "messages": {
          "values": [
            {
              "content": "=You write follow-up emails for unanswered quotes. You DRAFT only. You never send anything, and you never claim the email was sent. A human will review and send this draft.\n\n=== CONTEXT (the business brain, pasted by the owner) ===\n{{ $json.businessContext }}\n=== END CONTEXT ===\n\n=== THE QUOTE ===\nContact: {{ $json.contactName }} at {{ $json.company }}\nQuote amount: {{ $json.quoteAmount }}\nWhat was quoted: {{ $json.quoteSummary }}\nSent: {{ $json.quoteSentDate }} ({{ $json.daysSinceSent }} days ago, no reply)\nThis is follow-up touch number {{ $json.touchNumber }} of 3.\n=== END QUOTE ===\n\nStage instruction for touch {{ $json.touchNumber }}: {{ $json.tone }}\n\nRules:\n- Write in the voice and style defined in the CONTEXT block. Match its language.\n- Reference the actual quote (what it covered, the amount if natural). No generic 'just checking in'.\n- Keep it under 120 words. One clear question or next step.\n- No pressure tactics, no fake urgency, no invented discounts or deadlines.\n- Do not invent facts that are not in the CONTEXT block or the quote details.\n- Sign off exactly the way the CONTEXT block says to sign off.\n\nReturn EXACTLY this format and nothing else:\nSubject: <subject line>\n\n<email body>"
            }
          ]
        },
        "options": {}
      },
      "id": "29fbe0da-ddb9-4faf-fecc-80ed0adfb98d",
      "name": "Write Follow-up Draft (AI)",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.8,
      "position": [
        1100,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Split the AI output into subject + body and carry the quote fields forward.\n// IMPORTANT: pair each AI draft back to ITS OWN source quote row by the\n// current item's index. itemMatching() with a running counter blanks out\n// recordId / contactEmail when more than one quote is due in a single run,\n// because pairedItem linkage is unreliable across the langchain AI node.\nconst items = $input.all();\nconst out = [];\nfor (let i = 0; i < items.length; i++) {\n  const item = items[i];\n  const raw =\n    item.json.message?.content ??\n    item.json.choices?.[0]?.message?.content ??\n    item.json.text ??\n    '';\n  const text = String(raw).trim();\n  const lines = text.split('\\n');\n  let subject = 'Following up on your quote';\n  let bodyStart = 0;\n  if (lines[0] && lines[0].toLowerCase().startsWith('subject:')) {\n    subject = lines[0].slice(8).trim();\n    bodyStart = 1;\n  }\n  const body = lines.slice(bodyStart).join('\\n').trim();\n\n  // Recover this draft's own quote row. The Business Brain Context node runs\n  // with includeOtherFields enabled, so the quote fields arrive on the AI\n  // input item itself; prefer those. Fall back to the matching source row by\n  // this item's index so each draft keeps its own recordId / contactEmail.\n  const carried = item.json || {};\n  const source =\n    $('Filter Quotes Due Today').itemMatching(i)?.json ||\n    $('Filter Quotes Due Today').all()[i]?.json ||\n    {};\n  const quote = {\n    recordId: carried.recordId || source.recordId || '',\n    contactName: carried.contactName || source.contactName || 'there',\n    contactEmail: carried.contactEmail || source.contactEmail || '',\n    company: carried.company || source.company || '',\n    quoteAmount: carried.quoteAmount || source.quoteAmount || '',\n    quoteSummary: carried.quoteSummary || source.quoteSummary || '',\n    quoteSentDate: carried.quoteSentDate || source.quoteSentDate || '',\n    daysSinceSent: carried.daysSinceSent ?? source.daysSinceSent ?? '',\n    touchNumber: carried.touchNumber ?? source.touchNumber ?? '',\n    tone: carried.tone || source.tone || '',\n  };\n  out.push({ json: { ...quote, subject, body } });\n}\nreturn out;"
      },
      "id": "3a0cf1eb-eeca-4aba-afdd-91fe1beac0ae",
      "name": "Parse Draft",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        0
      ]
    },
    {
      "parameters": {
        "resource": "draft",
        "subject": "={{ $json.subject }}",
        "message": "={{ $json.body }}",
        "options": {
          "sendTo": "={{ $json.contactEmail }}"
        }
      },
      "id": "4b1da2fc-ffdb-4bcb-b0ee-a20f2cfbd1bf",
      "name": "Create Gmail Draft",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1540,
        0
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "base": {
          "__rl": true,
          "value": "YOUR_AIRTABLE_BASE_ID",
          "mode": "id"
        },
        "table": {
          "__rl": true,
          "value": "YOUR_QUOTES_TABLE_ID",
          "mode": "id"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "id": "={{ $('Parse Draft').item.json.recordId }}",
            "Follow-up Stage": "={{ $('Parse Draft').item.json.touchNumber }}",
            "Last Follow-up Date": "={{ $now.toISO() }}",
            "Last Activity": "=Follow-up draft #{{ $('Parse Draft').item.json.touchNumber }} created in Gmail (day {{ $('Parse Draft').item.json.daysSinceSent }}). Awaiting human review and send."
          },
          "matchingColumns": [
            "id"
          ],
          "schema": []
        },
        "options": {}
      },
      "id": "5c2eb3ad-aaec-4dcc-c1ff-b3a03dace2ca",
      "name": "Log Activity in CRM",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        1760,
        0
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "={{ $env.YOUR_SLACK_CHANNEL }}",
          "mode": "id"
        },
        "text": "=Quote follow-up draft ready for review:\n\u2022 Contact: {{ $('Parse Draft').item.json.contactName }} ({{ $('Parse Draft').item.json.company }})\n\u2022 Quote: {{ $('Parse Draft').item.json.quoteAmount }}, sent {{ $('Parse Draft').item.json.daysSinceSent }} days ago, no reply\n\u2022 Touch {{ $('Parse Draft').item.json.touchNumber }} of 3\n\u2022 Subject: {{ $('Parse Draft').item.json.subject }}\n\nThe draft is sitting in Gmail Drafts. Review it and press send.",
        "otherOptions": {}
      },
      "id": "6d3fc4be-bbfd-4edd-d2aa-c4b14ebfd3db",
      "name": "Ping Owner on Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        1980,
        0
      ]
    }
  ],
  "connections": {
    "Daily 8am Trigger": {
      "main": [
        [
          {
            "node": "Get Open Quotes (CRM)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Open Quotes (CRM)": {
      "main": [
        [
          {
            "node": "Filter Quotes Due Today",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Quotes Due Today": {
      "main": [
        [
          {
            "node": "Due For Follow-up?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Due For Follow-up?": {
      "main": [
        [
          {
            "node": "Business Brain Context",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Business Brain Context": {
      "main": [
        [
          {
            "node": "Write Follow-up Draft (AI)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Follow-up Draft (AI)": {
      "main": [
        [
          {
            "node": "Parse Draft",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Draft": {
      "main": [
        [
          {
            "node": "Create Gmail Draft",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Gmail Draft": {
      "main": [
        [
          {
            "node": "Log Activity in CRM",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Activity in CRM": {
      "main": [
        [
          {
            "node": "Ping Owner on Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}