This workflow corresponds to n8n.io template #16065 — we link there as the canonical source.
This workflow follows the Gmail → Google Sheets recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs daily to read estimates from Google Sheets, identify stale items that need a follow-up, generate a personalized email draft with Anthropic Claude, save it as a Gmail draft, and update the source sheet with the latest follow-up date and status. Runs daily on a…
Source: https://n8n.io/workflows/16065/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Cold Email Automation - Safe Ramp + AI Personalization + Follow-ups. Uses googleSheets, httpRequest, gmail. Scheduled trigger; 34 nodes.
How it works:
This workflow runs every weekday morning to find HubSpot deals with stale proposal activity, pulls the associated contact email, enforces opt-out and follow-up limits using Google Sheets, sends a valu
How it works time trigger using the cron format, every weekday at 5pm gets CentralStationCRM people updates of today checks for tag "Outreach" if true, sends message on gmail (predefine in node) waits
This n8n workflow template, "Email Outreach Automation," is designed to help you set up an automated email outreach system using tools you might already be familiar with: Google Sheets and Google Docs