{
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "name": "Send AI personalized follow-ups for stale estimates from Google Sheets",
  "tags": [],
  "nodes": [
    {
      "id": "bc6e071d-76eb-4783-98d5-463dc1d42080",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -368,
        0
      ],
      "parameters": {
        "width": 480,
        "height": 864,
        "content": "## Send AI personalized follow-ups for stale estimates from Google Sheets\n\n### How it works\n\nThis workflow runs daily to find stale customer estimates in a Google Sheet and prepare personalized follow-up emails. It filters the estimate pipeline based on a configured age threshold, uses Claude to generate a tailored message, then saves the result as a Gmail draft. Finally, it updates the sheet so the estimate record reflects that a follow-up draft was created.\n\n### Setup steps\n\n- Configure the schedule trigger with the desired daily run time.\n- Connect Google Sheets credentials and select the spreadsheet/range containing the estimate pipeline.\n- Set the staleThresholdDays, businessName, senderName, and emailSignature values in the configuration node.\n- Configure the Anthropic API request with a valid API key and desired Claude model.\n- Connect Gmail credentials and verify the draft creation fields map to the parsed AI output.\n- Ensure the final Google Sheets update targets the correct row and status columns.\n\n### Customization\n\nAdjust the stale threshold, email signature, Claude prompt, and sheet status values to match the business process and tone of follow-up messages."
      },
      "typeVersion": 1
    },
    {
      "id": "2534d1f1-220c-4a6f-a0ab-105241e5d386",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        192,
        160
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 320,
        "content": "## Schedule and configure\n\nStarts the workflow on a daily schedule, sets follow-up parameters such as stale threshold and sender details, then reads the estimate pipeline from Google Sheets."
      },
      "typeVersion": 1
    },
    {
      "id": "329dd400-283a-48ed-b419-4fc44cec62c9",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        880,
        160
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 320,
        "content": "## Filter and draft follow-up\n\nIdentifies stale estimates, sends the relevant customer and estimate context to Claude, and parses the AI response into a usable follow-up draft merged with the original row data."
      },
      "typeVersion": 1
    },
    {
      "id": "08b4ecb0-75d9-4c88-8a22-a04a1c2433d2",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1568,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 592,
        "content": "## Save and update records\n\nCreates a Gmail draft for the generated follow-up and updates the corresponding estimate status back in Google Sheets."
      },
      "typeVersion": 1
    },
    {
      "id": "a1b2c3d4-0005-4000-8000-000000000006",
      "name": "When Daily at 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        240,
        320
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 9,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "a1b2c3d4-0005-4000-8000-000000000007",
      "name": "Set Follow-up Parameters",
      "type": "n8n-nodes-base.set",
      "position": [
        460,
        320
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "s1-threshold",
              "name": "staleThresholdDays",
              "type": "number",
              "value": 7
            },
            {
              "id": "s2-businessName",
              "name": "businessName",
              "type": "string",
              "value": "REPLACE_WITH_YOUR_BUSINESS_NAME"
            },
            {
              "id": "s3-senderName",
              "name": "senderName",
              "type": "string",
              "value": "REPLACE_WITH_YOUR_NAME"
            },
            {
              "id": "s4-signature",
              "name": "emailSignature",
              "type": "string",
              "value": "Talk soon,\\nREPLACE_WITH_YOUR_NAME\\nREPLACE_WITH_YOUR_BUSINESS_NAME"
            },
            {
              "id": "s5-tone",
              "name": "tone",
              "type": "string",
              "value": "friendly and concise, never pushy, never use slang"
            },
            {
              "id": "s6-today",
              "name": "todayIso",
              "type": "string",
              "value": "={{ $now.toISO() }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "a1b2c3d4-0005-4000-8000-000000000008",
      "name": "Read Estimates from Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        680,
        320
      ],
      "parameters": {
        "options": {},
        "operation": "read",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Estimates"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_YOUR_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "a1b2c3d4-0005-4000-8000-000000000009",
      "name": "Filter Stale Estimate Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        928,
        320
      ],
      "parameters": {
        "jsCode": "// Filter the estimate pipeline to only rows that are stale and need a follow-up\nconst config = $('Set Follow-up Parameters').item.json;\nconst threshold = Number(config.staleThresholdDays) || 7;\nconst now = new Date();\n\nconst items = $input.all();\nconst stale = [];\n\nfor (const item of items) {\n  const row = item.json;\n  const status = (row.status || '').toString().toLowerCase().trim();\n  if (status !== 'sent' && status !== 'open' && status !== 'pending' && status !== '') continue;\n\n  const lastFollowupStr = row.last_followup_date || row.estimate_date || '';\n  if (!lastFollowupStr) continue;\n\n  const lastFollowupDate = new Date(lastFollowupStr);\n  if (isNaN(lastFollowupDate.getTime())) continue;\n\n  const daysSince = Math.floor((now - lastFollowupDate) / (1000 * 60 * 60 * 24));\n  if (daysSince < threshold) continue;\n\n  if (!row.customer_email || !row.customer_name) continue;\n\n  stale.push({\n    json: {\n      ...row,\n      daysSinceLastTouch: daysSince,\n      config\n    }\n  });\n}\n\nreturn stale;"
      },
      "typeVersion": 2
    },
    {
      "id": "a1b2c3d4-0005-4000-8000-000000000010",
      "name": "Post to Claude API",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1152,
        320
      ],
      "parameters": {
        "url": "https://api.anthropic.com/v1/messages",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"model\": \"claude-sonnet-4-6\",\n  \"max_tokens\": 700,\n  \"messages\": [\n    {\n      \"role\": \"user\",\n      \"content\": {{ JSON.stringify(\"You write personalized re-engagement emails for unsold business estimates. The customer received an estimate, never replied or accepted, and the operator wants to re-engage without being pushy. Reply with valid JSON only, no markdown fences, no commentary. Use this exact schema:\\n\\n{\\n  \\\"subject\\\": \\\"<email subject line, under 60 characters, no exclamation marks>\\\",\\n  \\\"body\\\": \\\"<plain text email body, under 120 words, signed with the provided signature>\\\"\\n}\\n\\nRules:\\n- Reference the customer by first name only\\n- Reference the specific service they requested\\n- Reference the estimate amount only if it adds value\\n- Acknowledge time has passed without being apologetic or hand-wringing\\n- Offer a clear, low-friction next step (a quick call, a follow-up question, a small concession)\\n- Tone: \" + $json.config.tone + \"\\n- Sign with: \" + $json.config.emailSignature + \"\\n- NEVER use em dashes or double hyphens; use commas or periods instead\\n\\nCUSTOMER DATA:\\nName: \" + $json.customer_name + \"\\nService requested: \" + $json.service_requested + \"\\nEstimate amount: \" + $json.estimate_amount + \"\\nDays since last touch: \" + $json.daysSinceLastTouch + \"\\nNotes from operator: \" + ($json.notes || \"\") + \"\\nBusiness name: \" + $json.config.businessName) }}\n    }\n  ]\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "a1b2c3d4-0005-4000-8000-000000000011",
      "name": "Parse Claude Response",
      "type": "n8n-nodes-base.code",
      "position": [
        1376,
        320
      ],
      "parameters": {
        "jsCode": "// Parse Claude's JSON response and merge with customer data for downstream nodes\nconst customer = $('Filter Stale Estimate Rows').item.json;\nconst raw = $json.content?.[0]?.text || '{}';\n\nlet parsed = {\n  subject: 'Following up on your estimate',\n  body: 'AI generation failed; please write a manual follow-up.'\n};\n\ntry {\n  const cleaned = raw.replace(/^```(?:json)?\\s*/i, '').replace(/\\s*```\\s*$/i, '').trim();\n  parsed = JSON.parse(cleaned);\n} catch (e) {\n  parsed.body = 'AI returned malformed JSON: ' + raw.slice(0, 200);\n}\n\nreturn [{\n  json: {\n    customer_email: customer.customer_email,\n    customer_name: customer.customer_name,\n    service_requested: customer.service_requested,\n    estimate_amount: customer.estimate_amount,\n    daysSinceLastTouch: customer.daysSinceLastTouch,\n    subject: parsed.subject || 'Following up on your estimate',\n    body: parsed.body || '',\n    followupDate: customer.config.todayIso\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "a1b2c3d4-0005-4000-8000-000000000012",
      "name": "Send Draft via Gmail",
      "type": "n8n-nodes-base.gmail",
      "position": [
        1620,
        208
      ],
      "parameters": {
        "message": "={{ $json.body }}",
        "options": {
          "sendTo": "={{ $json.customer_email }}"
        },
        "subject": "={{ $json.subject }}",
        "resource": "draft",
        "emailType": "text"
      },
      "typeVersion": 2.1
    },
    {
      "id": "a1b2c3d4-0005-4000-8000-000000000013",
      "name": "Update Status in Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1620,
        432
      ],
      "parameters": {
        "columns": {
          "value": {
            "status": "followup_drafted",
            "customer_email": "={{ $json.customer_email }}",
            "last_followup_date": "={{ $json.followupDate }}"
          },
          "schema": [
            {
              "id": "customer_email",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "customer_email",
              "defaultMatch": true,
              "canBeUsedToMatch": true
            },
            {
              "id": "last_followup_date",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "last_followup_date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "customer_email"
          ]
        },
        "options": {},
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Estimates"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_YOUR_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "When Daily at 9am": {
      "main": [
        [
          {
            "node": "Set Follow-up Parameters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post to Claude API": {
      "main": [
        [
          {
            "node": "Parse Claude Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Claude Response": {
      "main": [
        [
          {
            "node": "Send Draft via Gmail",
            "type": "main",
            "index": 0
          },
          {
            "node": "Update Status in Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Follow-up Parameters": {
      "main": [
        [
          {
            "node": "Read Estimates from Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Stale Estimate Rows": {
      "main": [
        [
          {
            "node": "Post to Claude API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Estimates from Sheets": {
      "main": [
        [
          {
            "node": "Filter Stale Estimate Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}