Skip to content

In n8n, copy this file and paste it onto the canvas, or use Import from File.

{
  "name": "25 · AI Lead Qualifier and Booking Agent",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lq/lead",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m25-new-lead-api",
      "name": "New Lead API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "course-25-lead"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lq/reply",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m25-reply-api",
      "name": "Reply API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        180
      ],
      "webhookId": "course-25-reply"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lq/book",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m25-book-api",
      "name": "Book API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        360
      ],
      "webhookId": "course-25-book"
    },
    {
      "parameters": {
        "httpMethod": "GET",
        "path": "lq/lead/status",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m25-lead-status-api",
      "name": "Lead Status API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        600
      ],
      "webhookId": "course-25-status"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lq/cancel",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m25-cancel-api",
      "name": "Cancel API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        780
      ],
      "webhookId": "course-25-cancel"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "s1",
              "name": "business_name",
              "value": "Northstar Automation",
              "type": "string"
            },
            {
              "id": "s2",
              "name": "owner_name",
              "value": "Jordan",
              "type": "string"
            },
            {
              "id": "s3",
              "name": "owner_email",
              "value": "jordan@northstar.example",
              "type": "string"
            },
            {
              "id": "s4",
              "name": "services",
              "value": "={{ [{\"name\": \"AI chatbot\",\"keywords\": [\"chatbot\",\"chat bot\",\"ai assistant\",\"support bot\",\"whatsapp bot\"]},{\"name\": \"CRM integration\",\"keywords\": [\"crm\",\"hubspot\",\"salesforce\",\"pipedrive\",\"gohighlevel\",\"integrat\"]},{\"name\": \"reporting dashboard\",\"keywords\": [\"report\",\"dashboard\",\"kpi\",\"analytics\"]},{\"name\": \"workflow automation\",\"keywords\": [\"automat\",\"workflow\",\"zapier\",\"make.com\",\"n8n\",\"manual\"]}] }}",
              "type": "array"
            },
            {
              "id": "s5",
              "name": "min_budget",
              "value": 3000,
              "type": "number"
            },
            {
              "id": "s6",
              "name": "max_timeline_weeks",
              "value": 12,
              "type": "number"
            },
            {
              "id": "s7",
              "name": "resources_url",
              "value": "https://northstar.example/free-automation-guides",
              "type": "string"
            },
            {
              "id": "s8",
              "name": "calendar_mode",
              "value": "google",
              "type": "string"
            },
            {
              "id": "s9",
              "name": "calendar_id",
              "value": "primary",
              "type": "string"
            },
            {
              "id": "s10",
              "name": "timezone",
              "value": "America/Chicago",
              "type": "string"
            },
            {
              "id": "s11",
              "name": "hours",
              "value": "={{ {\"mon\": {\"open\": 9,\"close\": 17},\"tue\": {\"open\": 9,\"close\": 17},\"wed\": {\"open\": 9,\"close\": 17},\"thu\": {\"open\": 9,\"close\": 17},\"fri\": {\"open\": 9,\"close\": 17} } }}",
              "type": "object"
            },
            {
              "id": "s12",
              "name": "meeting_minutes",
              "value": 30,
              "type": "number"
            },
            {
              "id": "s13",
              "name": "buffer_minutes",
              "value": 15,
              "type": "number"
            },
            {
              "id": "s14",
              "name": "min_notice_hours",
              "value": 4,
              "type": "number"
            },
            {
              "id": "s15",
              "name": "horizon_days",
              "value": 7,
              "type": "number"
            },
            {
              "id": "s16",
              "name": "hold_minutes",
              "value": 30,
              "type": "number"
            },
            {
              "id": "s17",
              "name": "use_ai",
              "value": true,
              "type": "boolean"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "m25-settings-edit-me",
      "name": "Settings (edit me)",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r1",
                    "leftValue": "={{ $('New Lead API').isExecuted || $('Reply API').isExecuted || $('Book 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": "={{ $('Lead Status API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "status"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r3",
                    "leftValue": "={{ $('Cancel API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "cancel"
            }
          ]
        },
        "options": {}
      },
      "id": "m25-route",
      "name": "Route",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        460,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Find or create the lead and add the incoming message. One lead per email address.\nconst s = $('Settings (edit me)').first().json;\nconst which = $('New Lead API').isExecuted ? 'lead' : $('Reply API').isExecuted ? 'reply' : 'book';\nconst req = $(which === 'lead' ? 'New Lead API' : which === 'reply' ? 'Reply API' : 'Book API').first().json;\nconst b = req.body ?? {};\nconst db = $getWorkflowStaticData('global');\ndb.leads ??= {}; db.holds ??= {}; db.bookings ??= {};\nconst now = b.now || new Date().toISOString(); // tests inject the clock\n\nconst fail = (code, error) => [{ json: { ok: false, code, error } }];\nlet lead;\nif (which === 'lead') {\n  const email = String(b.email ?? '').trim().toLowerCase();\n  if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(email)) return fail(400, 'a valid email is required');\n  lead = Object.values(db.leads).find((l) => l.email === email);\n  if (!lead) {\n    const id = 'L-' + Math.random().toString(36).slice(2, 8).toUpperCase();\n    lead = db.leads[id] = { id, email, name: String(b.name ?? '').trim(), company: String(b.company ?? '').trim(), phone: String(b.phone ?? ''),\n      source: String(b.source ?? 'website'), channel: b.channel === 'email' ? 'email' : 'chat', created_at: now,\n      answers: {}, asked: null, status: 'qualifying', score: 0, reasons: [], messages: [], offered: [], booking: null };\n  }\n  // Structured answers from a form count as answers already given\n  for (const k of ['need', 'budget', 'timeline', 'decision_maker']) if (b[k] !== undefined && b[k] !== '') lead.answers[k] = b[k];\n} else {\n  lead = db.leads[String(b.lead_id ?? '')];\n  if (!lead) return fail(404, 'unknown lead_id');\n}\nconst text = String(which === 'book' ? '' : b.message ?? b.text ?? '').trim().slice(0, 2000);\nif (text) lead.messages.push({ from: 'lead', text, at: now });\nlead.updated_at = now;\n\nreturn [{ json: {\n  ok: true, which, lead_id: lead.id, text, slot_id: which === 'book' ? String(b.slot_id ?? '') : null, now,\n  calendar_mode: b.calendar_mode ?? s.calendar_mode, busy_override: Array.isArray(b.busy) ? b.busy : null,\n  asked: lead.asked, answers: lead.answers, offered: lead.offered, status: lead.status,\n  use_ai: s.use_ai && b.ai !== false && Boolean(text) && ['qualifying', 'offered'].includes(lead.status),\n} }];\n"
      },
      "id": "m25-intake",
      "name": "Intake",
      "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.ok }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m25-valid",
      "name": "Valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        920,
        60
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: false, error: $json.error } }}",
        "options": {
          "responseCode": "={{ $json.code }}"
        }
      },
      "id": "m25-respond-error",
      "name": "Respond Error",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1140,
        240
      ]
    },
    {
      "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": "m25-use-ai",
      "name": "Use AI?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1140,
        40
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=You read messages from sales leads for {{ $('Settings (edit me)').first().json.business_name }} and extract facts. Don't write a reply.\n\nServices we offer: {{ $('Settings (edit me)').first().json.services.map(s => s.name).join(', ') }}\nThe question we last asked: {{ $json.asked || '(none, this is the first message)' }}\nTime slots we offered (if any): {{ $json.offered.map(o => o.id + ' = ' + o.label).join('; ') || '(none)' }}\n\nLead's message:\n{{ $json.text }}\n\nReturn only what the message actually says (null when it doesn't say):\n- need: which of our services they want (use the service name exactly), or null\n- budget: their budget as a number in dollars (upper end of a range), or null\n- timeline_weeks: how many weeks until they want it done (asap = 1, next month = 6), or null\n- decision_maker: true if they decide, false if someone else does, or null\n- slot_choice: \"S1\", \"S2\" or \"S3\" if they picked one of the offered times, or null\n- wants_human: true only if they ask to talk to a person",
        "hasOutputParser": true
      },
      "id": "m25-extract-answers",
      "name": "Extract Answers",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.9,
      "position": [
        1360,
        -100
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5-mini",
          "cachedResultName": "gpt-5-mini"
        },
        "options": {}
      },
      "id": "m25-openai-chat-model",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.3,
      "position": [
        1300,
        100
      ]
    },
    {
      "parameters": {
        "schemaType": "fromJson",
        "jsonSchemaExample": "{\n  \"need\": \"CRM integration\",\n  \"budget\": 8000,\n  \"timeline_weeks\": 6,\n  \"decision_maker\": true,\n  \"slot_choice\": null,\n  \"wants_human\": false\n}"
      },
      "id": "m25-answers-format",
      "name": "Answers Format",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.3,
      "position": [
        1460,
        100
      ]
    },
    {
      "parameters": {},
      "id": "m25-join-ai",
      "name": "Join AI",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        1600,
        40
      ]
    },
    {
      "parameters": {
        "jsCode": "// Combine AI extraction (if it ran and returned output) with the rule-based extraction. Code decides; AI only reads.\nconst s = $('Settings (edit me)').first().json;\nconst item = $input.first();\nconst fromAi = item.json.ok === undefined; // the AI chain outputs only { output } or { error }\nconst x = fromAi ? $('Intake').first().json : item.json;\nif (!x.ok) return [{ json: x }];\nconst t = x.text.toLowerCase();\n\n// --- rules (fallback, and for anything the AI left empty)\nconst rules = {};\n// Most keyword hits wins; ties go to the earlier (more specific) service in Settings\nconst svc = s.services.map((sv) => ({ sv, hits: sv.keywords.filter((k) => t.includes(k)).length })).filter((x) => x.hits).sort((a, b) => b.hits - a.hits)[0]?.sv;\nif (svc) rules.need = svc.name;\nconst money = [...t.replace(/,/g, '').matchAll(/\\$\\s?(\\d+(?:\\.\\d+)?)\\s?(k)?|(\\d+(?:\\.\\d+)?)\\s?(k)\\b|(\\d{4,})\\s*(?:usd|dollars)?/g)]\n  .map((m) => Number(m[1] ?? m[3] ?? m[5]) * ((m[2] || m[4]) ? 1000 : 1)).filter((n) => n >= 100);\nif (money.length && (x.asked === 'budget' || /budget|\\$|usd|dollars|\\bk\\b/.test(t))) rules.budget = Math.max(...money);\nconst tl = [[/\\b(asap|right away|immediately|this week|urgent)\\b/, 1], [/\\bnext week\\b/, 2], [/\\bthis month\\b/, 3], [/\\bnext month\\b/, 6],\n  [/\\b(this|next) quarter\\b|\\b(2|3|two|three) months?\\b/, 12], [/\\b(6|six) months?\\b|\\blater this year\\b/, 26], [/\\bnext year\\b|\\bjust (researching|looking|exploring)\\b|\\bno rush\\b/, 52]];\nconst wk = t.match(/\\b(\\d+)\\s*(week|month)s?\\b/);\nfor (const [re, w] of tl) if (re.test(t)) { rules.timeline_weeks = w; break; }\nif (rules.timeline_weeks === undefined && wk && x.asked === 'timeline_weeks') rules.timeline_weeks = Number(wk[1]) * (wk[2] === 'month' ? 4 : 1);\nif (/\\b(i'?m the (owner|founder|ceo|decision maker)|i (decide|make the call|sign off)|yes,? (it'?s|that'?s) me|i am the decision)/.test(t) || (x.asked === 'decision_maker' && /^\\s*(yes|yep|yeah|i do|me)\\b/.test(t)))\n  rules.decision_maker = true;\nelse if (/\\b(my (boss|manager|partner|cfo)|need to (check|ask|run it by)|not the decision|committee)\\b/.test(t) || (x.asked === 'decision_maker' && /^\\s*(no|nope)\\b/.test(t)))\n  rules.decision_maker = false;\nconst wants_human = /\\b(human|real person|talk to (someone|a person)|call me|speak to (someone|a person))\\b/.test(t);\n\nconst ai = fromAi ? item.json.output : null;\nconst clean = (o) => Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v !== null && v !== undefined && v !== ''));\nconst extracted = { ...rules, ...clean(ai ? { need: ai.need, budget: ai.budget, timeline_weeks: ai.timeline_weeks, decision_maker: ai.decision_maker } : {}) };\nreturn [{ json: { ...x, extracted, wants_human: wants_human || ai?.wants_human === true, slot_hint: ai?.slot_choice ?? null,\n  extraction_source: ai ? 'ai' : 'rules', ai_error: fromAi && !ai ? String(item.json.error?.message ?? item.json.error ?? 'no AI output').slice(0, 200) : undefined } }];\n"
      },
      "id": "m25-pick-extraction",
      "name": "Pick Extraction",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1820,
        40
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.calendar_mode !== 'internal' && !$json.busy_override }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m25-need-calendar",
      "name": "Need Calendar?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2040,
        40
      ]
    },
    {
      "parameters": {
        "operation": "getAll",
        "calendar": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Settings (edit me)').first().json.calendar_id }}"
        },
        "returnAll": true,
        "timeMin": "={{ $json.now }}",
        "timeMax": "={{ DateTime.fromISO($json.now).plus({ days: $('Settings (edit me)').first().json.horizon_days + 1 }).toISO() }}",
        "options": {
          "singleEvents": true
        }
      },
      "id": "m25-get-busy-times",
      "name": "Get Busy Times",
      "type": "n8n-nodes-base.googleCalendar",
      "typeVersion": 1.3,
      "position": [
        2260,
        -80
      ],
      "onError": "continueRegularOutput",
      "alwaysOutputData": true
    },
    {
      "parameters": {},
      "id": "m25-join-calendar",
      "name": "Join Calendar",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        2480,
        40
      ]
    },
    {
      "parameters": {
        "jsCode": "// Busy time ranges for slot finding: from the request (tests), the internal calendar, or Google Calendar.\nconst x = $('Pick Extraction').first().json;\nlet busy = [], calendar_error = null, source;\nif (x.busy_override) { busy = x.busy_override; source = 'request'; }\nelse if (x.calendar_mode === 'internal') { source = 'internal'; }\nelse {\n  source = 'google';\n  const items = $('Get Busy Times').all().map((i) => i.json);\n  if (items.some((e) => e.error) || (items.length === 1 && !items[0].start && !items[0].id && Object.keys(items[0]).length > 0 && !items[0].kind)) {\n    calendar_error = String(items.find((e) => e.error)?.error?.message ?? items[0].error ?? 'calendar returned no events list (check the Google Calendar credential)').slice(0, 300);\n  }\n  busy = items.filter((e) => e.start && e.transparency !== 'transparent' && e.status !== 'cancelled')\n    .map((e) => ({ start: e.start.dateTime ?? e.start.date, end: e.end.dateTime ?? e.end.date }));\n}\nreturn [{ json: { ...x, busy, busy_source: source, calendar_error } }];\n"
      },
      "id": "m25-busy-times",
      "name": "Busy Times",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2700,
        40
      ]
    },
    {
      "parameters": {
        "jsCode": "// Decide the next step: ask the next question, offer slots, book a slot, or close politely.\nconst s = $('Settings (edit me)').first().json;\nconst x = $input.first().json;\nif (!x.ok) return [{ json: x }];\nconst db = $getWorkflowStaticData('global');\nconst lead = db.leads[x.lead_id];\nconst now = DateTime.fromISO(x.now);\nconst first = (lead.name || '').split(' ')[0] || 'there';\n\n// Merge answers (budget from a form may be text like \"$5k-10k\")\nconst num = (v) => { if (typeof v === 'number') return v; const m = [...String(v ?? '').toLowerCase().replace(/,/g, '').matchAll(/(\\d+(?:\\.\\d+)?)\\s*(k)?/g)].map((g) => Number(g[1]) * (g[2] ? 1000 : 1)); return m.length ? Math.max(...m) : null; };\nconst a = lead.answers;\nif (!['offered', 'booked'].includes(lead.status)) Object.assign(a, x.extracted); // answers are frozen once slots are offered\nconst toWeeks = (v) => {\n  if (typeof v === 'number') return v;\n  const t = String(v ?? '').toLowerCase();\n  for (const [re, w] of [[/asap|right away|immediately|this week|urgent/, 1], [/next week/, 2], [/this month/, 3], [/next month/, 6], [/quarter/, 12], [/next year|just (researching|looking|exploring)|no rush/, 52]]) if (re.test(t)) return w;\n  const m = t.match(/(\\d+)\\s*(day|week|month)/); if (m) return Math.ceil(Number(m[1]) * { day: 1 / 7, week: 1, month: 4 }[m[2]]);\n  return null;\n};\nif (a.budget !== undefined) a.budget = num(a.budget);\nif (a.timeline !== undefined && (a.timeline_weeks === undefined || a.timeline_weeks === null)) a.timeline_weeks = toWeeks(a.timeline);\nif (typeof a.decision_maker === 'string') a.decision_maker = /^(y|yes|true|1)/i.test(a.decision_maker);\n\n// Score with reasons\nconst reasons = [];\nlet score = 0;\nif (a.need) { score += 30; reasons.push(`need: ${a.need}`); }\nif (a.budget != null) { if (a.budget >= s.min_budget) { score += 30; reasons.push(`budget $${a.budget} ≥ $${s.min_budget}`); } else reasons.push(`budget $${a.budget} below $${s.min_budget}`); }\nif (a.timeline_weeks != null) { if (a.timeline_weeks <= s.max_timeline_weeks) { score += 20; reasons.push(`timeline ${a.timeline_weeks} wk`); } else reasons.push(`timeline ${a.timeline_weeks} wk is later than ${s.max_timeline_weeks}`); }\nif (a.decision_maker === true) { score += 20; reasons.push('decision maker'); } else if (a.decision_maker === false) reasons.push('not the decision maker (invite them to the call)');\nlead.score = score; lead.reasons = reasons;\n\n// --- slots\nconst zone = s.timezone;\nconst fmt = (iso) => DateTime.fromISO(iso).setZone(zone).toFormat(\"ccc LLL d, h:mm a\") + ` (${DateTime.fromISO(iso).setZone(zone).toFormat('ZZZZ')})`;\nconst overlaps = (st, en, b) => st < DateTime.fromISO(b.end).plus({ minutes: s.buffer_minutes }) && en > DateTime.fromISO(b.start).minus({ minutes: s.buffer_minutes });\nfor (const [k, h] of Object.entries(db.holds)) if (DateTime.fromISO(h.until) < now) delete db.holds[k]; // expired holds\nfunction findSlots() {\n  // Bookings and other leads' holds block time just like calendar events (buffer included)\n  const asRange = (k) => ({ start: k, end: DateTime.fromISO(k).plus({ minutes: s.meeting_minutes }).toISO() });\n  const blocked = [...x.busy, ...Object.keys(db.bookings).map(asRange),\n    ...Object.entries(db.holds).filter(([, h]) => h.lead_id !== lead.id).map(([k]) => asRange(k))];\n  const earliest = now.plus({ hours: s.min_notice_hours });\n  const out = [];\n  for (let d = 0; d <= s.horizon_days && out.length < 3; d++) {\n    const day = now.setZone(zone).startOf('day').plus({ days: d });\n    const hrs = s.hours[day.toFormat('ccc').toLowerCase()];\n    if (!hrs) continue;\n    for (let t = day.set({ hour: hrs.open }); t.plus({ minutes: s.meeting_minutes }) <= day.set({ hour: hrs.close }); t = t.plus({ minutes: 30 })) {\n      const en = t.plus({ minutes: s.meeting_minutes });\n      if (t < earliest) continue;\n      const key = t.toUTC().toISO({ suppressMilliseconds: true });\n      if (blocked.some((b) => overlaps(t, en, b))) continue;\n      out.push(key);\n      break; // one slot per day, so the options are spread out\n    }\n  }\n  return out.map((start, i) => ({ id: `S${i + 1}`, start, label: fmt(start) }));\n}\nfunction hold(slots) {\n  for (const [k, h] of Object.entries(db.holds)) if (h.lead_id === lead.id) delete db.holds[k];\n  for (const sl of slots) db.holds[sl.start] = { lead_id: lead.id, until: now.plus({ minutes: s.hold_minutes }).toISO() };\n}\n\n// --- what did they pick?\nfunction pickSlot() {\n  if (!lead.offered.length) return null;\n  if (x.slot_id) return lead.offered.find((o) => o.id === x.slot_id) ?? 'invalid';\n  const t = x.text.toLowerCase();\n  const n = t.match(/^\\s*(?:option\\s*|slot\\s*|s)?([1-3])\\b/) ?? (x.slot_hint ? [null, String(x.slot_hint).replace(/\\D/g, '')] : null);\n  if (n && lead.offered[Number(n[1]) - 1]) return lead.offered[Number(n[1]) - 1];\n  const hit = lead.offered.filter((o) => { const dt = DateTime.fromISO(o.start).setZone(zone);\n    return t.includes(dt.toFormat('cccc').toLowerCase()) || t.includes(dt.toFormat('ccc').toLowerCase() + ' ') || t.includes(dt.toFormat('h:mm a').toLowerCase()) || t.includes(dt.toFormat('ha').toLowerCase()); });\n  return hit.length === 1 ? hit[0] : null;\n}\n\nconst order = ['need', 'budget', 'timeline_weeks', 'decision_maker'];\nconst Q = {\n  need: `Thanks${first !== 'there' ? ', ' + first : ''}! What would you like help with: ${s.services.map((v) => v.name).join(', ')}?`,\n  budget: `Got it. Roughly what budget do you have in mind for this?`,\n  timeline_weeks: `And when would you like to have this up and running?`,\n  decision_maker: `Last one: are you the person who decides on this, or should someone else join the call?`,\n};\nlet action, reply, slots = [], booking = null;\n\nif (x.wants_human) {\n  lead.status = 'handoff'; action = 'handoff';\n  reply = `Of course. I've passed your details to ${s.owner_name}, who will get back to you within one business day.`;\n} else if (lead.status === 'booked') {\n  action = 'already_booked'; reply = `You're booked for ${lead.booking.label}. See you then! Reply here if you need to change it.`;\n} else if (lead.status === 'offered' && (x.which === 'book' || pickSlot())) {\n  const sl = pickSlot();\n  if (!sl || sl === 'invalid') { action = 'error'; reply = 'That time is no longer available.'; x.code = 409; }\n  else if (db.bookings[sl.start] && db.bookings[sl.start] !== lead.id) { action = 'error'; reply = `Sorry, ${sl.label} was just taken. Here are the other times: ${lead.offered.filter((o) => o.id !== sl.id).map((o) => o.id + ': ' + o.label).join(' · ')}`; x.code = 409; }\n  else { action = 'book'; booking = sl; reply = `Booked: ${sl.label}. You'll get a ${x.calendar_mode === 'internal' ? 'confirmation email' : 'calendar invite'} at ${lead.email}.`; }\n} else {\n  const missing = order.find((k) => a[k] === undefined || a[k] === null);\n  if (a.budget != null && a.budget < s.min_budget) {\n    lead.status = 'not_a_fit'; action = 'close';\n    reply = `Thanks for the details. Our projects usually start at $${s.min_budget}, so we may not be the best fit right now. These free resources might help: ${s.resources_url}`;\n  } else if (a.timeline_weeks != null && a.timeline_weeks > s.max_timeline_weeks) {\n    lead.status = 'nurture'; action = 'close'; lead.follow_up_on = now.plus({ weeks: Math.max(1, a.timeline_weeks - s.max_timeline_weeks) }).toISODate();\n    reply = `Thanks! Since you're planning a bit further out, I'll check back in closer to the time. Meanwhile, here are some resources: ${s.resources_url}`;\n  } else if (missing) {\n    lead.asked = missing; action = 'ask'; reply = Q[missing];\n  } else if (x.calendar_error) {\n    lead.status = 'qualified'; action = 'manual_scheduling';\n    reply = `Great, you're a good fit! ${s.owner_name} will email you a few times for a ${s.meeting_minutes}-minute call shortly.`;\n  } else {\n    slots = findSlots();\n    if (!slots.length) { lead.status = 'qualified'; action = 'manual_scheduling'; reply = `Great, you're a good fit! Our calendar is full this week; ${s.owner_name} will email you some times.`; }\n    else {\n      lead.status = 'offered'; lead.offered = slots; hold(slots); action = 'offer';\n      reply = `Great, you're a good fit! Pick a time for a ${s.meeting_minutes}-minute call with ${s.owner_name}:\\n${slots.map((o) => `${o.id.slice(1)}. ${o.label}`).join('\\n')}\\nReply with 1, 2 or 3.`;\n    }\n  }\n}\nlead.messages.push({ from: 'bot', text: reply, at: x.now });\nconst summary = `${lead.name || lead.email}${lead.company ? ' (' + lead.company + ')' : ''} · score ${score}: ${reasons.join('; ')}`;\nreturn [{ json: {\n  ok: action !== 'error', code: x.code ?? 200, lead_id: lead.id, action, reply, status: lead.status, score, reasons, answers: a,\n  slots, booking, calendar_mode: x.calendar_mode, busy_source: x.busy_source, calendar_error: x.calendar_error,\n  extraction_source: x.extraction_source, ai_error: x.ai_error, email: lead.email, name: lead.name, summary,\n  alert_owner: ['handoff', 'manual_scheduling', 'book'].includes(action),\n  event: booking ? { start: booking.start, end: DateTime.fromISO(booking.start).plus({ minutes: s.meeting_minutes }).toUTC().toISO(),\n    summary: `Intro call: ${lead.name || lead.email}${lead.company ? ' (' + lead.company + ')' : ''}`, description: `${summary}\\n\\nConversation:\\n${lead.messages.slice(-8).map((m) => m.from + ': ' + m.text).join('\\n')}` } : null,\n} }];\n"
      },
      "id": "m25-decide-next-step",
      "name": "Decide Next Step",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2920,
        40
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.action === 'book' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m25-book",
      "name": "Book?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3140,
        40
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.calendar_mode !== 'internal' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m25-google-calendar",
      "name": "Google Calendar?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3360,
        -60
      ]
    },
    {
      "parameters": {
        "operation": "create",
        "calendar": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Settings (edit me)').first().json.calendar_id }}"
        },
        "start": "={{ $json.event.start }}",
        "end": "={{ $json.event.end }}",
        "useDefaultReminders": true,
        "additionalFields": {
          "summary": "={{ $json.event.summary }}",
          "description": "={{ $json.event.description }}",
          "attendees": [
            "={{ $json.email }}"
          ],
          "sendUpdates": "all"
        }
      },
      "id": "m25-create-event",
      "name": "Create Event",
      "type": "n8n-nodes-base.googleCalendar",
      "typeVersion": 1.3,
      "position": [
        3580,
        -140
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Confirm the booking: Google mode needs an event id from Google Calendar; internal mode keeps its own calendar.\nconst x = $('Decide Next Step').first().json;\nconst res = $input.first().json;\nconst db = $getWorkflowStaticData('global');\nconst lead = db.leads[x.lead_id];\nconst google = x.calendar_mode !== 'internal';\nconst ok = google ? Boolean(res.id) && !res.lead_id : true; // without a credential the node passes our item through (it has lead_id)\nif (ok) {\n  db.bookings[x.booking.start] = lead.id;\n  for (const [k, h] of Object.entries(db.holds)) if (h.lead_id === lead.id) delete db.holds[k];\n  lead.status = 'booked'; lead.booking = { ...x.booking, event_id: google ? res.id : null, booked_at: x.event.start };\n  lead.offered = [];\n  return [{ json: { ...x, status: 'booked', booked: true, event_id: google ? res.id : null } }];\n}\nconst error = String(res.error?.message ?? res.error ?? 'no event id returned (check the Google Calendar credential)').slice(0, 300);\nlead.messages.push({ from: 'bot', text: '(booking failed, owner alerted)', at: x.event.start });\nreturn [{ json: { ...x, booked: false, booking_error: error, action: 'manual_scheduling', alert_owner: true,\n  reply: `Thanks! I couldn't confirm that time automatically, so ${$('Settings (edit me)').first().json.owner_name} will email you to lock it in.` } }];\n"
      },
      "id": "m25-record-booking",
      "name": "Record Booking",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3800,
        -60
      ]
    },
    {
      "parameters": {},
      "id": "m25-join-outcome",
      "name": "Join Outcome",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        4020,
        40
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ (({ event, summary, ...rest }) => rest)($json) }}",
        "options": {
          "responseCode": "={{ $json.code }}"
        }
      },
      "id": "m25-respond-reply",
      "name": "Respond Reply",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        4240,
        -80
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.alert_owner }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m25-alert-owner",
      "name": "Alert Owner?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        4240,
        80
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Settings (edit me)').first().json.owner_email }}",
        "subject": "={{ ($json.booked ? '📅 Booked: ' : $json.action === 'handoff' ? '🙋 Wants a person: ' : '⏰ Schedule manually: ') + ($json.name || $json.email) }}",
        "emailType": "text",
        "message": "={{ $json.summary + '\\n\\n' + ($json.booking ? 'Time: ' + $json.booking.label + '\\n' : '') + ($json.booking_error || $json.calendar_error ? 'Calendar problem: ' + ($json.booking_error || $json.calendar_error) + '\\n' : '') + 'Lead: ' + $json.email }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "m25-email-owner",
      "name": "Email Owner",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        4460,
        80
      ],
      "onError": "continueRegularOutput",
      "webhookId": "course-25-gmail-owner"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.booked === true && $json.calendar_mode === 'internal' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m25-internal-booking",
      "name": "Internal Booking?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        4240,
        240
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "={{ 'Confirmed: ' + $json.booking.label }}",
        "emailType": "text",
        "message": "={{ 'Hi ' + ($json.name || 'there').split(' ')[0] + ',\\n\\nYou\\'re booked for a ' + $('Settings (edit me)').first().json.meeting_minutes + '-minute call with ' + $('Settings (edit me)').first().json.owner_name + ' on ' + $json.booking.label + '.\\n\\nSee you then!' }}",
        "options": {
          "appendAttribution": false,
          "senderName": "={{ $('Settings (edit me)').first().json.business_name }}"
        }
      },
      "id": "m25-email-confirmation",
      "name": "Email Confirmation",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        4460,
        240
      ],
      "onError": "continueRegularOutput",
      "webhookId": "course-25-gmail-lead"
    },
    {
      "parameters": {
        "jsCode": "// One lead's record (for checking the flow and debugging).\nconst q = $('Lead Status API').first().json.query ?? {};\nconst db = $getWorkflowStaticData('global');\nconst lead = (db.leads ?? {})[String(q.lead_id ?? '')] ?? Object.values(db.leads ?? {}).find((l) => l.email === String(q.email ?? '').toLowerCase());\nreturn [{ json: lead ? { found: true, ...lead, holds: Object.entries(db.holds ?? {}).filter(([, h]) => h.lead_id === lead.id).map(([k]) => k) } : { found: false } }];\n"
      },
      "id": "m25-lead-status",
      "name": "Lead Status",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        600
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": "={{ $json.found ? 200 : 404 }}"
        }
      },
      "id": "m25-respond-status",
      "name": "Respond Status",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        920,
        600
      ]
    },
    {
      "parameters": {
        "jsCode": "// Cancel a booking (or a pending offer): frees the slot and the holds. A Google event, if any, is removed by hand (Exercise 3).\nconst b = $('Cancel API').first().json.body ?? {};\nconst db = $getWorkflowStaticData('global');\nconst lead = (db.leads ?? {})[String(b.lead_id ?? '')];\nif (!lead) return [{ json: { found: false, lead_id: b.lead_id ?? null } }];\nconst freed = [];\nfor (const [k, v] of Object.entries(db.bookings ?? {})) if (v === lead.id) { delete db.bookings[k]; freed.push(k); }\nfor (const [k, h] of Object.entries(db.holds ?? {})) if (h.lead_id === lead.id) delete db.holds[k];\nconst was = lead.status;\nlead.status = 'cancelled'; lead.offered = []; lead.cancelled_at = new Date().toISOString(); lead.cancel_reason = String(b.reason ?? '');\nreturn [{ json: { found: true, lead_id: lead.id, previous_status: was, status: 'cancelled', freed_slots: freed, event_id: lead.booking?.event_id ?? null } }];\n"
      },
      "id": "m25-cancel-booking",
      "name": "Cancel Booking",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        780
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": "={{ $json.found ? 200 : 404 }}"
        }
      },
      "id": "m25-respond-cancel",
      "name": "Respond Cancel",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        920,
        780
      ]
    }
  ],
  "connections": {
    "New Lead API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Book API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lead Status API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cancel API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings (edit me)": {
      "main": [
        [
          {
            "node": "Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route": {
      "main": [
        [
          {
            "node": "Intake",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Lead Status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Cancel Booking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Intake": {
      "main": [
        [
          {
            "node": "Valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Valid?": {
      "main": [
        [
          {
            "node": "Use AI?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use AI?": {
      "main": [
        [
          {
            "node": "Extract Answers",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Join AI",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Extract Answers",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Answers Format": {
      "ai_outputParser": [
        [
          {
            "node": "Extract Answers",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Extract Answers": {
      "main": [
        [
          {
            "node": "Join AI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Join AI": {
      "main": [
        [
          {
            "node": "Pick Extraction",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Extraction": {
      "main": [
        [
          {
            "node": "Need Calendar?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Need Calendar?": {
      "main": [
        [
          {
            "node": "Get Busy Times",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Join Calendar",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Get Busy Times": {
      "main": [
        [
          {
            "node": "Join Calendar",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Join Calendar": {
      "main": [
        [
          {
            "node": "Busy Times",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Busy Times": {
      "main": [
        [
          {
            "node": "Decide Next Step",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide Next Step": {
      "main": [
        [
          {
            "node": "Book?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Book?": {
      "main": [
        [
          {
            "node": "Google Calendar?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Join Outcome",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Google Calendar?": {
      "main": [
        [
          {
            "node": "Create Event",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Record Booking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Event": {
      "main": [
        [
          {
            "node": "Record Booking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Booking": {
      "main": [
        [
          {
            "node": "Join Outcome",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Join Outcome": {
      "main": [
        [
          {
            "node": "Respond Reply",
            "type": "main",
            "index": 0
          },
          {
            "node": "Alert Owner?",
            "type": "main",
            "index": 0
          },
          {
            "node": "Internal Booking?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Alert Owner?": {
      "main": [
        [
          {
            "node": "Email Owner",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Internal Booking?": {
      "main": [
        [
          {
            "node": "Email Confirmation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lead Status": {
      "main": [
        [
          {
            "node": "Respond Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cancel Booking": {
      "main": [
        [
          {
            "node": "Respond Cancel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}