A demo lands on the calendar. The rep clicks in cold — no idea who booked, what the company does, or why now. So the first ten minutes go to "So, tell me a bit about yourselves" while the prospect quietly wonders why they're re-explaining their own business to the vendor.
Doing it right means 15–20 minutes of manual research per meeting: the LinkedIn dig, the funding news, the "what changed for them recently" hunt. On a full demo calendar, nobody has that time. So it gets skipped, and the demo pays for it.
This workflow makes prepared the default. The instant a meeting is booked, Twain researches the prospect in about 20 seconds and hands the rep a ready AE brief plus a drafted pre-demo touch — no scrambling, no new dashboard.
The cold-demo tax
Every unprepared demo bleeds value in three places at once:
- The first ten minutes are wasted. Generic discovery you could have done beforehand eats the most valuable part of the call.
- Show and convert rates slip. Prospects who feel researched show up and lean in. Prospects who feel like a number ghost or stall.
- Prep doesn't scale. 15–20 minutes per meeting times a calendar full of demos is hours a week of research nobody has — so it quietly stops happening.
The fix isn't "tell reps to prep harder." It's removing the manual work so prep happens whether or not anyone has the time.
Walk in prepared, every time
The moment a meeting is booked — from Calendly, Google Calendar, Chili Piper, HubSpot Meetings, or anything else — the workflow fires automatically.
Twain researches the prospect and their company in roughly 20 seconds, then writes two things: an AE brief (why-now, talking points, discovery questions) and a drafted pre-demo message. n8n delivers both to where the rep already works — a Slack brief card and a Gmail draft — before the rep has even thought about prepping.
Manual research that used to cost 15–20 minutes per meeting now costs the time it takes to read a Slack card.
What it does for your numbers
This is a GTM lever, not a productivity toy. The payoff lands where revenue teams already measure:
- Better demos. Reps open with "I saw you just expanded into X" instead of "what does your company do?" The call starts where it should — at value.
- Higher show and convert rates. A relevant pre-demo touch keeps the meeting warm between booking and call, and a researched rep closes more of the ones who show.
- Zero pre-call scrambling. No 11:55 panic-Googling before the noon demo. The brief is already waiting.
- Hours back across the team. At ~15–20 minutes saved per demo, a rep running 10 demos a week recovers two to three hours — time that goes back into selling.
- Demand-gen ROI that doesn't leak. You paid to book the meeting. This protects that spend by making sure the meeting actually lands.
What the rep actually gets
Not a "demo booked" ping. A working brief the rep can act on in seconds:
- Why now — the trigger or context that makes this a real opportunity today.
- Talking points — what to lead with for this specific prospect.
- Discovery questions — sharp, account-specific questions instead of the generic script.
- A drafted pre-demo email — written to the prospect, sitting in the rep's Gmail drafts, ready to review and send.
The card carries the person, title, company, meeting time, and the deal when the booking brought one — so the whole picture is in one place, in Slack, the moment the meeting is set.
Works with whatever you book in
Teams book demos in different places, and this template meets you where you already are. Calendly, Google Calendar, Chili Piper, HubSpot Meetings, or a generic webhook for anything else — every source feeds the same path.
Whatever the booking tool, the workflow normalizes it down to the same handful of fields — name, work email, meeting time, LinkedIn if it's there, deal id if it's there — and everything downstream stops caring where the booking came from. No rip-and-replace of your stack to get prepared demos; it bolts onto the booking flow you already run.
It also only fires on demos, not every meeting on a calendar. With Google Calendar you have two easy options: point the trigger at a dedicated Demo calendar so nothing else can set it off —
— or keep using a shared calendar and add a Match Term so only events whose title matches (e.g. demo booked) trigger the workflow.
Either way — dedicated calendar, match term, or the built-in keyword filter downstream — you decide what counts as a demo; everything else is ignored.
A human still approves every send
Nothing goes out on its own. The pre-demo email lands as a draft in the rep's inbox; the brief lands as a card in Slack. The rep reviews, tweaks if needed, and sends — or doesn't.
That's deliberate. AI does the research and the first draft; the rep stays the author and keeps their voice on every touch. For teams wary of automation messaging prospects on their behalf, this is the version that earns trust: all the speed, none of the "an AI emailed my prospect without me" risk.
Import the template
Prefer to import rather than rebuild? Copy the sanitized JSON below — secrets removed, live IDs replaced with placeholders. You attach your own connections and paste your demo campaign_id after import.
{
"name": "Twain — Demo-booking Research & Pre-demo Touch",
"settings": {
"executionOrder": "v1",
"availableInMCP": true,
"binaryMode": "separate"
},
"nodes": [
{
"id": "cb631b80-e444-40c3-b46c-3826de3376cb",
"name": "Generic Booking (Webhook)",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [240, 180],
"parameters": {
"httpMethod": "POST",
"path": "demo-booked",
"options": {}
}
},
{
"id": "2aeb90b4-fe40-49b4-b35d-74873c292a95",
"name": "Dedupe Bookings",
"type": "n8n-nodes-base.removeDuplicates",
"typeVersion": 2,
"position": [1760, 520],
"parameters": {
"operation": "removeItemsSeenInPreviousExecutions",
"dedupeValue": "={{ $json.uid }}",
"options": {
"scope": "node",
"historySize": 100000
}
}
},
{
"id": "0603e3d8-a972-4b8a-ad5b-b62728154424",
"name": "Has usable contact?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [3660, 520],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 3
},
"conditions": [
{
"id": "usable-1",
"leftValue": "={{ Boolean($json.has_work_email) || Boolean($json.has_linkedin_url) }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"looseTypeValidation": true,
"options": {}
}
},
{
"id": "3e043ff3-7e06-40c7-bebc-a32686895d2a",
"name": "Slack: Skipped (cannot research)",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.5,
"position": [4040, 740],
"onError": "continueRegularOutput",
"parameters": {
"authentication": "accessToken",
"select": "channel",
"channelId": {
"__rl": true,
"value": "n8n-workflows",
"mode": "name"
},
"text": "=:no_entry_sign: *Twain — Demo-booking Research & Pre-demo Touch — skipped*\n\n*{{ $('Normalize Booking').item.json.displayName || $('Normalize Booking').item.json.email || 'Unknown invitee' }}*\n:email: {{ $('Normalize Booking').item.json.email || 'No email provided' }}\n\nNo work email, and the LinkedIn lookup didn't return a profile URL for this lead — so there's nothing to research. Enable a LinkedIn Lookup provider (or add a work email / LinkedIn URL upstream) to catch leads like this.\n\n<https://www.twain.ai/w|Open Twain workspace>",
"otherOptions": {
"includeLinkToWorkflow": false,
"unfurl_links": false,
"unfurl_media": false
}
}
},
{
"id": "0aa5c900-bff5-409d-adb5-9d280c2a9d11",
"name": "Twain Generate Contact",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [4040, 520],
"onError": "continueErrorOutput",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000,
"parameters": {
"method": "POST",
"url": "https://public.api.twain.ai/v2/Generate/Contact",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ {\n campaign_id: $('Normalize Booking').item.json.campaign_id,\n contact: {\n ...($('Normalize Booking').item.json.has_work_email ? { work_email: $('Normalize Booking').item.json.email } : {}),\n ...($json.has_linkedin_url ? { linkedin_profile_url: $json.linkedin_url } : {}),\n },\n generate_sequence: true,\n generate_research: true,\n add_contact_to_campaign: true,\n} }}",
"options": {
"timeout": 180000
}
}
},
{
"id": "484e965e-59bf-47f4-92e0-d2773067d258",
"name": "Build Pre-demo Brief",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [4420, 520],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const api = $json;\nconst norm = $(\"Normalize Booking\").item.json;\nconst cfg = $('Demo Settings').first().json;\nconst messages = Array.isArray(api.messages) ? api.messages : [];\n\n// --- Parse configurable step->channel split (1-based indices) ---\nconst parseSteps = (s) =>\n String(s ?? \"\")\n .split(\",\")\n .map((x) => parseInt(String(x).trim(), 10))\n .filter((n) => Number.isInteger(n) && n >= 1);\nlet emailSteps = parseSteps(cfg.email_steps);\nconst linkedinSteps = parseSteps(cfg.linkedin_steps);\n// Fall back to step 1 for email if none configured.\nif (emailSteps.length === 0) emailSteps = [1];\n\nconst stepMsg = (i1) => messages[i1 - 1] || {}; // i1 is 1-based\n\nconst research = api.research || {};\nconst person = research.person || {};\nconst company = research.company || {};\nconst ex = research.expanded_research || {};\nconst warnings = api.warnings || {};\nconst categories = Array.isArray(warnings.categories) ? warnings.categories : [];\nconst esc = (s) =>\n String(s ?? \"\")\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\");\nconst toHtml = (s) => (s ? esc(s).split(\"\\n\").join(\"<br>\") : \"\");\nconst md = (sec) => (sec && typeof sec.markdown === \"string\" ? sec.markdown.trim() : \"\");\n\n// --- Email step(s): Gmail draft uses the FIRST email step ---\nconst primaryEmailIdx = emailSteps[0]; // 1-based\nconst primary = stepMsg(primaryEmailIdx);\nconst subject = String(\n primary.subject_line ||\n api.subject_line ||\n \"Prep for our demo, \" + (person.name || norm.displayName || \"there\"),\n);\nconst firstBody = String(primary.message || \"\");\nconst bodyHtml = toHtml(firstBody);\n\n// Preview of ALL email steps (for Slack), escape-safe single string.\nconst email_preview =\n emailSteps\n .map((i1) => {\n const m = stepMsg(i1);\n const subj = m.subject_line ? \"Subject: \" + m.subject_line + \"\\n\" : \"\";\n return subj + (m.message || \"\");\n })\n .filter(Boolean)\n .join(\"\\n\\n--------\\n\\n\") || \"No email steps.\";\n\n// --- LinkedIn step(s) ---\nconst linkedin_messages = linkedinSteps.map((i1) => ({\n step: i1,\n message: String(stepMsg(i1).message || \"\"),\n}));\nconst has_linkedin_steps = linkedin_messages.length > 0;\nconst linkedin_preview =\n linkedin_messages.map((m) => m.message).join(\"\\n\\n--------\\n\\n\") ||\n \"No LinkedIn steps.\";\n\n// --- Full sequence preview (all steps) ---\nconst sequencePreview = messages\n .map((m, i) => {\n const subj = m.subject_line ? \"Subject: \" + m.subject_line + \"\\n\" : \"\";\n return subj + (m.message || \"\");\n })\n .join(\"\\n\\n--------\\n\\n\");\n\n// --- AE brief (structured research) ---\nconst why_now = md(ex.why_now);\nconst talking_points = md(ex.talking_points);\nconst discovery_questions = md(ex.discovery_questions);\nconst briefParts = [];\nif (why_now) briefParts.push(\":zap: *Why now*\\n\" + why_now);\nif (talking_points) briefParts.push(\":speaking_head_in_silhouette: *Talking points*\\n\" + talking_points);\nif (discovery_questions) briefParts.push(\":question: *Discovery questions*\\n\" + discovery_questions);\nconst ae_brief = briefParts.join(\"\\n\\n\");\n\n// --- Meeting meta ---\nlet mt = \"\";\ntry {\n mt = norm.meeting_time ? new Date(norm.meeting_time).toUTCString() : \"\";\n} catch (e) {\n mt = String(norm.meeting_time || \"\");\n}\nconst metaParts = [];\nif (mt) metaParts.push(\":clock3: \" + mt);\nif (norm.deal_id || \"\") metaParts.push(\":handshake: Deal `\" + norm.deal_id + \"`\");\nconst meta_line = metaParts.join(\"\\n\");\n\nconst resolvedLi = (research.person && research.person.linkedin_profile_url) || \"\";\nconst linkedin_url = resolvedLi || norm.linkedin_url || \"\";\nconst linkedin_line = linkedin_url ? \":link: <\" + linkedin_url + \"|LinkedIn profile>\" : \"\";\n// Prebuilt escape-safe warnings line (single token for Slack cards; empty when no warnings).\nconst warning_line =\n warnings && warnings.has_warnings\n ? \":warning: *Warnings:* \" +\n ((categories || []).join(\", \") || \"flagged\") +\n (warnings.summary ? \" — \" + warnings.summary : \"\")\n : \"\";\n\nreturn {\n to_email: norm.email || \"\",\n has_work_email: norm.has_work_email === true,\n // Primary email step (drives the Gmail draft + email Slack card)\n subject: subject,\n body_html: bodyHtml,\n body_text: firstBody,\n email_preview: email_preview,\n email_steps: emailSteps,\n // LinkedIn steps (drive HeyReach + LinkedIn Slack card)\n linkedin_messages: linkedin_messages,\n linkedin_preview: linkedin_preview,\n has_linkedin_steps: has_linkedin_steps,\n linkedin_steps: linkedinSteps,\n heyreach_campaign_id: cfg.heyreach_campaign_id || \"\",\n // Shared\n sequence_preview: sequencePreview || \"No messages returned.\",\n step_count: messages.length,\n person_name: person.name || norm.displayName || norm.email || \"Unknown\",\n person_title: person.title || \"\",\n company_name: company.name || \"\",\n ae_brief: ae_brief || \"No structured research returned yet — open the lead in Twain for full context.\",\n source: norm.source || \"\",\n deal_id: norm.deal_id || \"\",\n meta_line: meta_line,\n linkedin_url: linkedin_url,\n linkedin_line: linkedin_line,\n meeting_time: norm.meeting_time || \"\",\n meeting_time_display: mt,\n link: api.link || \"\",\n warnings_line: categories.length ? categories.join(\", \") : \"\",\n warnings_summary: warnings.summary || \"\",\n warning_line: warning_line,\n};\n"
}
},
{
"id": "390f2e94-8cd9-4ff3-b93f-cbf03333c01c",
"name": "Has Work Email?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [4800, 520],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.has_work_email }}",
"operator": {
"type": "boolean",
"operation": "true"
},
"rightValue": "",
"id": "hwe-1"
}
],
"combinator": "and"
},
"looseTypeValidation": true,
"options": {}
}
},
{
"id": "709019b0-e019-4e6a-a004-43202761d2d1",
"name": "Gmail: Create Pre-demo Draft",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [5180, 410],
"onError": "continueErrorOutput",
"parameters": {
"resource": "draft",
"subject": "={{ $('Build Pre-demo Brief').item.json.subject }}",
"emailType": "html",
"message": "={{ $('Build Pre-demo Brief').item.json.body_html }}",
"options": {
"sendTo": "={{ $('Build Pre-demo Brief').item.json.to_email }}"
}
}
},
{
"id": "47a73edc-8d01-4388-8a99-b8198ecf22cd",
"name": "Slack: Pre-demo brief + draft ready",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.5,
"position": [5560, 410],
"onError": "continueRegularOutput",
"parameters": {
"authentication": "accessToken",
"select": "channel",
"channelId": {
"__rl": true,
"value": "n8n-workflows",
"mode": "name"
},
"text": "=:calendar: *Demo booked — pre-demo brief ready*\n\n*{{ $('Build Pre-demo Brief').item.json.person_name }}*{{ [$('Build Pre-demo Brief').item.json.person_title, $('Build Pre-demo Brief').item.json.company_name].filter(Boolean).length ? ' · ' + [$('Build Pre-demo Brief').item.json.person_title, $('Build Pre-demo Brief').item.json.company_name].filter(Boolean).join(' @ ') : '' }}\n\n:email: {{ $('Build Pre-demo Brief').item.json.to_email }}\n{{ $('Build Pre-demo Brief').item.json.linkedin_line }}\n\n{{ $('Build Pre-demo Brief').item.json.meta_line }}\n{{ $('Build Pre-demo Brief').item.json.warning_line }}\n\n{{ $('Build Pre-demo Brief').item.json.ae_brief }}\n\n:memo: *_{{ $('Build Pre-demo Brief').item.json.subject }}_*\n```\n{{ ($('Build Pre-demo Brief').item.json.body_text || '').slice(0, 700) }}{{ ($('Build Pre-demo Brief').item.json.body_text || '').length > 700 ? '…' : '' }}\n```\n\n:inbox_tray: Saved as a Gmail draft — open *Drafts* to review and send.\n<{{ $('Build Pre-demo Brief').item.json.link }}|Open lead in Twain>",
"otherOptions": {
"includeLinkToWorkflow": false,
"unfurl_links": false,
"unfurl_media": false
}
}
},
{
"id": "79d73607-1106-4b31-9add-4044575473ca",
"name": "Slack: Brief ready, no draft",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.5,
"position": [5560, 630],
"onError": "continueRegularOutput",
"parameters": {
"authentication": "accessToken",
"select": "channel",
"channelId": {
"__rl": true,
"value": "n8n-workflows",
"mode": "name"
},
"text": "=:warning: *Pre-demo brief ready — no Gmail draft created*\n\n*{{ $('Build Pre-demo Brief').item.json.person_name }}*{{ [$('Build Pre-demo Brief').item.json.person_title, $('Build Pre-demo Brief').item.json.company_name].filter(Boolean).length ? ' · ' + [$('Build Pre-demo Brief').item.json.person_title, $('Build Pre-demo Brief').item.json.company_name].filter(Boolean).join(' @ ') : '' }}\n\n:email: {{ $('Build Pre-demo Brief').item.json.to_email }}\n{{ $('Build Pre-demo Brief').item.json.linkedin_line }}\n\n{{ $('Build Pre-demo Brief').item.json.meta_line }}\n{{ $('Build Pre-demo Brief').item.json.warning_line }}\n\n{{ $('Build Pre-demo Brief').item.json.ae_brief }}\n\nGmail isn't connected (or the draft step failed), so nothing was saved to Drafts. Connect Gmail to auto-draft, or copy the message below to send manually.\n\n:memo: *_{{ $('Build Pre-demo Brief').item.json.subject }}_*\n```\n{{ ($('Build Pre-demo Brief').item.json.body_text || '').slice(0, 900) }}{{ ($('Build Pre-demo Brief').item.json.body_text || '').length > 900 ? '…' : '' }}\n```\n\n<{{ $('Build Pre-demo Brief').item.json.link }}|Open lead in Twain>",
"otherOptions": {
"includeLinkToWorkflow": false,
"unfurl_links": false,
"unfurl_media": false
}
}
},
{
"id": "1c11b8e7-ec7a-4481-adc4-9dffcfe8945e",
"name": "Slack: Brief ready (LinkedIn only)",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.5,
"position": [5180, 630],
"onError": "continueRegularOutput",
"parameters": {
"authentication": "accessToken",
"select": "channel",
"channelId": {
"__rl": true,
"value": "n8n-workflows",
"mode": "name"
},
"text": "=:wave: *Demo booked — pre-demo brief ready (LinkedIn only)*\n\n*{{ $('Build Pre-demo Brief').item.json.person_name }}*{{ [$('Build Pre-demo Brief').item.json.person_title, $('Build Pre-demo Brief').item.json.company_name].filter(Boolean).length ? ' · ' + [$('Build Pre-demo Brief').item.json.person_title, $('Build Pre-demo Brief').item.json.company_name].filter(Boolean).join(' @ ') : '' }}\n\n{{ $('Build Pre-demo Brief').item.json.linkedin_line }}\n\n{{ $('Build Pre-demo Brief').item.json.meta_line }}\n{{ $('Build Pre-demo Brief').item.json.warning_line }}\n\n{{ $('Build Pre-demo Brief').item.json.ae_brief }}\n\nNo work email on file — send this via LinkedIn ahead of the demo.\n\n:speech_balloon: *_Sequence · {{ $('Build Pre-demo Brief').item.json.step_count }} step{{ $('Build Pre-demo Brief').item.json.step_count == 1 ? '' : 's' }}_*\n```\n{{ ($('Build Pre-demo Brief').item.json.sequence_preview || '').slice(0, 1400) }}{{ ($('Build Pre-demo Brief').item.json.sequence_preview || '').length > 1400 ? '…' : '' }}\n```\n\n<{{ $('Build Pre-demo Brief').item.json.link }}|Open lead in Twain>",
"otherOptions": {
"includeLinkToWorkflow": false,
"unfurl_links": false,
"unfurl_media": false
}
}
},
{
"id": "da26fe7e-e8bc-4cf6-ad0e-7774145b6492",
"name": "Slack: Twain error",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.5,
"position": [4420, 740],
"onError": "continueRegularOutput",
"parameters": {
"authentication": "accessToken",
"select": "channel",
"channelId": {
"__rl": true,
"value": "n8n-workflows",
"mode": "name"
},
"text": "=:rotating_light: *Twain Contact generation failed*\n\n*{{ $('Normalize Booking').item.json.displayName || $('Normalize Booking').item.json.email || 'Unknown invitee' }}*\n:email: {{ $('Normalize Booking').item.json.email || 'No email provided' }}\n\n*Error:*\n{{ String($json.error?.message ?? $json.message ?? 'Unknown error').replace(/^\\s*\\d{3}\\s*-\\s*/, '').trim() }}\n\n<https://www.twain.ai/w|Open Twain workspace>",
"otherOptions": {
"includeLinkToWorkflow": false,
"unfurl_links": false,
"unfurl_media": false
}
}
},
{
"id": "e92693d2-9527-4bcb-a3ba-1a60a958b0d7",
"name": "Normalize Booking",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1000, 520],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const src = $json.body ?? $json ?? {};\nconst PERSONAL = [\"gmail.com\",\"googlemail.com\",\"outlook.com\",\"hotmail.com\",\"live.com\",\"msn.com\",\"yahoo.com\",\"ymail.com\",\"icloud.com\",\"me.com\",\"mac.com\",\"proton.me\",\"protonmail.com\",\"aol.com\",\"gmx.com\",\"gmx.net\",\"zoho.com\",\"pm.me\",\"mail.com\",\"yandex.com\"];\nconst DEMO_TERMS = [\"demo\"];\nconst str = (v) => (v === undefined || v === null) ? \"\" : String(v);\nconst firstNonEmpty = (...vals) => { for (const v of vals) { const s = str(v).trim(); if (s) return s; } return \"\"; };\n\n// --- Detect provider (order matters: most specific first) ---\nlet source = \"generic\";\nif (src.payload && (src.payload.scheduled_event || src.payload.uri || src.event === \"invitee.created\" || src.payload.email)) source = \"calendly\";\nelse if (src.guest || src.slot || (src.event && src.event.start) || src.crm) source = \"chili_piper\";\nelse if (src.properties || src.dealId !== undefined || src.engagementId !== undefined || src.objectId !== undefined) source = \"hubspot_meetings\";\nelse if (src.kind === \"calendar#event\" || (src.start && Array.isArray(src.attendees)) || src.iCalUID) source = \"google_calendar\";\n\n// --- Calendly LinkedIn from Q&A (best effort) ---\nconst qaLinkedin = () => {\n const qa = src.payload && Array.isArray(src.payload.questions_and_answers) ? src.payload.questions_and_answers : [];\n for (const item of qa) {\n const q = str(item && (item.question || item.q)).toLowerCase();\n if (q.includes(\"linkedin\")) return str(item && (item.answer || item.a)).trim();\n }\n return \"\";\n};\n\n// --- Google Calendar: pick the prospect attendee (not the host/organizer) ---\nconst gcalAttendee = () => {\n const att = Array.isArray(src.attendees) ? src.attendees : [];\n const prospect = att.find((a) => a && a.self !== true && a.organizer !== true && a.responseStatus !== \"declined\");\n return prospect || att[0] || null;\n};\nconst gcalLinkedin = () => {\n const liRe = /https?:\\/\\/([\\w-]+\\.)*linkedin\\.com\\/(in|pub|company)\\/[^\\s\"'<>)\\]]+/i;\n const liScrape = (String(src.description||\"\") + \" \" + String(src.location||\"\")).match(liRe);\n return liScrape ? liScrape[0] : \"\";\n};\n\n// --- Per-source extraction with generic-flat fallback (all off `src`) ---\nconst p = src.payload || {};\nconst props = src.properties || {};\nconst gAtt = source === \"google_calendar\" ? gcalAttendee() : null;\n\nconst email = firstNonEmpty(\n p.email,\n src.guest && src.guest.email, src.fields && src.fields.email,\n props.email,\n gAtt && gAtt.email, source === \"google_calendar\" ? (src.creator && src.creator.email) : \"\",\n src.email\n).toLowerCase();\n\nconst name = firstNonEmpty(\n p.name,\n src.guest && src.guest.name, src.fields && src.fields.name,\n src.firstName && src.lastName ? (src.firstName + \" \" + src.lastName) : \"\",\n props.firstname && props.lastname ? (props.firstname + \" \" + props.lastname) : \"\",\n props.firstname,\n gAtt && gAtt.displayName,\n src.name, src.displayName, src.invitee_name\n);\n\nconst meeting_time = firstNonEmpty(\n p.scheduled_event && p.scheduled_event.start_time,\n src.event && src.event.start, src.slot && src.slot.start, src.startTime,\n props.hs_meeting_start_time,\n src.start && (src.start.dateTime || src.start.date),\n src.meeting_time, src.start_time\n);\n\nconst meeting_title = firstNonEmpty(\n p.scheduled_event && p.scheduled_event.name,\n source === \"google_calendar\" ? src.summary : \"\",\n src.event && src.event.name, src.meetingType, src.fields && src.fields.meetingType, src.slot && src.slot.name,\n props.hs_meeting_title, props.meeting_name, src.subject,\n src.meeting_type, src.title, src.event_type, src.eventType\n);\n\nconst linkedin_url = firstNonEmpty(\n source === \"calendly\" ? qaLinkedin() : \"\",\n source === \"google_calendar\" ? gcalLinkedin() : \"\",\n p.linkedin, p.linkedin_url, p.linkedin_profile_url,\n src.guest && src.guest.linkedin,\n src.linkedin, src.linkedin_url, src.linkedin_profile_url,\n props.linkedin, props.linkedin_url\n);\n\nconst deal_id = firstNonEmpty(\n src.crm && src.crm.dealId,\n src.dealId, props.deal_id, src.associatedDealId,\n p.deal_id,\n src.deal_id\n);\n\nconst stable_id = firstNonEmpty(\n p.scheduled_event && p.scheduled_event.uri, p.uri,\n src.bookingId, src.eventId, src.event && src.event.id,\n src.engagementId, src.objectId, props.hs_object_id,\n source === \"google_calendar\" ? (src.id || src.iCalUID) : \"\",\n src.id, src.uid, src.booking_id, src.event_id\n);\n\nconst uid = stable_id || (email + \"|\" + meeting_time);\nconst email_domain = (email.split(\"@\")[1] || \"\").toLowerCase();\nconst is_personal_email = PERSONAL.includes(email_domain);\nconst has_work_email = Boolean(email) && !is_personal_email;\nconst has_linkedin_url = Boolean(linkedin_url);\n\nconst t = meeting_title.toLowerCase();\nconst is_demo = !meeting_title ? true : DEMO_TERMS.some((term) => t.includes(term));\n\nreturn {\n source: source,\n uid: uid,\n email: email,\n displayName: name,\n meeting_time: meeting_time,\n meeting_title: meeting_title,\n is_demo: is_demo,\n campaign_id: ($('Demo Settings').first().json.campaign_id || \"REPLACE_WITH_DEMO_CAMPAIGN_ID\"),\n linkedin_url: linkedin_url,\n deal_id: deal_id,\n email_domain: email_domain,\n is_personal_email: is_personal_email,\n has_linkedin_url: has_linkedin_url,\n has_work_email: has_work_email\n};\n"
}
},
{
"id": "001af38b-93ed-490e-916d-44ecb3d1aa1c",
"name": "Calendly Trigger",
"type": "n8n-nodes-base.calendlyTrigger",
"typeVersion": 2,
"position": [240, 350],
"disabled": true,
"parameters": {
"events": ["invitee.created"]
}
},
{
"id": "e5404f3d-2b7b-4361-bdf8-f88787437cc9",
"name": "Chili Piper Booking",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [240, 520],
"parameters": {
"httpMethod": "POST",
"path": "booking/chili-piper",
"options": {}
}
},
{
"id": "7d7528d4-e0bb-45fa-a395-fc72b16b7a70",
"name": "HubSpot Meeting Booked",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [240, 690],
"parameters": {
"httpMethod": "POST",
"path": "booking/hubspot",
"options": {}
}
},
{
"id": "fb1db403-9f17-4af5-bb22-13ade33675df",
"name": "Google Calendar Trigger",
"type": "n8n-nodes-base.googleCalendarTrigger",
"typeVersion": 1,
"position": [240, 860],
"parameters": {
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
},
"calendarId": {
"__rl": true,
"value": "REPLACE_WITH_DEMO_CALENDAR_ID",
"mode": "list",
"cachedResultName": "Demo"
},
"triggerOn": "eventCreated",
"options": {}
}
},
{
"id": "ca1dc18f-8ac9-4092-a7ad-2a61707f4814",
"name": "Demo meetings only",
"type": "n8n-nodes-base.filter",
"typeVersion": 2.3,
"position": [1380, 520],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.is_demo }}",
"operator": {
"type": "boolean",
"operation": "true"
},
"rightValue": "",
"id": "demo-filter-1"
}
],
"combinator": "and"
},
"looseTypeValidation": true,
"options": {}
}
},
{
"id": "5a458db9-4979-46a8-838a-67af46c49291",
"name": "Demo Settings",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [620, 520],
"parameters": {
"assignments": {
"assignments": [
{
"id": "campaign_id",
"name": "campaign_id",
"value": "REPLACE_WITH_DEMO_CAMPAIGN_ID",
"type": "string"
},
{
"id": "email_steps",
"name": "email_steps",
"value": "1",
"type": "string"
},
{
"id": "linkedin_steps",
"name": "linkedin_steps",
"value": "2",
"type": "string"
},
{
"id": "heyreach_campaign_id",
"name": "heyreach_campaign_id",
"value": "REPLACE_WITH_HEYREACH_CAMPAIGN_ID",
"type": "string"
}
]
},
"includeOtherFields": true,
"options": {}
}
},
{
"id": "ab8b1915-f4b0-4114-b420-0c5387caa5a5",
"name": "Needs LinkedIn Lookup?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [2140, 520],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "needs-li-1",
"leftValue": "={{ !$('Normalize Booking').item.json.has_linkedin_url && Boolean($('Normalize Booking').item.json.email) }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"looseTypeValidation": true,
"options": {}
}
},
{
"id": "9ebc71d0-8bb7-40c9-8f6a-1a117dbd391a",
"name": "LinkedIn Lookup — Findymail",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [2520, 300],
"onError": "continueRegularOutput",
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 2000,
"parameters": {
"method": "POST",
"url": "https://app.findymail.com/api/search/reverse-email",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { email: $('Normalize Booking').item.json.email, with_profile: true } }}",
"options": {
"timeout": 30000
}
}
},
{
"id": "f32682b7-1eb5-4cc2-bf95-2b6e214ca758",
"name": "Apply Resolved LinkedIn",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [2900, 520],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Resolve LinkedIn URL from the provider response.\n// Adjust the field below to match YOUR provider's response shape.\nconst resp = $json || {};\nconst linkedin_url = resp.linkedin_url ?? resp.linkedin_profile_url ?? resp.url ?? resp.contact?.linkedin_url ?? resp.profile?.linkedin_url ?? resp.person?.linkedin_url ?? resp.data?.linkedin_url ?? resp.response?.linkedin_url ?? \"\";\n\n// Carry forward all normalized fields, overriding only the LinkedIn ones.\nconst n = $('Normalize Booking').item.json;\nreturn {\n ...n,\n linkedin_url: linkedin_url,\n has_linkedin_url: Boolean(linkedin_url)\n};\n"
}
},
{
"id": "d8a84847-73a3-420e-8933-7c814f19540b",
"name": "Resolved Contact",
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [3280, 520],
"parameters": {}
},
{
"id": "f2f37ca3-abee-41ab-b01b-06189b668bb4",
"name": "Has LinkedIn steps?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [5940, 520],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 3
},
"conditions": [
{
"id": "has-li-steps-1",
"leftValue": "={{ $('Build Pre-demo Brief').item.json.has_linkedin_steps }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"looseTypeValidation": true,
"options": {}
}
},
{
"id": "c85bea94-b794-4e23-85c2-8d08ce8c729b",
"name": "HeyReach: Add to Campaign",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [6320, 520],
"onError": "continueRegularOutput",
"parameters": {
"method": "POST",
"url": "https://api.heyreach.io/api/public/campaign/AddLeadsToCampaignV2",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ {\n campaignId: Number($('Build Pre-demo Brief').item.json.heyreach_campaign_id) || $('Build Pre-demo Brief').item.json.heyreach_campaign_id,\n accountLeadPairs: [{\n lead: {\n profileUrl: $('Build Pre-demo Brief').item.json.linkedin_url,\n firstName: ($('Build Pre-demo Brief').item.json.person_name || \"\").trim().split(\" \")[0] || \"\",\n lastName: ($('Build Pre-demo Brief').item.json.person_name || \"\").trim().split(\" \").slice(1).join(\" \") || \"\",\n companyName: $('Build Pre-demo Brief').item.json.company_name || \"\",\n emailAddress: $('Build Pre-demo Brief').item.json.to_email || \"\",\n customUserFields: [{ name: \"twain_message\", value: $('Build Pre-demo Brief').item.json.linkedin_preview || \"\" }]\n }\n }]\n} }}",
"options": {
"timeout": 30000
}
}
},
{
"id": "c559eed6-e935-438f-affa-89804a6b6d5c",
"name": "Slack: LinkedIn steps queued (HeyReach)",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.5,
"position": [6700, 520],
"parameters": {
"authentication": "accessToken",
"select": "channel",
"channelId": {
"__rl": true,
"value": "n8n-workflows",
"mode": "name"
},
"text": "=:link: *LinkedIn step(s) queued — HeyReach*\n\n*{{ $('Build Pre-demo Brief').item.json.person_name }}*{{ [$('Build Pre-demo Brief').item.json.person_title, $('Build Pre-demo Brief').item.json.company_name].filter(Boolean).length ? ' · ' + [$('Build Pre-demo Brief').item.json.person_title, $('Build Pre-demo Brief').item.json.company_name].filter(Boolean).join(' @ ') : '' }}\n\n{{ $('Build Pre-demo Brief').item.json.linkedin_line }}\n\n:speech_balloon: *_LinkedIn message_*\n```\n{{ ($('Build Pre-demo Brief').item.json.linkedin_preview || '').slice(0, 1400) }}{{ ($('Build Pre-demo Brief').item.json.linkedin_preview || '').length > 1400 ? '…' : '' }}\n```\n\n{{ $('HeyReach: Add to Campaign').isExecuted ? ':white_check_mark: Pushed to HeyReach campaign `' + ($('Build Pre-demo Brief').item.json.heyreach_campaign_id || '') + '`.' : ':wrench: *Configure HeyReach to enable* — add the `X-API-KEY` credential + `heyreach_campaign_id`, then enable `HeyReach: Add to Campaign`. Until then, send these manually.' }}\n<{{ $('Build Pre-demo Brief').item.json.link }}|Open lead in Twain>",
"otherOptions": {
"includeLinkToWorkflow": false,
"unfurl_links": false,
"unfurl_media": false,
"thread_ts": {
"replyValues": {
"thread_ts": "={{ $('Slack: Pre-demo brief + draft ready').isExecuted ? $('Slack: Pre-demo brief + draft ready').item.json.message_timestamp : ($('Slack: Brief ready, no draft').isExecuted ? $('Slack: Brief ready, no draft').item.json.message_timestamp : $('Slack: Brief ready (LinkedIn only)').item.json.message_timestamp) }}"
}
}
}
}
},
{
"id": "a63b7bfd-5df4-4082-ab21-102d233e8a2a",
"name": "LinkedIn Lookup — Apollo",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [2520, 80],
"disabled": true,
"onError": "continueRegularOutput",
"parameters": {
"method": "POST",
"url": "https://api.apollo.io/api/v1/people/match",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { email: $('Normalize Booking').item.json.email } }}",
"options": {
"timeout": 30000
}
}
},
{
"id": "b1a9bc6b-bffd-4bca-ac35-a983d00aca38",
"name": "LinkedIn Lookup — People Data Labs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [2520, -140],
"disabled": true,
"onError": "continueRegularOutput",
"parameters": {
"url": "=https://api.peopledatalabs.com/v5/person/enrich?email={{ encodeURIComponent($('Normalize Booking').item.json.email) }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {
"timeout": 30000
}
}
},
{
"id": "757d37b3-bdd0-4991-b373-ff2fdc6eb986",
"name": "LinkedIn Lookup — Proxycurl",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [2520, -360],
"disabled": true,
"onError": "continueRegularOutput",
"parameters": {
"url": "=https://nubela.co/proxycurl/api/linkedin/profile/resolve/email?email={{ encodeURIComponent($('Normalize Booking').item.json.email) }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {
"timeout": 30000
}
}
}
],
"connections": {
"Twain Generate Contact": {
"main": [
[
{
"node": "Build Pre-demo Brief",
"type": "main",
"index": 0
}
],
[
{
"node": "Slack: Twain error",
"type": "main",
"index": 0
}
]
]
},
"Build Pre-demo Brief": {
"main": [
[
{
"node": "Has Work Email?",
"type": "main",
"index": 0
}
]
]
},
"Has Work Email?": {
"main": [
[
{
"node": "Gmail: Create Pre-demo Draft",
"type": "main",
"index": 0
}
],
[
{
"node": "Slack: Brief ready (LinkedIn only)",
"type": "main",
"index": 0
}
]
]
},
"Gmail: Create Pre-demo Draft": {
"main": [
[
{
"node": "Slack: Pre-demo brief + draft ready",
"type": "main",
"index": 0
}
],
[
{
"node": "Slack: Brief ready, no draft",
"type": "main",
"index": 0
}
]
]
},
"Normalize Booking": {
"main": [
[
{
"node": "Demo meetings only",
"type": "main",
"index": 0
}
]
]
},
"Demo meetings only": {
"main": [
[
{
"node": "Dedupe Bookings",
"type": "main",
"index": 0
}
]
]
},
"Calendly Trigger": {
"main": [
[
{
"node": "Demo Settings",
"type": "main",
"index": 0
}
]
]
},
"Google Calendar Trigger": {
"main": [
[
{
"node": "Demo Settings",
"type": "main",
"index": 0
}
]
]
},
"Chili Piper Booking": {
"main": [
[
{
"node": "Demo Settings",
"type": "main",
"index": 0
}
]
]
},
"HubSpot Meeting Booked": {
"main": [
[
{
"node": "Demo Settings",
"type": "main",
"index": 0
}
]
]
},
"Generic Booking (Webhook)": {
"main": [
[
{
"node": "Demo Settings",
"type": "main",
"index": 0
}
]
]
},
"Demo Settings": {
"main": [
[
{
"node": "Normalize Booking",
"type": "main",
"index": 0
}
]
]
},
"Needs LinkedIn Lookup?": {
"main": [
[
{
"node": "LinkedIn Lookup — Findymail",
"type": "main",
"index": 0
},
{
"node": "LinkedIn Lookup — Apollo",
"type": "main",
"index": 0
},
{
"node": "LinkedIn Lookup — People Data Labs",
"type": "main",
"index": 0
},
{
"node": "LinkedIn Lookup — Proxycurl",
"type": "main",
"index": 0
}
],
[
{
"node": "Resolved Contact",
"type": "main",
"index": 1
}
]
]
},
"Apply Resolved LinkedIn": {
"main": [
[
{
"node": "Resolved Contact",
"type": "main",
"index": 0
}
]
]
},
"Has LinkedIn steps?": {
"main": [
[
{
"node": "HeyReach: Add to Campaign",
"type": "main",
"index": 0
}
]
]
},
"HeyReach: Add to Campaign": {
"main": [
[
{
"node": "Slack: LinkedIn steps queued (HeyReach)",
"type": "main",
"index": 0
}
]
]
},
"LinkedIn Lookup — Findymail": {
"main": [
[
{
"node": "Apply Resolved LinkedIn",
"type": "main",
"index": 0
}
]
]
},
"LinkedIn Lookup — Apollo": {
"main": [
[
{
"node": "Apply Resolved LinkedIn",
"type": "main",
"index": 0
}
]
]
},
"LinkedIn Lookup — People Data Labs": {
"main": [
[
{
"node": "Apply Resolved LinkedIn",
"type": "main",
"index": 0
}
]
]
},
"LinkedIn Lookup — Proxycurl": {
"main": [
[
{
"node": "Apply Resolved LinkedIn",
"type": "main",
"index": 0
}
]
]
},
"Slack: Pre-demo brief + draft ready": {
"main": [
[
{
"node": "Has LinkedIn steps?",
"type": "main",
"index": 0
}
]
]
},
"Slack: Brief ready, no draft": {
"main": [
[
{
"node": "Has LinkedIn steps?",
"type": "main",
"index": 0
}
]
]
},
"Slack: Brief ready (LinkedIn only)": {
"main": [
[
{
"node": "Has LinkedIn steps?",
"type": "main",
"index": 0
}
]
]
},
"Dedupe Bookings": {
"main": [
[
{
"node": "Needs LinkedIn Lookup?",
"type": "main",
"index": 0
}
]
]
},
"Resolved Contact": {
"main": [
[
{
"node": "Has usable contact?",
"type": "main",
"index": 0
}
]
]
},
"Has usable contact?": {
"main": [
[
{
"node": "Twain Generate Contact",
"type": "main",
"index": 0
}
],
[
{
"node": "Slack: Skipped (cannot research)",
"type": "main",
"index": 0
}
]
]
}
}
}
After import you connect your own Twain API key (Header Auth), the Gmail mailbox drafts should land in, Slack, and any booking-tool OAuth you use — then paste your demo campaign's campaign_id into the one labeled field in the Demo Settings node. That campaign is where you decide, once, how deep the research goes and how the pre-demo copy reads; every future booking inherits it.
Who this is for
This earns its place anywhere a prepared demo moves the number:
- AE-owned and sales-assist motions — turn the cold first ten minutes into a real conversation, every call.
- Founder-led sales — when the founder is the closer, this is the research team they don't have headcount for.
- RevOps and demand-gen — protect the cost-per-meeting you're already paying by making sure booked demos actually land.
- Teams on any booking stack — Calendly, Chili Piper, HubSpot Meetings, or the Google Calendar catch-all. Same brief, whatever the source.
If demos just appear on a calendar and prep is left to whoever has a spare fifteen minutes, this makes prepared the easy, automatic default — with a human still approving every send.