Cal.com->Discovery Research->CRM Entry->Showup Email
Shared 11/14/2025
12 views
Visual Workflow
JSON Code
{
"id": "sD56S5pnfFNgsXi1",
"meta": {
"instanceId": "99a33e93add361efeb775edd835a69b346a8a8fe75d5581a689090540b0bca41",
"templateCredsSetupCompleted": true
},
"name": "Cal.com->Discovery Research->CRM Entry->Showup Email",
"tags": [],
"nodes": [
{
"id": "284055ac-05b3-4d0c-b813-950226b122fa",
"name": "Gmail Trigger",
"type": "n8n-nodes-base.gmailTrigger",
"position": [
128,
-176
],
"parameters": {
"simple": false,
"filters": {
"q": "join me for a strategy session"
},
"options": {},
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
}
},
"credentials": {
"gmailOAuth2": {
"id": "ttP4XdXatEVuYNhV",
"name": "Gmail account 2"
}
},
"typeVersion": 1.2
},
{
"id": "c083e083-1404-4745-94b9-82f0c7d32366",
"name": "Switch",
"type": "n8n-nodes-base.switch",
"position": [
368,
-176
],
"parameters": {
"rules": {
"values": [
{
"outputKey": "Main Qualification link",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "17e5a858-acfe-4dc3-bcb9-5721d7a7131c",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{$json.text.toLowerCase().includes('join me for a strategy session')}}",
"rightValue": "=Join me for a strategy session"
}
]
},
"renameOutput": true
},
{
"outputKey": "Manual Fwd",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "4774f0f1-8d9b-44b4-ae02-88407d52d5cc",
"operator": {
"type": "string",
"operation": "contains"
},
"leftValue": "={{ $json.headers.subject }}",
"rightValue": "Fwd"
}
]
},
"renameOutput": true
}
]
},
"options": {}
},
"typeVersion": 3.2
},
{
"id": "03847dda-d4b0-45ea-a23d-dbc184fadc2f",
"name": "Parse Email",
"type": "n8n-nodes-base.code",
"position": [
688,
-160
],
"parameters": {
"jsCode": "// n8n Code node: Parse Cal.com booking (forwarded or direct) from HTML + plain text\n// Input: $input.first().json with fields: textAsHtml, html, text, subject\n// Output: one item with fields described in the prompt\n\nconst src = $input.first().json || {};\nconst html = String(src.textAsHtml || src.html || \"\");\nconst text = String(src.text || \"\");\n\n// ---------- helpers ----------\nconst stripTags = (s) => s.replace(/<[^>]+>/g, \" \").replace(/\\s+/g, \" \").trim();\n\nconst firstUrl = (s) => {\n if (!s) return null;\n const m = s.match(/https?:\\/\\/\\S+/i);\n return m ? m[0].replace(/[)>.,;]$/, \"\") : null;\n};\n\nconst normalizeUrlOrDomain = (s) => {\n if (!s) return null;\n s = s.trim().replace(/^<|>$/g, \"\");\n // href=\"...\":\n const href = s.match(/href=[\"'](https?:\\/\\/[^\"']+)[\"']/i);\n if (href) s = href[1];\n\n // If already a URL, sanitize\n if (/^https?:\\/\\//i.test(s)) {\n try {\n const u = new URL(s);\n u.protocol = \"https:\";\n u.hash = \"\";\n u.search = \"\";\n return u.toString().replace(/\\/$/, \"\");\n } catch { /* ignore */ }\n }\n\n // If bare domain like \"example.com\"\n const dom = s.replace(/^https?:\\/\\//i, \"\")\n .replace(/^www\\./i, \"\")\n .replace(/\\/.*$/, \"\")\n .trim();\n if (/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9-]+)+$/i.test(dom)) {\n return \"https://\" + dom.toLowerCase();\n }\n return null;\n};\n\n// find the value in the next <p> after a <p>Label</p>\nfunction nextPAfterLabelHtml(html, labelText) {\n const re = new RegExp(\n `<p[^>]*>\\\\s*${labelText.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}\\\\s*<\\\\/p>\\\\s*<p[^>]*>([\\\\s\\\\S]*?)<\\\\/p>`,\n \"i\"\n );\n const m = html.match(re);\n return m ? stripTags(m[1]).trim() : null;\n}\n\n// find first href after the label block\nfunction hrefNearLabel(html, labelText) {\n const re = new RegExp(\n `<p[^>]*>\\\\s*${labelText.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}\\\\s*<\\\\/p>[\\\\s\\\\S]*?<a[^>]+href=[\"']([^\"']+)[\"']`,\n \"i\"\n );\n const m = html.match(re);\n return m ? m[1].trim() : null;\n}\n\n// find text value: line immediately after a line matching the label\nfunction nextLineAfterLabelText(txt, labelRe) {\n const lines = txt.split(/\\r?\\n/).map(l => l.trim());\n for (let i = 0; i < lines.length; i++) {\n if (labelRe.test(lines[i])) {\n // return next non-empty line\n for (let j = i + 1; j < lines.length; j++) {\n if (lines[j]) return lines[j];\n }\n break;\n }\n }\n return null;\n}\n\n// ---------- WHEN + TZ ----------\nlet whenText =\n nextPAfterLabelHtml(html, \"When\") ||\n nextLineAfterLabelText(text, /^When\\b/i) ||\n null;\n\nlet tzLabel = null, dateText = null, startTimeText = null, endTimeText = null;\nif (whenText) {\n const tz = whenText.match(/\\(([^)]+)\\)\\s*$/);\n if (tz) tzLabel = tz[1].trim();\n\n const parts = whenText.split(\"|\");\n if (parts.length >= 2) {\n dateText = parts[0].trim();\n const right = parts.slice(1).join(\"|\").trim();\n const t = right.match(/(\\d{1,2}:\\d{2}\\s*(?:am|pm))\\s*-\\s*(\\d{1,2}:\\d{2}\\s*(?:am|pm))/i);\n if (t) { startTimeText = t[1].trim(); endTimeText = t[2].trim(); }\n } else {\n const dt = whenText.match(/^(.+?)\\s+(\\d{1,2}:\\d{2}\\s*(?:am|pm))\\s*-\\s*(\\d{1,2}:\\d{2}\\s*(?:am|pm))/i);\n if (dt) { dateText = dt[1].trim(); startTimeText = dt[2].trim(); endTimeText = dt[3].trim(); }\n }\n}\n\n// ---------- WHERE (meeting URL) ----------\nlet where_url = null;\n// HTML pattern\nlet mWhere = html.match(/Meeting URL:\\s*<a[^>]+href=[\"']([^\"']+)[\"']/i);\nif (!mWhere) {\n mWhere = html.match(/<p[^>]*>\\s*Where\\s*<\\/p>[\\s\\S]*?<a[^>]+href=[\"']([^\"']+)[\"']/i);\n}\nif (mWhere) where_url = mWhere[1].trim();\n// TEXT fallback\nif (!where_url) {\n const tLine = nextLineAfterLabelText(text, /^Where\\b/i);\n const u = firstUrl((tLine || \"\") + \"\\n\" + text); // search nearby / whole text\n if (u) where_url = u;\n}\n\n// ---------- WHO (guest) ----------\nlet guestName = null, guestEmail = null;\n// HTML\n{\n const m = html.match(/>\\s*([^<]*?)\\s*-\\s*Guest\\b[\\s\\S]*?mailto:([A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,})/i);\n if (m) { guestName = stripTags(m[1]).trim() || null; guestEmail = m[2].trim().toLowerCase(); }\n}\n// TEXT fallback\nif (!guestEmail) {\n const m = text.match(/-\\s*Guest\\s+([A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,})/i);\n if (m) guestEmail = m[1].toLowerCase();\n}\nif (!guestName && guestEmail) {\n // try to grab the token before \"- Guest\"\n const m = text.match(/([^\\n]+?)\\s*-\\s*Guest\\s+[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i);\n if (m) guestName = m[1].trim();\n}\n\n// ---------- Company website (PRIORITY: user's typed value) ----------\nlet companyWebsite = null;\n// HTML: value often in the next <p> or as <a>\ncompanyWebsite =\n normalizeUrlOrDomain(hrefNearLabel(html, \"Company website\\\\s*\\\\(required if you have one\\\\)\\\\*?\") ||\n nextPAfterLabelHtml(html, \"Company website\\\\s*\\\\(required if you have one\\\\)\\\\*?\"));\n// TEXT fallback: next non-empty line after the label\nif (!companyWebsite) {\n const typed = nextLineAfterLabelText(text, /^Company website\\b/i);\n companyWebsite = normalizeUrlOrDomain(typed);\n}\n\n// If still missing: derive from guest email domain if not generic\nif (!companyWebsite && guestEmail) {\n const domain = guestEmail.split(\"@\")[1] || \"\";\n const generic = /^(gmail\\.com|yahoo\\.com|outlook\\.com|hotmail\\.com|live\\.com|msn\\.com|me\\.com|icloud\\.com|proton\\.me|protonmail\\.com|aol\\.com|gmx\\.com|yandex\\.com|zoho\\.com|mail\\.com)$/i;\n if (domain && !generic.test(domain)) {\n companyWebsite = \"https://\" + domain.toLowerCase();\n }\n}\n\n// ---------- Pricing answer (single, clean sentence) ----------\nlet pricingAnswer = null;\n// HTML (label paragraph then value paragraph)\n{\n const m = html.match(/pricing ranges\\?\\s*<\\/p>\\s*<p[^>]*>([\\s\\S]*?)<\\/p>/i);\n if (m) pricingAnswer = stripTags(m[1]).trim();\n}\n// TEXT fallback: the next non-empty line after the long pricing question block\nif (!pricingAnswer) {\n const lines = text.split(/\\r?\\n/).map(l => l.trim());\n for (let i = 0; i < lines.length; i++) {\n if (/pricing ranges\\?\\s*$/i.test(lines[i])) {\n for (let j = i + 1; j < lines.length; j++) {\n if (lines[j]) { pricingAnswer = lines[j]; break; }\n }\n break;\n }\n }\n}\nif (pricingAnswer) {\n // ensure only the first sentence if it accidentally runs on\n const m = pricingAnswer.match(/^(.+?\\.)\\s/);\n pricingAnswer = m ? m[1].trim() : pricingAnswer.trim();\n}\n\n// ---------- Where did you find us? (answer only) ----------\nlet whereDidYouFindUs =\n nextPAfterLabelHtml(html, \"Where did you find us\\\\?\") ||\n nextLineAfterLabelText(text, /^Where did you find us\\?/i) ||\n null;\n\n// ---------- Final note (answer only) ----------\nlet finalNoteAnswer = null;\n// HTML\n{\n const m = html.match(/<p[^>]*>\\s*Final note:\\s*[\\s\\S]*?<\\/p>\\s*<p[^>]*>([\\s\\S]*?)<\\/p>/i);\n if (m) finalNoteAnswer = stripTags(m[1]).trim();\n}\n// TEXT fallback\nif (!finalNoteAnswer) {\n const ln = nextLineAfterLabelText(text, /^Final note:/i);\n if (ln) finalNoteAnswer = ln;\n}\n// Reduce to first short sentence if it looks long\nif (finalNoteAnswer) {\n const m = finalNoteAnswer.match(/^(.+?\\.)\\s/);\n finalNoteAnswer = m ? m[1].trim() : finalNoteAnswer.trim();\n}\n\n// ---------- Company Name ----------\nlet companyName =\n nextPAfterLabelHtml(html, \"Company Name\") ||\n nextLineAfterLabelText(text, /^Company Name$/i) ||\n null;\n\n// ---------- Who block (for debugging / extra context) ----------\nlet _whoBlock = null;\n{\n const m = html.match(/<p[^>]*>\\s*Who\\s*<\\/p>([\\s\\S]*?)<p[^>]*>\\s*Where\\s*<\\/p>/i);\n _whoBlock = m ? stripTags(m[1]).trim() : null;\n if (!_whoBlock) {\n // text fallback: collect lines between \"Who\" and \"Where\"\n const lines = text.split(/\\r?\\n/);\n const iWho = lines.findIndex(l => /^\\s*Who\\s*$/i.test(l));\n const iWhere = lines.findIndex(l => /^\\s*Where\\s*$/i.test(l));\n if (iWho !== -1 && iWhere !== -1 && iWhere > iWho) {\n _whoBlock = lines.slice(iWho + 1, iWhere).map(s => s.trim()).filter(Boolean).join(\" \");\n }\n }\n}\n\n// ---------- Build ----------\nreturn [{\n json: {\n subject: src.subject || null,\n\n whenText: whenText || null,\n tzLabel: tzLabel || null,\n dateText: dateText || null,\n startTimeText: startTimeText || null,\n endTimeText: endTimeText || null,\n\n when: whenText || null,\n where_url: where_url || null,\n\n guest: {\n name: guestName || null,\n email: guestEmail || null,\n },\n\n // PRIORITY order already applied:\n companyWebsite: companyWebsite || null,\n\n pricingAnswer: pricingAnswer || null,\n whereDidYouFindUs: whereDidYouFindUs || null,\n finalNoteAnswer: finalNoteAnswer || null,\n companyName: companyName || null,\n\n _whoBlock: _whoBlock || null,\n }\n}];\n"
},
"typeVersion": 2
},
{
"id": "684d9f61-ae17-4f3e-b9a2-f4dce7edf0a2",
"name": "Google Sheets",
"type": "n8n-nodes-base.googleSheets",
"position": [
1456,
-160
],
"parameters": {
"columns": {
"value": {
"ID": "={{ Math.floor(Math.random() * 9000000) + 1000000 }}",
"Heat": "={{ $json.motivationLevel ?? ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Heat\"] ?? \"\") : \"\") }}",
"Name": "={{ $(\"Parse Email\").isExecuted ? ($(\"Parse Email\").item.json.guest?.name ?? \"\") : ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Full Name\"] ?? \"\") : \"\") }}",
"Email": "={{ $(\"Parse Email\").isExecuted ? ($(\"Parse Email\").item.json.guest?.email ?? \"\") : ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json.Email ?? \"\") : \"\") }}",
"Niche": "={{ $json.businessType ?? ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Niche\"] ?? \"\") : \"\") }}",
"Notes": "={{ $json.notes ?? \"\" }}",
"Stage": "={{ $json.leadStage ?? ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Stage\"] ?? \"\") : \"\") }}",
"Wants": "={{ $json.wants ?? ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Wants\"] ?? \"\") : \"\") }}",
"Source": "={{ $(\"Parse Email\").isExecuted ? ($(\"Parse Email\").item.json.whereDidYouFindUs ?? \"\") : ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Found me on\"] ?? \"\") : \"\") }}",
"Company": "={{ $(\"Parse Email\").isExecuted ? ($(\"Parse Email\").item.json.companyName ?? \"\") : ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Company Name\"] ?? \"\") : \"\") }}",
"Revenue": "={{ $json.company?.size?.revenue_exact || $json.company?.size?.revenue_range || ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Revenue\"] ?? \"\") : \"\") }}",
"Website": "={{ $(\"Parse Email\").isExecuted ? ($(\"Parse Email\").item.json.companyWebsite ?? \"\") : ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json.Website ?? \"\") : \"\") }}",
"Timezone": "={{ $(\"Parse Email\").isExecuted ? ($(\"Parse Email\").item.json.tzLabel ?? \"\") : ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Timezone\"] ?? \"\") : \"\") }}",
"Employees": "={{ $json.company?.size?.employees_exact || $json.company?.size?.employees_range || ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Employees\"] ?? \"\") : \"\") }}",
"Date Acquired": "={{ $now }}",
"Business Model": "={{ $json.company?.businessModel ?? ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Business Model\"] ?? \"\") : \"\") }}",
"Decision Maker?": "={{ $json.isDecisionMaker ?? ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Decision Maker?\"] ?? \"\") : \"\") }}",
"Appointment Time/Date": "={{ $(\"Parse Email\").isExecuted ? ($(\"Parse Email\").item.json.whenText ?? \"\") : ($(\"On form submission\").isExecuted ? ($(\"On form submission\").item.json[\"Appointment Time\"] ?? \"\") : \"\") }}"
},
"schema": [
{
"id": "ID",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "ID",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Date Acquired",
"type": "string",
"display": true,
"required": false,
"displayName": "Date Acquired",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Appointment Time/Date",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Appointment Time/Date",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Timezone",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Timezone",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Name",
"type": "string",
"display": true,
"required": false,
"displayName": "Name",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Email",
"type": "string",
"display": true,
"required": false,
"displayName": "Email",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Whatsapp",
"type": "string",
"display": true,
"required": false,
"displayName": "Whatsapp",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Heat",
"type": "string",
"display": true,
"required": false,
"displayName": "Heat",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Decision Maker?",
"type": "string",
"display": true,
"required": false,
"displayName": "Decision Maker?",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Company",
"type": "string",
"display": true,
"required": false,
"displayName": "Company",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Niche",
"type": "string",
"display": true,
"required": false,
"displayName": "Niche",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Website",
"type": "string",
"display": true,
"required": false,
"displayName": "Website",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Wants",
"type": "string",
"display": true,
"required": false,
"displayName": "Wants",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Source",
"type": "string",
"display": true,
"required": false,
"displayName": "Source",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Stage",
"type": "string",
"display": true,
"required": false,
"displayName": "Stage",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "To do",
"type": "string",
"display": true,
"required": false,
"displayName": "To do",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Value",
"type": "string",
"display": true,
"required": false,
"displayName": "Value",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Notes",
"type": "string",
"display": true,
"required": false,
"displayName": "Notes",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Employees",
"type": "string",
"display": true,
"required": false,
"displayName": "Employees",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Revenue",
"type": "string",
"display": true,
"required": false,
"displayName": "Revenue",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Business Model",
"type": "string",
"display": true,
"required": false,
"displayName": "Business Model",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [
"ID"
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "list",
"value": "gid=0",
"cachedResultUrl": "REDACTED",
"cachedResultName": "Leads and Clients"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "REDACTED",
"cachedResultUrl": "REDACTED",
"cachedResultName": "Novasoft CRM"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"id": "WhN0yjeaicfIEgig",
"name": "Google Sheets WORKING"
}
},
"typeVersion": 4.6
},
{
"id": "2afe95d6-d10e-4b8e-b348-5221003bf658",
"name": "AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"position": [
928,
-160
],
"parameters": {
"text": "=Guest: {{ $(\"Parse Email\").item.json.guest?.name ?? '' }} {{ $(\"Parse Email\").item.json.guest?.email ?? '' }}\nCompany Name: {{ $(\"Parse Email\").item.json.companyName ?? $json['Company Name'] ?? '' }}\n\nCompany website:\n{{ $(\"Parse Email\").item.json.companyWebsite ?? $json.companyWebsite ?? $json.Website ?? 'unknown'}}\n\nSubject:\n{{ $(\"Gmail Trigger\").first().json.subject ?? '' }}\n\nBooking email (plain text):\n{{ $if($(\"Gmail Trigger\").isExecuted, $(\"Gmail Trigger\").first().json.text, null) }}\n\nIf no Email - lead info:\nFull Name: {{ $json['Full Name'] ?? ''}}\nWants: {{ $json.Wants ?? ''}}\nCompany Name: {{ $json['Company Name'] ?? ''}}\nFound me on: {{ $json['Found me on'] ?? '' }}\nEmail: {{ $json.Email ?? '' }}\n\nWhen calling the “Web research via Tavily” tool, set the tool input key \"query\" to the exact search text you want (e.g., the company domain or name).\n",
"options": {
"systemMessage": "Your first character must be { and your last character must be }. Do not output any text before or after the JSON.\n\nYou receive a call-booking email plus (optionally) a company website/domain. Or just Lead information. Extract fields using the schema. If a domain is present, research the company and estimate employees and revenue with sources.\n\nIMPORTANT BEHAVIOUR\n\n- If \"Company website\" (or domain) is present in the inputs: use that as your primary research key.\n- If there is NO website/domain:\n 1) Try the company name from the booking/form.\n 2) If no company name, try other identifiers from the email/form: guest name, guest email (including the domain part after \"@\"), the call description, niche/industry, subject line.\n 3) Use Tavily for up to 3–5 targeted queries until you either (a) find the company, or (b) conclude there isn’t enough info. If not enough info: DO NOT invent. Leave unknown fields as null and finish.\n\nValidate the website: If the provided domain does not match the lead’s company name or the guest email’s base domain, or it looks like the organizer’s signature or a meeting link (cal.com, meet.google.com, zoom, etc.), treat it as suspect → do a quick Tavily check and replace it with the authoritative company domain if found.\n\nOverwrite on stronger evidence: If research finds a more authoritative company domain (matches the brand on the site + LinkedIn company page), use that in company.website even if an input domain was given.\n\n- Preferred sources/signals (in order): The company’s own site; LinkedIn company page (headcount); Crunchbase/PitchBook; official filings; legitimate news. Avoid random directories unless nothing else exists.\n- If you can’t be reasonably confident, keep confidence low and explain briefly in \"estimation.method\"/\"signals\".\n\nTools\n\nUse “Web research via Tavily” with 3–5 targeted queries:\n- “<domain> site:linkedin.com/company”\n- “<brand> LinkedIn”\n- “<brand> Crunchbase OR PitchBook”\n- “<brand> revenue OR turnover OR filings”\n- “<brand> careers OR jobs”\n- If no company name: use the email domain after \"@\" (if present), or \"<person name> <city|industry>\" from any clues in the message.\n\nAdd any page you relied on to \"sources\" (full URLs).\n\nEstimation heuristics (compact)\n\nEmployees: prefer LinkedIn headcount/range (cross-check About/Careers).\n\nRevenue: if employees known, estimate using benchmarks (override with filings):\n- SaaS/product: $120k–$220k per employee\n- Agency/services: $90k–$160k per employee\n- E-com/DTC: $80k–$140k per employee\n\nConfidence: high (recent filing or 2+ primary sources agree), medium (recent LinkedIn + 1 signal), low (single/old datapoint).\n\nOutput rules (must follow exactly)\n\nFinal message MUST be a single JSON object only (no preface, no tool logs, no markdown).\n\nEvery key from the schema must exist. If unknown, use null (not \"unknown\").\n\nAllowed enums only:\n\"motivationLevel\": \"cold\" | \"warm\" | \"hot\" | null\n\"isDecisionMaker\": \"Yes\" | \"No\" | null\n\"company.estimation.confidence\": \"low\" | \"medium\" | \"high\" | null\n\nAll leaf values are strings or null (no numbers/booleans).\n\n\"sources\" is an array of URL strings. If none, [].\n\nDo NOT include tool calls or analysis in the final message. The final message is the JSON only.\n\nSchema (match exactly)\n\n{\n \"motivationLevel\": \"cold | warm | hot | null\",\n \"isDecisionMaker\": \"Yes | No | null\",\n \"businessType\": \"string|null\",\n \"leadStage\": \"string|null\",\n \"notes\": \"string|null\",\n \"wants\": \"string|null\",\n \"company\": {\n \"website\": \"string|null\",\n \"size\": {\n \"employees_exact\": \"string|null\",\n \"employees_range\": \"string|null\",\n \"revenue_exact\": \"string|null\",\n \"revenue_range\": \"string|null\"\n },\n \"niche\": \"string|null\",\n \"businessModel\": \"string|null\",\n \"estimation\": {\n \"confidence\": \"low | medium | high | null\",\n \"method\": \"string|null\",\n \"signals\": [\"string\"]\n }\n },\n \"sources\": [\"string\"]\n}\n\nTiny example (format only; values will differ)\n\n{\n \"motivationLevel\": \"warm\",\n \"isDecisionMaker\": \"Yes\",\n \"businessType\": \"Creative agency\",\n \"leadStage\": \"awaiting first meeting\",\n \"notes\": \"Confirmed budget tolerance.\",\n \"wants\": \"AI voice callers for outreach\",\n \"company\": {\n \"website\": \"https://example.com\",\n \"size\": {\n \"employees_exact\": null,\n \"employees_range\": \"15–25\",\n \"revenue_exact\": null,\n \"revenue_range\": \"$1.4M–$3.2M\"\n },\n \"niche\": \"Branding, web, content\",\n \"businessModel\": \"Services\",\n \"estimation\": {\n \"confidence\": \"medium\",\n \"method\": \"LinkedIn 19 employees × services benchmark\",\n \"signals\": [\n \"https://www.linkedin.com/company/example/\",\n \"https://example.com/careers\"\n ]\n }\n },\n \"sources\": [\n \"https://www.linkedin.com/company/example/\",\n \"https://example.com/\"\n ]\n}\n\nFinal step\n\nWhen finished researching, output the JSON object only. If any field is unknown, set it to null. If sources are none, set \"sources\": [].",
"returnIntermediateSteps": false
},
"promptType": "define",
"hasOutputParser": true
},
"typeVersion": 2
},
{
"id": "2733558d-a9aa-42f0-afb1-cbb52f9308e2",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"position": [
864,
48
],
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-5",
"cachedResultName": "gpt-5"
},
"options": {
"responseFormat": "json_object"
}
},
"credentials": {
"openAiApi": {
"id": "evhlyY0wWJsxbShn",
"name": "OpenAi Generic"
}
},
"typeVersion": 1.2
},
{
"id": "cced142f-0d86-4f36-9bc9-825a33dd6883",
"name": "Web research via Tavily",
"type": "n8n-nodes-base.httpRequestTool",
"position": [
1024,
80
],
"parameters": {
"url": "https://api.tavily.com/search",
"method": "POST",
"options": {},
"jsonBody": "={\n \"query\": \"{{ $fromAI('query', { description: 'What to research (company, niche, size, etc.)' }) }}\",\n \"include_answer\": true,\n \"include_raw_content\": false,\n \"max_results\": 6,\n \"search_depth\": \"basic\",\n \"days\": 365,\n \"topic\": \"general\",\n \"use_cached\": true,\n \"auto_parameters\": true\n}\n",
"sendBody": true,
"sendHeaders": true,
"specifyBody": "json",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"credentials": {
"httpBearerAuth": {
"id": "dlEBofyHrMFJJW3G",
"name": "Tavily (Bearer)"
}
},
"typeVersion": 4.2
},
{
"id": "62cd3f15-c250-4711-a85c-6c25561e0296",
"name": "Notion",
"type": "n8n-nodes-base.notion",
"position": [
2704,
-160
],
"parameters": {
"title": "={{ $('Google Sheets').item.json.Name }} — {{ $('Google Sheets').item.json.Company }}",
"options": {},
"resource": "databasePage",
"databaseId": {
"__rl": true,
"mode": "url",
"value": "REDACTED"
}
},
"credentials": {
"notionApi": {
"id": "fk7MfF5JZSaZX0Zo",
"name": "Notion account"
}
},
"typeVersion": 2.2
},
{
"id": "d3673bc7-a41f-4fb7-826d-387a1336a93d",
"name": "OpenAI Chat Model1",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"position": [
1744,
48
],
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-5",
"cachedResultName": "gpt-5"
},
"options": {}
},
"credentials": {
"openAiApi": {
"id": "evhlyY0wWJsxbShn",
"name": "OpenAi Generic"
}
},
"typeVersion": 1.2
},
{
"id": "f76120d2-4cf2-4ef1-8e78-83a9a82a5e7a",
"name": "Web research via Tavily1",
"type": "n8n-nodes-base.httpRequestTool",
"position": [
1888,
80
],
"parameters": {
"url": "https://api.tavily.com/search",
"method": "POST",
"options": {},
"jsonBody": "={\n \"query\": {{ JSON.stringify($json.seed_site || $json.Company || '') }},\n \"search_depth\": \"basic\",\n \"max_results\": 6,\n \"include_answer\": true,\n \"include_raw_content\": false,\n \"days\": 720,\n \"topic\": \"general\",\n \"use_cached\": true,\n \"auto_parameters\": true\n}\n",
"sendBody": true,
"sendHeaders": true,
"specifyBody": "json",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"credentials": {
"httpBearerAuth": {
"id": "dlEBofyHrMFJJW3G",
"name": "Tavily (Bearer)"
}
},
"typeVersion": 4.2
},
{
"id": "c3a2a870-b098-4563-a16b-2f90bf5041a6",
"name": "Structured Output Parser1",
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"position": [
2144,
32
],
"parameters": {
"jsonSchemaExample": "{\n \"title\": \"string\",\n \"reportMarkdown\": \"string\",\n \"sources\": [\"string\"]\n}\n"
},
"typeVersion": 1.2
},
{
"id": "9c0cbc32-3515-4f89-9189-c8258ea89279",
"name": "Discovery Call Research",
"type": "@n8n/n8n-nodes-langchain.agent",
"onError": "continueRegularOutput",
"position": [
1872,
-160
],
"parameters": {
"text": "=Lead context (from CRM):\n- Name: {{$json.Name}}\n- Email: {{$json.Email}}\n- Company: {{$json.Company ?? 'unknown'}}\n- Website: {{$json.Website ?? 'unknown'}}\n- Stage: {{$json.Stage ?? 'unknown'}}\n- Wants: {{$json.Wants || $('On form submission').first().json.Wants}}\n- Employees: {{ $json.Employees ?? \"Unknown\"}}\n- Business Model: {{ $json['Business Model'] ?? \"Unknown\" }}\n- Estimated Revenue: {{ $json.Revenue ?? \"Unknown\"}}\n\nYou are **Alex Hormozi** (AI sales/marketing automation). Use **Gap Selling** and be punchy, direct.\n\nYou MUST call the tool **“Web research via Tavily1”** to research BOTH:\n- Company (website + brand)\n- Person (name + email → LinkedIn, socials, press, blogs, interviews)\n\nEmulate the structure, tone, and scannability of this example:\n \n`# COMPANY OVERVIEW\nHart Of Texas Realty is a boutique, relationship-driven real estate brokerage in Texas. \nThey help buyers and sellers with residential (and likely some land) transactions. \nTheir positioning is “personalized guidance” through the full buying/selling journey. \nTheir website is property listing focused, not “form heavy”. \nThey likely rely on calls, texts, messages, and personal back-and-forth to move deals forward.\n\nBecause their brand is built on personal service, their biggest operational weak points will be:\n- delayed responses when humans are busy\n- uneven qualification quality depending on which agent picked up\n- inconsistent follow up cadence\n- overload of repetitive first-touch questions\n\nThis is exactly where AI voice fits: scaling the “first touch” without losing personalized feel.\n`\n\nFormatting to produce (match this exact outline, terse bullets):\n# <Company or Person + Company> — Discovery Research\n## COMPANY OVERVIEW\n- 4–6 bullets\n- include funnel posture bullet\n\n## DISCOVERY CALL SCRIPT\n- 5–8 bullets\n\n## QUESTIONS (GAP SELLING)\n### CURRENT SITUATION\n- bullets\n### DREAM OUTCOME\n- bullets\n### THE GAP\n- bullets\n### CONSTRAINTS / RISKS\n- bullets\n### ROI / METRICS\n- bullets\n\n## SOURCES\n- one URL per line (no commentary)\n\nReturn ONLY JSON:\n{ \"title\": \"string\", \"reportMarkdown\": \"string\", \"sources\": [\"string\"] }\nNo code fences.\n\nRun these exact seeds first, then expand if needed:\n- {{$json.seed_site}}\n- {{$json.seed_linkedin_company}}\n- {{$json.seed_about}}\n- {{$json.seed_pricing}}\n- {{$json.seed_careers}}\n- {{$json.seed_linkedin_person}}\n{{ $json.seed_email_domain ? '- ' + $json.seed_email_domain : '' }}\n\n",
"options": {
"maxIterations": 8,
"systemMessage": "=Act as **Alex Hormozi**, owner/operator of an AI sales & marketing automation agency.\nUse **Gap Selling** methodology. Your voice is direct, punchy, practical. \nOutput must be short, scannable, field-tested bullets — no fluff, no filler.\n\n**Research rules**\n- You MUST call the tool **“Web research via Tavily1”** multiple times:\n - Query the company’s website + brand (site: + brand, product pages, pricing, careers).\n - Query the person with full name + email (LinkedIn, Twitter/X, YouTube, podcasts, press, blogs).\n - Query intent signals (hiring, new launches, funding, customer reviews).\n- Prefer primary sources (official site, LinkedIn) and show only real URLs in `sources`.\n- If something can’t be verified, say “Unknown” briefly and move on.\n\n**Formatting rules**\n- Return ONLY JSON with keys: `title`, `reportMarkdown`, `sources`. No code fences.\n- `reportMarkdown` must follow EXACTLY this section order and style:\n # <Company or Person + Company> — Discovery Research\n ## COMPANY OVERVIEW\n - 4–6 bullets. What they do, ICP, positioning, revenue/size signals if findable.\n - 1 bullet on the website’s funnel posture (e.g., form-heavy vs content-led vs listing-led).\n\n ## DISCOVERY CALL SCRIPT\n - Ultra-brief steps (5–8 bullets). Written to read aloud.\n\n## QUESTIONS (GAP SELLING)\n### CURRENT SITUATION\n- 6–10 **questions** (facts, workflow, tooling, volume, response times)\n\n### DREAM OUTCOME\n- 4–6 **questions** that ask them to define success in measurable terms (6–12 months)\n - Examples: “What would ‘great’ look like in 90–180 days?” “How many kept demos/month is success?”\n\n### THE GAP\n- 6–10 **questions** that surface missed revenue, failure modes, and bottlenecks\n - Examples: “Where are leads falling out today?” “Which objections stall the most deals?”\n\n### CONSTRAINTS / RISKS\n- 3–6 **questions** (compliance, languages, integrations, data quality)\n\n### ROI / METRICS\n- 3–6 **questions** that quantify value (value/appointment, closes/wk, CAC/LTV impact)\n - Examples: “What’s your target cost per kept demo?” “What CAC/LTV change would make this a yes?”\n\n ## SOURCES\n - One FULL URL per line (no commentary)\n\n**Quality bar**\n- Be concrete. Replace generic phrasing with specifics from research.\n- If weak data, say “Unknown” — never invent.\n\nYou MUST call Web research via Tavily1 at least 3–5 times with distinct, high-intent queries (company site + pricing + careers; person’s LinkedIn/Twitter/interviews).\nFor each key page you cite, call Tavily Extract to pull the full text before writing bullets. If you skip Extract on a key page, revise and try again.\n\nIdentify a canonical domain first (match website host to the email domain when possible, or confirm via the LinkedIn company page). Discard results that don’t match the brand/domain. If uncertain, say Unknown and continue.\n\nMake ≤ 6 tool calls total. If you have 3–5 solid primary sources (official site, LinkedIn, pricing/careers), stop and finish. If the schema can’t be met perfectly, return your best attempt once.\n\nIf you hit your tool/iteration limit or can’t verify enough, return your best attempt once in the required JSON schema; never return empty output.",
"returnIntermediateSteps": true
},
"promptType": "define",
"hasOutputParser": true
},
"typeVersion": 2
},
{
"id": "6ea1b94c-0494-49c9-b765-bddc57962bbd",
"name": "Notion Markdown",
"type": "n8n-nodes-notion-markdown.notionMarkdown",
"position": [
2480,
-160
],
"parameters": {
"inputMarkdown": "={{$json.md}}"
},
"typeVersion": 1
},
{
"id": "25ccde07-4005-45ac-abcf-4989bddc3119",
"name": "Tavily Extract",
"type": "n8n-nodes-base.httpRequestTool",
"position": [
2032,
80
],
"parameters": {
"url": "https://api.tavily.com/extract",
"method": "POST",
"options": {},
"sendBody": true,
"sendHeaders": true,
"authentication": "genericCredentialType",
"bodyParameters": {
"parameters": [
{
"name": "url",
"value": "={{$fromAI('url') || $fromAI('input') || $fromAI('page') || $fromAI('link') || $json.Website || ''}}"
}
]
},
"genericAuthType": "httpBearerAuth",
"headerParameters": {
"parameters": [
{
"name": "Content-type",
"value": "application/json"
}
]
}
},
"credentials": {
"httpBearerAuth": {
"id": "dlEBofyHrMFJJW3G",
"name": "Tavily (Bearer)"
}
},
"typeVersion": 4.3
},
{
"id": "3b647569-1188-4516-b795-b8c91694c8db",
"name": "write search queries",
"type": "n8n-nodes-base.code",
"position": [
1664,
-160
],
"parameters": {
"jsCode": "const company = ($json.Company || '').trim();\nconst name = ($json.Name || '').trim();\nconst email = ($json.Email || '').trim();\nconst website = ($json.Website || '').trim();\n\nlet host = null, domain = null;\ntry { if (website) host = new URL(website).hostname; } catch {}\nif (email && email.includes('@')) domain = email.split('@').pop().toLowerCase();\n\n// Normalize company (strip Inc/LLC/etc.)\nconst normalized = company.replace(/\\b(inc\\.?|llc|ltd|co\\.?|corp\\.?|limited)\\b/gi,'').trim();\n\nreturn [{\n json: {\n ...$json,\n // Canonical site or a strong “official site” query\n seed_site: host ? `site:${host}` : `\"${normalized}\" (official OR \"official site\" OR homepage) -facebook -instagram -youtube`,\n // Pinpoint company profile pages\n seed_linkedin_company: `\"${normalized}\" site:linkedin.com/company`,\n seed_crunchbase: `\"${normalized}\" site:crunchbase.com/organization`,\n seed_about: host ? `site:${host} (\"About us\" OR \"Company\" OR \"Team\")` : `\"${normalized}\" (\"About us\" OR \"Company\")`,\n seed_pricing: host ? `site:${host} (pricing OR plans)` : `\"${normalized}\" (pricing OR plans)`,\n seed_careers: host ? `site:${host} (careers OR jobs)` : `\"${normalized}\" (careers OR jobs)`,\n // Person disambiguation using company + email domain\n seed_linkedin_person: `\"${name}\" \"${normalized}\" site:linkedin.com/in`,\n seed_twitter_person: `\"${name}\" \"${normalized}\" site:twitter.com -status -likes`,\n seed_email_domain: domain ? `site:${domain}` : null\n }\n}];\n"
},
"typeVersion": 2
},
{
"id": "51b9eb39-0c76-4ff2-b798-1782f8c77a80",
"name": "Sanitize Output",
"type": "n8n-nodes-base.code",
"position": [
2224,
-160
],
"parameters": {
"jsCode": "// Takes the LLM output (reportMarkdown/sources), cleans it,\n// and forces bullets under certain sections to be QUESTIONS.\n\nconst o = $json.output ?? $json;\nconst mdRaw = o.reportMarkdown ?? o.report_markdown ?? o.markdown ?? o.content ?? '';\nlet md = String(mdRaw).replace(/```/g,'').replace(/\\n{3,}/g,'\\n\\n').trim();\n\nlet sources = [];\nif (Array.isArray(o.sources)) sources = o.sources;\nelse if (typeof o.sources === 'string')\n sources = o.sources.split(/\\r?\\n/).map(s=>s.trim()).filter(Boolean);\n\n// --- transform bullets to questions for specific sections\nconst makeSectionBulletsQuestions = (input, title) => {\n const lines = input.split('\\n');\n let inSection = false;\n\n // helpers: turn a few common statement-starters into questions\n const soften = (txt) => {\n let s = txt;\n\n // Opinionated rewrites for frequent patterns\n s = s.replace(/^-\\s*Targets?:\\s*/i, '- What are your targets — ');\n s = s.replace(/^-\\s*Value per\\s+/i, '- What is the value per ');\n s = s.replace(/^-\\s*CAC impact:?/i, '- What CAC impact do you expect ');\n s = s.replace(/^-\\s*LTV impact:?/i, '- What LTV impact do you expect ');\n s = s.replace(/^-\\s*Time saved:?/i, '- How much time do you intend to save ');\n s = s.replace(/^-\\s*Book\\s+/i, '- How many can you book ');\n s = s.replace(/^-\\s*Sub-?(\\d+)[^\\s]*\\s+/i, '- What service level do you require for ');\n s = s.replace(/^-\\s*2–3x|^-\\s*2-3x/i, '- What lift are you targeting ');\n s = s.replace(/^-\\s*Multilingual/i, '- Which languages must we support ');\n s = s.replace(/^-\\s*Calendar/i, '- How should scheduling be handled ');\n\n // ensure trailing '?'\n if (!/[??!]$/.test(s.trim())) s = s.trim().replace(/[.;:,]+$/,'') + '?';\n return s;\n };\n\n for (let i = 0; i < lines.length; i++) {\n const L = lines[i];\n\n // Enter section on exact H3 match\n if (/^###\\s+/.test(L)) {\n inSection = new RegExp(`^###\\\\s+${title.replace(/[.*+?^${}()|[\\\\]\\\\\\\\]/g, '\\\\$&')}$`).test(L.trim());\n continue;\n }\n // Exit section on next header\n if (inSection && (/^##\\s+/.test(L) || /^###\\s+/.test(L))) {\n inSection = false;\n }\n\n // Transform bullets only inside the section\n if (inSection && /^-\\s+/.test(L.trim())) {\n lines[i] = soften(L);\n }\n }\n return lines.join('\\n');\n};\n\n// Enforce questions for these sections:\n['DREAM OUTCOME', 'THE GAP', 'ROI / METRICS', 'CONSTRAINTS / RISKS'].forEach(h => {\n md = makeSectionBulletsQuestions(md, h);\n});\n\nreturn [{ json: { md, sources } }];\n"
},
"typeVersion": 2
},
{
"id": "c65d3ef8-3dc4-47e5-853f-307bce5106ee",
"name": "Write Blocks to Notion",
"type": "n8n-nodes-base.httpRequest",
"position": [
2928,
-160
],
"parameters": {
"url": "=https://api.notion.com/v1/blocks/{{$node[\"Notion\"].json.id}}/children",
"method": "PATCH",
"options": {},
"sendBody": true,
"sendHeaders": true,
"authentication": "genericCredentialType",
"bodyParameters": {
"parameters": [
{
"name": "children",
"value": "={{$node[\"Notion Markdown\"].json.output}}"
}
]
},
"genericAuthType": "httpBearerAuth",
"headerParameters": {
"parameters": [
{
"name": "Notion-Version",
"value": "2022-06-28"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"credentials": {
"httpBearerAuth": {
"id": "J0ubTW4wkt02jPOP",
"name": "Notion Bearer"
}
},
"typeVersion": 4.3
},
{
"id": "a0eee662-14d3-4b39-98f8-672b32462a5b",
"name": "Wait",
"type": "n8n-nodes-base.wait",
"position": [
2752,
144
],
"webhookId": "REDACTED",
"parameters": {
"amount": 32
},
"typeVersion": 1.1
},
{
"id": "1df3e62e-048f-4fff-902b-84440f44b7ba",
"name": "Send a message",
"type": "n8n-nodes-base.gmail",
"position": [
2928,
144
],
"webhookId": "REDACTED",
"parameters": {
"sendTo": "={{ $('Google Sheets').item.json.Email }}",
"message": "={{ $json.output[0].content[0].text }}",
"options": {
"appendAttribution": false
},
"subject": "Our call",
"emailType": "text"
},
"credentials": {
"gmailOAuth2": {
"id": "ttP4XdXatEVuYNhV",
"name": "Gmail account 2"
}
},
"typeVersion": 2.1
},
{
"id": "08add614-be55-459c-bade-e8e4723422e4",
"name": "write booking received email",
"type": "@n8n/n8n-nodes-langchain.openAi",
"position": [
2448,
144
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "gpt-4o-mini",
"cachedResultName": "GPT-4O-MINI"
},
"options": {},
"responses": {
"values": [
{
"content": "=To: {{ \n (\n $if($('Parse Email').isExecuted && $('Parse Email').item.json.guest.name, $('Parse Email').item.json.guest.name,\n $if($('On form submission').isExecuted && $('On form submission').item.json['Full Name'], $('On form submission').item.json['Full Name'],\n $if($('Google Sheets').isExecuted && $('Google Sheets').item.json.Name, $('Google Sheets').item.json.Name, 'unknown')\n )\n )\n ).toString().trim().split(' ')[0] || 'unknown'\n}}\nThe lead is inquiring about: {{ \n $if($('On form submission').isExecuted && $('On form submission').item.json['Wants'], $('On form submission').item.json['Wants'],\n $if($('Google Sheets').isExecuted && $('Google Sheets').item.json.Wants, $('Google Sheets').item.json.Wants, 'unknown')\n )\n}}\n\nFor a company in niche: {{ \n $if($('On form submission').isExecuted && $('On form submission').item.json['Niche'], $('On form submission').item.json['Niche'],\n $if($('Google Sheets').isExecuted && $('Google Sheets').item.json.Niche, $('Google Sheets').item.json.Niche, 'unknown')\n )\n}}\n\nBooking time: {{ \n $if($('Parse Email').isExecuted && $('Parse Email').item.json.whenText, $('Parse Email').item.json.whenText,\n $if($('On form submission').isExecuted && $('On form submission').item.json['Appointment Time'], $('On form submission').item.json['Appointment Time'], 'unknown')\n )\n}} Bangkok time.\n\nGuest's timezone: {{ \n $if($('Parse Email').isExecuted && $('Parse Email').item.json.tzLabel, $('Parse Email').item.json.tzLabel,\n $if($('On form submission').isExecuted && $('On form submission').item.json['Timezone'], $('On form submission').item.json['Timezone'], 'unknown')\n )\n}}\n"
},
{
"role": "system",
"content": "=You are a busy SDR, replying via email to someone who just booked a sales call, you want them to reply to make sure they aren't gonna no show. You want to write one short personalized comment if the user provides enough information to do so, otherwise just write a generic email like the user suggests. Output ONLY the RAW email text, nothing else.\n\nOnly include the information you have been given by the user, if a field is unknown, exclude any sentence about that information. If you're given a booking time and the guest's timezone, convert the time from Bangkok time to their timezone for the email, and say \"[day] at [xx:xx] your time\". Shorten the day/month (ex Thursday = thurs, November = nov), and do not say the year. If it is within a week of {{ $today }}, do not say the month either. Anything unknown, just skip it in the email (make it more generic, making sure they are gonna reply so we know they're committed to showing up).\n\nEmail Style and example:\n\n\"Hey (first name),\n\nSaw you booked a call for Sat at 10:30am your time, about AI voice callers for your real estate brokerage. I'll have some questions prepared, and likely can do a demo as well. Looking forward to it!\n\nPlease just shoot me a quick reply so I know you're committed to showing up!\n\nThanks,\n\nJess\""
}
]
},
"builtInTools": {}
},
"credentials": {
"openAiApi": {
"id": "evhlyY0wWJsxbShn",
"name": "OpenAi Generic"
}
},
"typeVersion": 2
},
{
"id": "c9bde474-293b-4b7c-a01a-943af4e09fce",
"name": "Sanitize Output1",
"type": "n8n-nodes-base.code",
"position": [
1264,
-160
],
"parameters": {
"jsCode": "// Robust JSON sanitizer for Agent output that may be wrapped (e.g., { output: \"...\" })\n\n// 0) pick the most likely raw JSON-bearing field\nfunction pickRaw(obj) {\n if (typeof obj === 'string') return obj;\n if (obj == null) return '';\n // common wrappers from agents/parsers/tools\n if (typeof obj.output === 'string') return obj.output;\n if (typeof obj.output === 'object' && obj.output !== null) return JSON.stringify(obj.output);\n if (typeof obj.text === 'string') return obj.text;\n if (typeof obj.message === 'string') return obj.message;\n if (typeof obj.data === 'string') return obj.data;\n // last resort: stringify the whole thing\n return JSON.stringify(obj);\n}\n\nlet raw = pickRaw($json);\n\n// 1) strip any leading junk (e.g., \"[undefined]\") until first \"{\"\nconst firstBrace = raw.indexOf('{');\nif (firstBrace === -1) throw new Error('No \"{\" found in model output.');\nraw = raw.slice(firstBrace);\n\n// 2) take from first \"{\" to last \"}\" (biggest JSON block)\nconst lastBrace = raw.lastIndexOf('}');\nif (lastBrace === -1) throw new Error('No closing \"}\" found in model output.');\nlet slice = raw.slice(0, lastBrace + 1);\n\n// 3) parse, with a tiny repair pass\nfunction tryParse(s) {\n try { return JSON.parse(s); } catch { /* noop */ }\n // remove BOM + trailing whitespace\n s = s.replace(/^\\uFEFF/, '').replace(/[^\\S\\r\\n]+$/g, '');\n return JSON.parse(s);\n}\n\nlet data = tryParse(slice);\n\n// 4) if we still got an outer wrapper, dig into .output\nif (data && typeof data === 'object' && !('motivationLevel' in data) && data.output) {\n if (typeof data.output === 'string') {\n data = tryParse(data.output);\n } else if (typeof data.output === 'object') {\n data = data.output;\n }\n}\n\n// 5) schema + merge (same as yours)\nconst coerce = v => (v === null || v === undefined) ? null : String(v);\nconst enumMotivation = new Set(['cold','warm','hot',null]);\nconst enumDecision = new Set(['Yes','No',null]);\nconst enumConf = new Set(['low','medium','high',null]);\n\nconst out = {\n motivationLevel: null,\n isDecisionMaker: null,\n businessType: null,\n leadStage: null,\n notes: null,\n wants: null,\n company: {\n website: null,\n size: {\n employees_exact: null,\n employees_range: null,\n revenue_exact: null,\n revenue_range: null,\n },\n niche: null,\n businessModel: null,\n estimation: {\n confidence: null,\n method: null,\n signals: [],\n }\n },\n sources: []\n};\n\nfunction merge(t, s) {\n if (!s || typeof s !== 'object') return;\n for (const k in t) {\n if (!(k in s) || s[k] === undefined) continue;\n if (t[k] && typeof t[k] === 'object' && !Array.isArray(t[k])) {\n merge(t[k], s[k] || {});\n } else if (Array.isArray(t[k])) {\n t[k] = Array.isArray(s[k]) ? s[k].map(x => coerce(x)).filter(x => x !== null) : [];\n } else {\n t[k] = coerce(s[k]);\n }\n }\n}\n\nmerge(out, data);\n\n// enums\nout.motivationLevel = enumMotivation.has(out.motivationLevel) ? out.motivationLevel : null;\nout.isDecisionMaker = enumDecision.has(out.isDecisionMaker) ? out.isDecisionMaker : null;\nout.company.estimation.confidence = enumConf.has(out.company.estimation.confidence)\n ? out.company.estimation.confidence\n : null;\n\n// arrays\nif (!Array.isArray(out.company.estimation.signals)) out.company.estimation.signals = [];\nif (!Array.isArray(out.sources)) out.sources = [];\n\nreturn [{ json: out }];\n"
},
"typeVersion": 2
},
{
"id": "4996a4f4-51bf-4a00-b51c-cefef2bb0671",
"name": "On form submission",
"type": "n8n-nodes-base.formTrigger",
"position": [
128,
-416
],
"webhookId": "REDACTED",
"parameters": {
"options": {},
"formTitle": "Lead Info",
"formFields": {
"values": [
{
"fieldLabel": "Full Name"
},
{
"fieldLabel": "Email"
},
{
"fieldLabel": "Wants"
},
{
"fieldLabel": "Company Name"
},
{
"fieldLabel": "Website"
},
{
"fieldLabel": "Found me on"
},
{
"fieldLabel": "Timezone"
},
{
"fieldLabel": "Appointment Time"
},
{
"fieldLabel": "Heat"
},
{
"fieldLabel": "Decision Maker?"
},
{
"fieldLabel": "Niche"
},
{
"fieldLabel": "Employees"
},
{
"fieldLabel": "Revenue"
},
{
"fieldLabel": "Business Model"
},
{
"fieldLabel": "Leads/month"
},
{
"fieldLabel": "Stage"
},
{
"fieldLabel": "Notes"
}
]
}
},
"typeVersion": 2.3
}
],
"active": true,
"pinData": {},
"settings": {
"timezone": "Asia/Bangkok",
"callerPolicy": "workflowsFromSameOwner",
"errorWorkflow": "QviMGes8uPJxgS2g",
"availableInMCP": false,
"executionOrder": "v1",
"timeSavedPerExecution": 30
},
"versionId": "5b10381d-aecc-4035-88f9-4a9b0d9bc2bd",
"connections": {
"Wait": {
"main": [
[
{
"node": "Send a message",
"type": "main",
"index": 0
}
]
]
},
"Notion": {
"main": [
[
{
"node": "Write Blocks to Notion",
"type": "main",
"index": 0
}
]
]
},
"Switch": {
"main": [
[
{
"node": "Parse Email",
"type": "main",
"index": 0
}
],
[
{
"node": "Parse Email",
"type": "main",
"index": 0
}
]
]
},
"AI Agent": {
"main": [
[
{
"node": "Sanitize Output1",
"type": "main",
"index": 0
}
]
]
},
"Parse Email": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"Gmail Trigger": {
"main": [
[
{
"node": "Switch",
"type": "main",
"index": 0
}
]
]
},
"Google Sheets": {
"main": [
[
{
"node": "write search queries",
"type": "main",
"index": 0
}
]
]
},
"Tavily Extract": {
"ai_tool": [
[]
]
},
"Notion Markdown": {
"main": [
[
{
"node": "Notion",
"type": "main",
"index": 0
}
]
]
},
"Sanitize Output": {
"main": [
[
{
"node": "Notion Markdown",
"type": "main",
"index": 0
},
{
"node": "write booking received email",
"type": "main",
"index": 0
}
]
]
},
"Sanitize Output1": {
"main": [
[
{
"node": "Google Sheets",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"On form submission": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model1": {
"ai_languageModel": [
[
{
"node": "Discovery Call Research",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"write search queries": {
"main": [
[
{
"node": "Discovery Call Research",
"type": "main",
"index": 0
}
]
]
},
"Discovery Call Research": {
"main": [
[
{
"node": "Sanitize Output",
"type": "main",
"index": 0
}
]
]
},
"Web research via Tavily": {
"ai_tool": [
[
{
"node": "AI Agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"Web research via Tavily1": {
"ai_tool": [
[
{
"node": "Discovery Call Research",
"type": "ai_tool",
"index": 0
}
]
]
},
"Structured Output Parser1": {
"ai_outputParser": [
[
{
"node": "Discovery Call Research",
"type": "ai_outputParser",
"index": 0
}
]
]
},
"write booking received email": {
"main": [
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
}
}
}