WhatsApp AI Assistant
Module 20 · ai · intermediate
learner.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "20 · WhatsApp AI Assistant",
"nodes": [
{
"parameters": {
"updates": [
"messages"
],
"options": {}
},
"id": "m20-whatsapp-trigger",
"name": "WhatsApp Trigger",
"type": "n8n-nodes-base.whatsAppTrigger",
"typeVersion": 1,
"position": [
0,
0
],
"webhookId": "course-20-wa-trigger"
},
{
"parameters": {
"httpMethod": "POST",
"path": "wa/inbound",
"responseMode": "responseNode",
"options": {}
},
"id": "m20-inbound-api",
"name": "Inbound API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
180
],
"webhookId": "course-20-inbound"
},
{
"parameters": {
"httpMethod": "GET",
"path": "wa/inbound",
"responseMode": "responseNode",
"options": {}
},
"id": "m20-verify-api",
"name": "Verify API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
400
],
"webhookId": "course-20-verify"
},
{
"parameters": {
"httpMethod": "GET",
"path": "wa/conversation",
"responseMode": "responseNode",
"options": {}
},
"id": "m20-conversation-api",
"name": "Conversation API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
580
],
"webhookId": "course-20-conversation"
},
{
"parameters": {
"httpMethod": "POST",
"path": "wa/handoff/release",
"responseMode": "responseNode",
"options": {}
},
"id": "m20-release-handoff-api",
"name": "Release Handoff API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
760
],
"webhookId": "course-20-release"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "s1",
"name": "business_name",
"value": "Cool Breeze Heating & Air",
"type": "string"
},
{
"id": "s2",
"name": "phone_number_id",
"value": "123456789012345",
"type": "string"
},
{
"id": "s3",
"name": "verify_token",
"value": "course-20-verify-me",
"type": "string"
},
{
"id": "s4",
"name": "use_ai",
"value": true,
"type": "boolean"
},
{
"id": "s5",
"name": "timezone",
"value": "America/Chicago",
"type": "string"
},
{
"id": "s6",
"name": "hours",
"value": "={{ { mon: { open: 8, close: 18 }, tue: { open: 8, close: 18 }, wed: { open: 8, close: 18 }, thu: { open: 8, close: 18 }, fri: { open: 8, close: 18 }, sat: { open: 9, close: 13 } } }}",
"type": "object"
},
{
"id": "s7",
"name": "hours_text",
"value": "Mon–Fri 8 AM–6 PM, Sat 9 AM–1 PM (Central)",
"type": "string"
},
{
"id": "s8",
"name": "service_area",
"value": "Austin, Round Rock, Pflugerville and Cedar Park",
"type": "string"
},
{
"id": "s9",
"name": "service_zips",
"value": "={{ ['78701','78702','78703','78704','78705','78721','78722','78723','78727','78728','78729','78731','78745','78748','78750','78751','78752','78753','78757','78758','78759','78660','78664','78665','78681','78613'] }}",
"type": "array"
},
{
"id": "s10",
"name": "services",
"value": "={{ [ { name: 'AC repair', keywords: ['ac ', 'a/c', 'air condition', 'cooling', 'not cooling', 'warm air'] }, { name: 'heating repair', keywords: ['heat', 'furnace', 'heater', 'no heat'] }, { name: 'maintenance tune-up', keywords: ['tune', 'maintenance', 'check-up', 'checkup', 'service plan'] }, { name: 'new system install', keywords: ['new system', 'replace', 'install', 'new unit', 'new ac'] } ] }}",
"type": "array"
},
{
"id": "s11",
"name": "faq",
"value": "={{ [ { keywords: ['hour', 'open', 'close', 'weekend', 'saturday', 'sunday'], answer: 'We are open Mon–Fri 8 AM–6 PM and Sat 9 AM–1 PM (Central). Messages after hours get a reply the next time we open.' }, { keywords: ['price', 'cost', 'how much', 'fee', 'charge', 'diagnostic'], answer: 'Our diagnostic visit is $89, credited toward the repair if you go ahead. Tune-ups are $129. Repairs are quoted on site before any work starts.' }, { keywords: ['area', 'serve', 'location', 'come to', 'zip'], answer: 'We serve Austin, Round Rock, Pflugerville and Cedar Park.' }, { keywords: ['financ', 'payment plan', 'monthly'], answer: 'Financing is available on new systems, with plans from 12 to 60 months (subject to approval).' }, { keywords: ['warranty', 'guarantee'], answer: 'Repairs come with a 1-year parts and labor warranty; new systems with a 10-year parts warranty.' } ] }}",
"type": "array"
},
{
"id": "s12",
"name": "emergency_keywords",
"value": "={{ ['smell gas', 'gas smell', 'gas leak', 'carbon monoxide', 'co alarm', 'smoke', 'sparks', 'fire'] }}",
"type": "array"
},
{
"id": "s13",
"name": "confirm_within",
"value": "1 business hour",
"type": "string"
},
{
"id": "s14",
"name": "handoff_hours",
"value": 12,
"type": "number"
},
{
"id": "s15",
"name": "memory_turns",
"value": 10,
"type": "number"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "m20-settings-edit-me",
"name": "Settings (edit me)",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
240,
380
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r1",
"leftValue": "={{ $('WhatsApp Trigger').isExecuted || $('Inbound API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "message"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r2",
"leftValue": "={{ $('Verify API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "verify"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r3",
"leftValue": "={{ $('Conversation API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "conversation"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r4",
"leftValue": "={{ $('Release Handoff API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "release"
}
]
},
"options": {}
},
"id": "m20-route",
"name": "Route",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
460,
380
]
},
{
"parameters": {
"jsCode": "// Turn a WhatsApp Cloud API webhook into one item per incoming message.\n// Accepts the raw webhook body (Inbound API) or the WhatsApp Trigger's items (one per change.value).\nconst viaApi = $('Inbound API').isExecuted;\nconst q = viaApi ? $('Inbound API').first().json.query ?? {} : {};\nconst values = viaApi\n ? ($('Inbound API').first().json.body?.entry ?? []).flatMap((e) => e.changes ?? []).map((c) => c.value ?? {})\n : $('WhatsApp Trigger').all().map((i) => i.json);\n\nconst textOf = (m) =>\n m.text?.body ??\n m.interactive?.button_reply?.title ??\n m.interactive?.list_reply?.title ??\n m.button?.text ??\n m[m.type]?.caption ??\n '';\n\nconst out = [];\nfor (const v of values) {\n const names = Object.fromEntries((v.contacts ?? []).map((c) => [c.wa_id, c.profile?.name ?? '']));\n for (const m of v.messages ?? []) {\n out.push({\n json: {\n wa_id: String(m.from ?? ''),\n profile_name: names[m.from] ?? '',\n message_id: String(m.id ?? ''),\n type: m.type ?? 'unknown',\n text: String(textOf(m)).trim().slice(0, 2000),\n received_at: m.timestamp ? new Date(Number(m.timestamp) * 1000).toISOString() : new Date().toISOString(),\n phone_number_id: v.metadata?.phone_number_id ?? '',\n dry_run: q.dry_run === 'true',\n now: q.now || new Date().toISOString(), // tests inject the clock with ?now=\n via_api: viaApi,\n },\n });\n }\n}\n// Status updates (sent/delivered/read) have no messages: return one marker item so the API caller gets an answer.\nreturn out.length ? out : [{ json: { ignored: true, reason: 'no messages (status update or other event)', via_api: viaApi } }];\n"
},
"id": "m20-parse-messages",
"name": "Parse Messages",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ !$json.ignored }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m20-any-messages",
"name": "Any Messages?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
920,
60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.via_api }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m20-ignored-via-api",
"name": "Ignored via API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1140,
240
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ignored: true, reason: $json.reason } }}",
"options": {}
},
"id": "m20-respond-ignored",
"name": "Respond Ignored",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1360,
240
]
},
{
"parameters": {
"jsCode": "// Load each contact's conversation, drop duplicates (Meta retries webhooks), and decide what to do.\n// Rules handle what must never go wrong (opt-out, safety, human handoff); everything else is an \"answer\".\nconst s = $('Settings (edit me)').first().json;\nconst db = $getWorkflowStaticData('global');\ndb.convos ??= {};\n\nconst lower = (t) => t.toLowerCase();\nconst has = (t, words) => words.some((w) => new RegExp(`\\\\b${w}\\\\b`, 'i').test(t));\n\nfunction openNow(nowIso) {\n const local = DateTime.fromISO(nowIso).setZone(s.timezone);\n const h = s.hours[local.toFormat('ccc').toLowerCase()]; // e.g. { open: 8, close: 18 }\n return { open: Boolean(h) && local.hour >= h.open && local.hour < h.close, local: local.toFormat(\"ccc HH:mm\") };\n}\n\n// Rule-based reply used when AI is off or fails. Fills the lead step by step.\nfunction rulesAnswer(c, text, profileName) {\n const t = lower(text);\n const patch = {};\n const service = s.services.find((sv) => sv.keywords.some((k) => t.includes(k)));\n if (service) patch.service = service.name;\n const zip = text.match(/\\b\\d{5}\\b/)?.[0];\n if (zip) patch.zip = zip;\n if (c.awaiting === 'name' && text.length <= 40 && !zip) patch.name = text.trim();\n if (c.awaiting === 'preferred_time') patch.preferred_time = text.trim().slice(0, 80);\n const when = t.match(/\\b(today|tomorrow|monday|tuesday|wednesday|thursday|friday|saturday|morning|afternoon|evening|asap)\\b/);\n if (when && !patch.preferred_time && c.lead.service) patch.preferred_time = text.trim().slice(0, 80);\n\n const lead = { ...c.lead, ...patch };\n if (!lead.name && profileName) lead.name = profileName;\n const booking = Object.keys(patch).length > 0 || has(t, ['book', 'schedule', 'appointment', 'technician', 'quote', 'estimate', 'repair', 'fix', 'broken', 'not working', 'come out']);\n const faq = s.faq.find((f) => f.keywords.some((k) => t.includes(k)));\n const first = (lead.name || profileName || '').split(' ')[0];\n\n if (booking || (c.awaiting && Object.keys(patch).length)) {\n if (lead.zip && !s.service_zips.includes(lead.zip))\n return { reply: `Sorry, ${lead.zip} is outside our service area (${s.service_area}). ${faq ? faq.answer : ''}`.trim(), patch: { ...patch, name: lead.name }, awaiting: null, intent: 'out_of_area' };\n const ask = {\n service: `Happy to help! What do you need: ${s.services.map((x) => x.name).join(', ')}?`,\n zip: `Got it: ${lead.service}. What's the ZIP code for the visit?`,\n name: 'Thanks! Who should the technician ask for?',\n preferred_time: `Thanks${first ? ' ' + first : ''}! When works best? (e.g. \"tomorrow morning\")`,\n };\n const missing = ['service', 'zip', 'name', 'preferred_time'].find((k) => !lead[k]);\n if (missing) return { reply: (faq ? faq.answer + '\\n\\n' : '') + ask[missing], patch: { ...patch, name: lead.name }, awaiting: missing, intent: 'booking' };\n return {\n reply: `Thanks ${first}! I've sent your ${lead.service} request for ${lead.zip} (${lead.preferred_time}) to our team. They'll confirm here within ${s.confirm_within}.`,\n patch: { ...patch, name: lead.name }, awaiting: null, intent: 'booking',\n };\n }\n if (faq) return { reply: faq.answer, patch, awaiting: c.awaiting, intent: 'faq' };\n if (has(t, ['hi', 'hello', 'hey', 'hola', 'good morning', 'good afternoon']))\n return { reply: `Hi${first ? ' ' + first : ''}! This is ${s.business_name}. I can answer questions (hours, prices, service area) or book a technician. What can I help with?`, patch, awaiting: null, intent: 'greeting' };\n return { reply: `I'm not sure about that one. I can help with hours, prices, our service area or booking a visit. Reply HUMAN to talk to our team.`, patch, awaiting: c.awaiting, intent: 'unknown' };\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n const m = item.json;\n const now = m.now;\n const c = JSON.parse(JSON.stringify(db.convos[m.wa_id] ?? {\n wa_id: m.wa_id, name: '', created_at: now, history: [], seen: [], lead: {}, awaiting: null,\n opted_out: false, handoff_until: null, lead_captured_at: null,\n })); // copy (structuredClone isn't available in the Code sandbox)\n if (m.profile_name && !c.name) c.name = m.profile_name;\n\n const duplicate = c.seen.includes(m.message_id);\n const hours = openNow(now);\n const t = lower(m.text);\n let action, reply = '', rules = null;\n\n if (duplicate) action = 'duplicate';\n else if (/^\\s*(stop|unsubscribe)\\s*$/i.test(m.text)) { action = 'opt_out'; reply = `You're unsubscribed from ${s.business_name} messages. Reply START to resume.`; }\n else if (/^\\s*start\\s*$/i.test(m.text)) { action = 'opt_in'; reply = `Welcome back! How can ${s.business_name} help?`; }\n else if (c.opted_out) action = 'ignore';\n else if (has(t, s.emergency_keywords)) {\n action = 'emergency';\n reply = `⚠️ If you smell gas or a CO alarm is going off, leave the home now and call 911 or your gas company from outside. Don't use switches or appliances. I've alerted our team; we'll call you back at this number.`;\n }\n else if (c.handoff_until && c.handoff_until > now) action = 'silent'; // a human is handling this chat\n else if (has(t, ['human', 'real person', 'agent', 'representative', 'talk to someone', 'speak to someone', 'talk to a person', 'speak to a person', 'call me'])) {\n action = 'handoff';\n reply = hours.open\n ? `Sure, I've asked a team member to take over. They'll reply here shortly.`\n : `Sure, I've passed this to our team. We're closed right now (${s.hours_text}); someone will reply here when we open.`;\n }\n else if (m.type !== 'text' && m.type !== 'interactive' && m.type !== 'button' && !m.text) {\n action = 'unsupported';\n reply = `Thanks! I can only read text messages right now. Could you type your question?`;\n }\n else { action = 'answer'; rules = rulesAnswer(c, m.text, c.name); }\n\n out.push({\n json: {\n ...m, action, reply, duplicate, open_now: hours.open, local_time: hours.local,\n rules, convo: c, use_ai: action === 'answer' && s.use_ai,\n history_text: c.history.slice(-s.memory_turns).map((h) => `${h.role === 'user' ? 'Customer' : 'Assistant'}: ${h.text}`).join('\\n'),\n },\n });\n}\nreturn out;\n"
},
"id": "m20-conversation-state",
"name": "Conversation State",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1140,
40
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.use_ai }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m20-use-ai",
"name": "Use AI?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1360,
40
]
},
{
"parameters": {
"promptType": "define",
"text": "=You are the WhatsApp assistant for {{ $('Settings (edit me)').first().json.business_name }}, a heating and air conditioning company.\n\nBusiness facts (the only facts you may use):\n{{ $('Settings (edit me)').first().json.faq.map(f => '- ' + f.answer).join('\\n') }}\n- Services: {{ $('Settings (edit me)').first().json.services.map(s => s.name).join(', ') }}\n- Service area ZIP codes: {{ $('Settings (edit me)').first().json.service_zips.join(', ') }}\n- Hours: {{ $('Settings (edit me)').first().json.hours_text }}. Open right now: {{ $json.open_now ? 'yes' : 'no' }} (local time {{ $json.local_time }}).\n\nBooking details collected so far: {{ JSON.stringify($json.convo.lead) }}\nCustomer's WhatsApp name: {{ $json.convo.name || 'unknown' }}\n\nConversation so far:\n{{ $json.history_text || '(new conversation)' }}\n\nNew message from the customer:\n{{ $json.text }}\n\nWrite the next reply. Rules:\n- Plain text, friendly, under 80 words, no markdown.\n- Answer only from the business facts. Never invent prices, availability or appointment times; if you don't know, say a team member will confirm.\n- To book a visit, collect: service, ZIP code, name, preferred day/time. Ask for one missing detail at a time. When all four are known, confirm them and say the team will confirm within {{ $('Settings (edit me)').first().json.confirm_within }}.\n- If the ZIP code is not in the service area, say so politely.\n- Set handoff to true if the customer is upset, wants a person, or asks the same thing twice without a good answer.\n- In lead, include only details the customer actually gave (use null for unknown). intent is one of: faq, booking, greeting, complaint, other.",
"hasOutputParser": true
},
"id": "m20-answer-with-ai",
"name": "Answer with AI",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.9,
"position": [
1580,
-100
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-5-mini",
"cachedResultName": "gpt-5-mini"
},
"options": {}
},
"id": "m20-openai-chat-model",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.3,
"position": [
1520,
100
]
},
{
"parameters": {
"schemaType": "fromJson",
"jsonSchemaExample": "{\n \"reply\": \"Our diagnostic visit is $89. What's the ZIP code for the visit?\",\n \"intent\": \"booking\",\n \"handoff\": false,\n \"lead\": {\n \"service\": \"AC repair\",\n \"zip\": \"78704\",\n \"name\": null,\n \"preferred_time\": null\n }\n}"
},
"id": "m20-reply-format",
"name": "Reply Format",
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1.3,
"position": [
1680,
100
]
},
{
"parameters": {
"jsCode": "// Use the AI reply when there is one; otherwise the rule-based reply. Runs for both branches of \"Use AI?\".\nreturn $input.all().map((item, i) => {\n const fromAi = !item.json.wa_id; // AI chain output only has { output } (or an error)\n const st = fromAi ? $('Conversation State').itemMatching(i).json : item.json;\n const ai = fromAi ? item.json.output : null;\n if (st.action !== 'answer') return { json: { ...st, reply_source: st.reply ? 'rules' : 'none', lead_patch: {}, next_awaiting: st.convo.awaiting } };\n if (ai?.reply) {\n const patch = Object.fromEntries(Object.entries(ai.lead ?? {}).filter(([, v]) => v !== null && String(v).trim() !== ''));\n return { json: { ...st, reply: String(ai.reply).slice(0, 4096), reply_source: 'ai', intent: ai.intent ?? 'other', lead_patch: patch, ai_handoff: ai.handoff === true, next_awaiting: null } };\n }\n return {\n json: {\n ...st, reply: st.rules.reply, reply_source: 'rules', intent: st.rules.intent, lead_patch: st.rules.patch,\n next_awaiting: st.rules.awaiting, ai_error: fromAi ? String(item.json.error?.message ?? item.json.error ?? 'no AI output') : undefined,\n },\n };\n});\n"
},
"id": "m20-pick-reply",
"name": "Pick Reply",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1860,
40
]
},
{
"parameters": {
"jsCode": "// Save the turn: history, lead fields, opt-out and handoff flags. Dry runs save nothing.\nconst s = $('Settings (edit me)').first().json;\nconst db = $getWorkflowStaticData('global');\ndb.convos ??= {};\n\nreturn $input.all().map(({ json: x }) => {\n const c = x.convo;\n const now = x.now;\n const addHours = (h) => new Date(new Date(now).getTime() + h * 3600e3).toISOString();\n let handoff = false, newLead = false;\n\n if (!x.duplicate) {\n c.seen = [...c.seen, x.message_id].slice(-50);\n c.last_inbound_at = now;\n c.window_open_until = addHours(24); // WhatsApp: free-form replies only within 24 h of the customer's last message\n c.history.push({ role: 'user', text: x.text || `[${x.type}]`, at: now });\n\n if (x.action === 'opt_out') c.opted_out = true;\n if (x.action === 'opt_in') c.opted_out = false;\n if (['handoff', 'emergency'].includes(x.action) || x.ai_handoff) { c.handoff_until = addHours(s.handoff_hours); handoff = true; }\n if (x.action === 'answer') {\n c.lead = { ...c.lead, ...x.lead_patch };\n if (!c.lead.name && c.name) c.lead.name = c.name;\n c.awaiting = x.next_awaiting ?? null;\n const complete = ['service', 'zip', 'name', 'preferred_time'].every((k) => c.lead[k]) && s.service_zips.includes(c.lead.zip);\n if (complete && !c.lead_captured_at) { c.lead_captured_at = now; newLead = true; }\n }\n if (x.reply) c.history.push({ role: 'assistant', text: x.reply, at: now });\n c.history = c.history.slice(-40);\n c.updated_at = now;\n if (!x.dry_run) db.convos[x.wa_id] = c;\n }\n\n // Keep static data small: forget conversations idle for 90 days.\n if (!x.dry_run) for (const [k, v] of Object.entries(db.convos)) if ((v.updated_at ?? v.created_at) < addHours(-24 * 90)) delete db.convos[k];\n\n const { convo, rules, history_text, ...rest } = x;\n return {\n json: {\n ...rest, has_reply: Boolean(x.reply) && !x.duplicate, handoff, new_lead: newLead,\n lead: c.lead, lead_complete: Boolean(c.lead_captured_at), awaiting: c.awaiting,\n needs_staff: handoff || x.action === 'silent',\n },\n };\n});\n"
},
"id": "m20-save-turn",
"name": "Save Turn",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2080,
40
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.dry_run }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m20-dry-run",
"name": "Dry Run?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2300,
40
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { dry_run: true, results: $input.all().map(i => i.json) } }}",
"options": {}
},
"id": "m20-respond-plan",
"name": "Respond Plan",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2520,
-100
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.has_reply }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m20-has-reply",
"name": "Has Reply?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2520,
60
]
},
{
"parameters": {
"operation": "send",
"phoneNumberId": "={{ $('Settings (edit me)').first().json.phone_number_id }}",
"recipientPhoneNumber": "={{ $json.wa_id }}",
"textBody": "={{ $json.reply }}",
"additionalFields": {}
},
"id": "m20-send-whatsapp-reply",
"name": "Send WhatsApp Reply",
"type": "n8n-nodes-base.whatsApp",
"typeVersion": 1.1,
"position": [
2740,
-20
],
"onError": "continueRegularOutput",
"webhookId": "course-20-wa-send"
},
{
"parameters": {
"jsCode": "// One result per message. A reply counts as sent only if WhatsApp returned a message id.\n// Items arrive either from \"Send WhatsApp Reply\" (has_reply) or straight from \"Has Reply?\" (nothing to send).\nreturn $input.all().map((item, i) => {\n const isApiResponse = !item.json.wa_id; // a WhatsApp API response has no wa_id at the top level\n const x = isApiResponse ? $('Save Turn').itemMatching(i).json : item.json;\n const res = isApiResponse ? item.json : null;\n const id = res?.messages?.[0]?.id ?? null;\n const sendError = x.has_reply && !id\n ? String(res?.error?.message ?? res?.error ?? 'no message id returned (check the WhatsApp credential and the node error in the execution)').slice(0, 300)\n : null;\n return {\n json: {\n wa_id: x.wa_id, message_id: x.message_id, action: x.action, intent: x.intent ?? null,\n reply: x.reply || null, reply_source: x.reply_source, ai_error: x.ai_error,\n sent: Boolean(id), send_error: sendError, whatsapp_message_id: id,\n duplicate: x.duplicate, open_now: x.open_now, handoff: x.handoff, needs_staff: x.needs_staff,\n lead: x.lead, new_lead: x.new_lead, lead_complete: x.lead_complete, awaiting: x.awaiting, via_api: x.via_api,\n },\n };\n});\n"
},
"id": "m20-reply-report",
"name": "Reply Report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2960,
60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.via_api }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m20-from-api",
"name": "From API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
3180,
-60
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { results: $input.all().map(i => i.json) } }}",
"options": {}
},
"id": "m20-respond-report",
"name": "Respond Report",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
3400,
-60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.new_lead }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m20-new-lead",
"name": "New Lead?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
3180,
120
]
},
{
"parameters": {},
"id": "m20-save-lead-exercise-1",
"name": "Save Lead (Exercise 1)",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
3400,
120
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.needs_staff }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m20-needs-staff",
"name": "Needs Staff?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
3180,
300
]
},
{
"parameters": {},
"id": "m20-alert-staff-exercise-2",
"name": "Alert Staff (Exercise 2)",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
3400,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $('Verify API').first().json.query['hub.mode'] === 'subscribe' && $('Verify API').first().json.query['hub.verify_token'] === $('Settings (edit me)').first().json.verify_token }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m20-token-matches",
"name": "Token Matches?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
700,
400
]
},
{
"parameters": {
"respondWith": "text",
"responseBody": "={{ $('Verify API').first().json.query['hub.challenge'] }}",
"options": {}
},
"id": "m20-respond-challenge",
"name": "Respond Challenge",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
360
]
},
{
"parameters": {
"respondWith": "text",
"responseBody": "forbidden",
"options": {
"responseCode": 403
}
},
"id": "m20-respond-forbidden",
"name": "Respond Forbidden",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
500
]
},
{
"parameters": {
"jsCode": "// Read-only view of one conversation (history, lead, flags).\nconst q = $('Conversation API').first().json.query ?? {};\nconst id = String(q.wa_id ?? '').replace(/\\D/g, '');\nconst c = ($getWorkflowStaticData('global').convos ?? {})[id];\nconst now = q.now || new Date().toISOString();\nreturn [{ json: c ? { found: true, ...c, handoff_active: Boolean(c.handoff_until && c.handoff_until > now), window_open: Boolean(c.window_open_until && c.window_open_until > now) } : { found: false, wa_id: id } }];\n"
},
"id": "m20-conversation-status",
"name": "Conversation Status",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
600
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseCode": "={{ $json.found ? 200 : 404 }}"
}
},
"id": "m20-respond-conversation",
"name": "Respond Conversation",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
600
]
},
{
"parameters": {
"jsCode": "// Hand the chat back to the bot (staff call this when they're done).\nconst b = $('Release Handoff API').first().json.body ?? {};\nconst id = String(b.wa_id ?? '').replace(/\\D/g, '');\nconst db = $getWorkflowStaticData('global');\nconst c = (db.convos ?? {})[id];\nif (!c) return [{ json: { found: false, wa_id: id } }];\nconst was = c.handoff_until;\nc.handoff_until = null;\nreturn [{ json: { found: true, wa_id: id, released: true, previous_handoff_until: was } }];\n"
},
"id": "m20-release-handoff",
"name": "Release Handoff",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
780
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseCode": "={{ $json.found ? 200 : 404 }}"
}
},
"id": "m20-respond-release",
"name": "Respond Release",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
780
]
}
],
"connections": {
"WhatsApp Trigger": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Inbound API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Verify API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Conversation API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Release Handoff API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Settings (edit me)": {
"main": [
[
{
"node": "Route",
"type": "main",
"index": 0
}
]
]
},
"Route": {
"main": [
[
{
"node": "Parse Messages",
"type": "main",
"index": 0
}
],
[
{
"node": "Token Matches?",
"type": "main",
"index": 0
}
],
[
{
"node": "Conversation Status",
"type": "main",
"index": 0
}
],
[
{
"node": "Release Handoff",
"type": "main",
"index": 0
}
]
]
},
"Parse Messages": {
"main": [
[
{
"node": "Any Messages?",
"type": "main",
"index": 0
}
]
]
},
"Any Messages?": {
"main": [
[
{
"node": "Conversation State",
"type": "main",
"index": 0
}
],
[
{
"node": "Ignored via API?",
"type": "main",
"index": 0
}
]
]
},
"Ignored via API?": {
"main": [
[
{
"node": "Respond Ignored",
"type": "main",
"index": 0
}
]
]
},
"Conversation State": {
"main": [
[
{
"node": "Use AI?",
"type": "main",
"index": 0
}
]
]
},
"Use AI?": {
"main": [
[
{
"node": "Answer with AI",
"type": "main",
"index": 0
}
],
[
{
"node": "Pick Reply",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "Answer with AI",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Reply Format": {
"ai_outputParser": [
[
{
"node": "Answer with AI",
"type": "ai_outputParser",
"index": 0
}
]
]
},
"Answer with AI": {
"main": [
[
{
"node": "Pick Reply",
"type": "main",
"index": 0
}
]
]
},
"Pick Reply": {
"main": [
[
{
"node": "Save Turn",
"type": "main",
"index": 0
}
]
]
},
"Save Turn": {
"main": [
[
{
"node": "Dry Run?",
"type": "main",
"index": 0
}
]
]
},
"Dry Run?": {
"main": [
[
{
"node": "Respond Plan",
"type": "main",
"index": 0
}
],
[
{
"node": "Has Reply?",
"type": "main",
"index": 0
}
]
]
},
"Has Reply?": {
"main": [
[
{
"node": "Send WhatsApp Reply",
"type": "main",
"index": 0
}
],
[
{
"node": "Reply Report",
"type": "main",
"index": 0
}
]
]
},
"Send WhatsApp Reply": {
"main": [
[
{
"node": "Reply Report",
"type": "main",
"index": 0
}
]
]
},
"Reply Report": {
"main": [
[
{
"node": "From API?",
"type": "main",
"index": 0
},
{
"node": "New Lead?",
"type": "main",
"index": 0
},
{
"node": "Needs Staff?",
"type": "main",
"index": 0
}
]
]
},
"From API?": {
"main": [
[
{
"node": "Respond Report",
"type": "main",
"index": 0
}
]
]
},
"New Lead?": {
"main": [
[
{
"node": "Save Lead (Exercise 1)",
"type": "main",
"index": 0
}
]
]
},
"Needs Staff?": {
"main": [
[
{
"node": "Alert Staff (Exercise 2)",
"type": "main",
"index": 0
}
]
]
},
"Token Matches?": {
"main": [
[
{
"node": "Respond Challenge",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond Forbidden",
"type": "main",
"index": 0
}
]
]
},
"Conversation Status": {
"main": [
[
{
"node": "Respond Conversation",
"type": "main",
"index": 0
}
]
]
},
"Release Handoff": {
"main": [
[
{
"node": "Respond Release",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"pinData": {}
}