Skip to content

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": {}
}