{
  "name": "Book appointments from phone calls with an AI voice receptionist using Claude and Google Sheets",
  "nodes": [
    {
      "parameters": {
        "width": 680,
        "height": 900,
        "content": "## Book appointments from phone calls with an AI voice receptionist\n\n### Who is it for\n\nAny business that takes bookings by phone: clinics, salons, dentists,\nbarbers, physios, garages, tutors. It is built around appointments, so\nit suits booking rather than order taking.\n\n### How it works\n\nThe caller speaks and text arrives at the webhook, from a browser or\nfrom your telephony provider. `Brain` matches the intent against your\nservice list, reads the diary, and offers up to three free slots, one\nper day, so a reply of \"Thursday\" is unambiguous. It understands dates\nand weekdays rather than only \"1\", \"2\", \"3\", because nobody answers a\nphone with an index. `Answer & Speak` rephrases that result for speech\nand answers simple questions from a fixed facts block.\n\nOnly `Brain` touches the diary, so the language model cannot invent an\nappointment. Audio never enters n8n: routing it through a workflow\nengine costs 2-3s per turn, and a voice agent reads as a dead line\npast about 1.2s.\n\n### How to set up\n\n1. Create a Google Sheet with three tabs: `leads`, `appointments` and\n   `message_log`.\n2. Paste its id into the five Google Sheets nodes, replacing\n   `PASTE_YOUR_GOOGLE_SHEET_ID_HERE`.\n3. Add a Google Sheets OAuth credential and an Anthropic API key as\n   Header Auth, header name `x-api-key`.\n4. Check the credential on `Answer & Speak` by hand. n8n attaches the\n   first Header Auth credential alphabetically, which is often the\n   wrong one.\n\n### Requirements\n\nn8n, a Google account, and an Anthropic API key. Deepgram is optional,\nfor neural speech instead of the browser voice.\n\n### How to customize\n\nTwo Code nodes hold everything specific to you, both plain JavaScript\nreturning plain objects.\n\n- `Load Config`: name, address, timezone, opening hours, closed days.\n- `Load Treatments`: your services, with aliases, duration and price.\n\nThe `aliases` field matters most. It is how \"anti wrinkle\", \"forehead\"\nand \"frown lines\" all reach one service. Write what customers say, not\nwhat your price list calls it. Sample data is a fictional med-spa;\nreplace it with your own.\n\nBoth are baked into the workflow rather than read from Sheets on every\nturn. Of a 4.5s reply we measured 3.29s of Sheets reads against 24ms of\nmodel time, so reference data that changes monthly does not belong in\na live call.\n\nFull setup notes and the measurements behind the design:\nhttps://github.com/Wizbit-org/n8n-voice-receptionist"
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1300,
        -160
      ],
      "id": "wizbit-setup-note",
      "name": "Template description"
    },
    {
      "parameters": {
        "content": "## How this works\n\nThe browser does the ears and the mouth. This workflow does the\nthinking. **Only text crosses the wire**: no audio ever reaches n8n,\nbecause routing audio through a workflow engine costs 2-3s a turn and\na voice agent reads as a dead line past about 1.2s.\n\n`POST /webhook/voice-receptionist`\n\n```json\n{ \"sessionId\": \"uuid\", \"text\": \"how much is lip filler\", \"turn\": 3 }\n```\n\nreturns\n\n```json\n{ \"ok\": true, \"reply\": \"...\", \"intent\": \"price\", \"needs_human\": false }\n```\n\nSend it that shape from anything: a web page, Twilio, or a phone\nsystem. The `trace` array in the response reports what the workflow\nactually did on each turn, which is what you want while wiring it up.\n\n**Brain vs Answer & Speak.** The Brain makes every decision that\ntouches the diary. `Answer & Speak` only rephrases the result for a\nmouth. Keep that split: it is the reason a booking cannot be invented\nby the language model.",
        "width": 460,
        "height": 700,
        "color": 7
      },
      "id": "v-note--620--60",
      "name": "Note -620/-60",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -620,
        -60
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "voice-receptionist",
        "responseMode": "responseNode",
        "options": {
          "allowedOrigins": "https://wizbitofficial.com,https://www.wizbitofficial.com,http://localhost:5173"
        }
      },
      "id": "v-webhook",
      "name": "Voice Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -100,
        300
      ],
      "webhookId": "voice-receptionist-webhook"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// =====================================================================\n// GUARD. Everything that keeps this public endpoint from costing money.\n//\n// A marketing page that lets any visitor talk to a paid model is an open\n// door to someone else's bill. Four limits, all cheap:\n//\n//   1. Origin allowlist. `allowedOrigins` on the Webhook node sets CORS,\n//      which stops a browser on another site. It does NOT stop curl.\n//      This check is the one that stops curl.\n//   2. Turns per session — a real enquiry is done well inside 20.\n//   3. Turns per IP per hour — stops one bored visitor looping.\n//      TURNS, not sessions. Sessions is the obvious meter and the wrong\n//      one: a reload starts a new session, and a clinic evaluating this\n//      from one office is a single IP with several people on it. Capping\n//      sessions locks out exactly the visitor we want. Turns is also what\n//      actually costs money, since every turn is one model call.\n//   4. Input length — a 5,000-word \"question\" is an attack, not a caller.\n//\n// State lives in $getWorkflowStaticData('global'), which survives between\n// executions and resets on restart. That is fine: this is a speed bump,\n// not a security boundary. If it ever gets expensive, move it behind\n// Cloudflare.\n// =====================================================================\n\nconst ALLOWED_ORIGINS = [\n  'https://wizbitofficial.com',\n  'https://www.wizbitofficial.com',\n  'http://localhost:5173',\n];\n\nconst MAX_TURNS_PER_SESSION = 20;\nconst MAX_TURNS_PER_IP_HOUR = 80;\nconst MAX_CHARS = 400;\nconst SESSION_TTL_MS = 30 * 60 * 1000;\n\nconst item = $input.first().json;\nconst body = item.body || {};\nconst headers = item.headers || {};\n\nconst origin = headers.origin || headers.referer || '';\nconst ip = headers['x-forwarded-for']\n  ? String(headers['x-forwarded-for']).split(',')[0].trim()\n  : (headers['x-real-ip'] || 'unknown');\n\nconst store = $getWorkflowStaticData('global');\nstore.sessions = store.sessions || {};\nstore.ips = store.ips || {};\n\nconst now = Date.now();\n\n// Prune, or the static data grows until the instance restarts.\nfor (const [k, v] of Object.entries(store.sessions)) {\n  if (now - v.last > SESSION_TTL_MS) delete store.sessions[k];\n}\nfor (const [k, v] of Object.entries(store.ips)) {\n  store.ips[k] = v.filter((t) => now - t < 60 * 60 * 1000);\n  if (!store.ips[k].length) delete store.ips[k];\n}\n\nconst deny = (reason, spoken) => [{\n  json: { blocked: true, reason, reply: spoken, intent: 'blocked', needs_human: false, ended: true },\n}];\n\nif (!ALLOWED_ORIGINS.some((o) => origin.startsWith(o))) {\n  return deny('bad-origin', 'This demo only runs on the Wizbit website.');\n}\n\nconst sessionId = String(body.sessionId || '').slice(0, 80);\nlet text = String(body.text || '').trim();\n\nif (!sessionId || !text) return deny('bad-request', 'I did not catch that. Try again.');\nif (text.length > MAX_CHARS) text = text.slice(0, MAX_CHARS);\n\nconst sess = store.sessions[sessionId] || { turns: 0, started: now, last: now, ip };\nsess.turns += 1;\nsess.last = now;\nstore.sessions[sessionId] = sess;\n\nif (sess.turns > MAX_TURNS_PER_SESSION) {\n  return deny('turn-cap',\n    'That is as far as the demo goes. Book a call and we will run it against your own diary.');\n}\n\nstore.ips[ip] = store.ips[ip] || [];\nstore.ips[ip].push(now);\nif (store.ips[ip].length > MAX_TURNS_PER_IP_HOUR) {\n  return deny('ip-cap', 'You have had a good go at this one. Book a call to see it on your own numbers.');\n}\n\nreturn [{\n  json: {\n    blocked: false,\n    sessionId,\n    text,\n    turn: sess.turns,\n    ip,\n    origin,\n    receivedAt: new Date(now).toISOString(),\n  },\n}];\n"
      },
      "id": "v-guard",
      "name": "Guard",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        120,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "version": 2
          },
          "conditions": [
            {
              "id": "blocked-check",
              "leftValue": "={{ $json.blocked }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "false",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "v-allowed",
      "name": "Allowed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        340,
        300
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: false, reply: $json.reply, intent: $json.intent, needs_human: false, ended: true }) }}",
        "options": {}
      },
      "id": "v-refuse",
      "name": "Refuse politely",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        560,
        520
      ],
      "notes": "A blocked caller still hears a sentence rather than silence."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Baked snapshot of the clinic's `config` tab. See the generator's\n// comment for why this is not a live Sheets read: it cost 835ms per turn\n// to fetch data that changes once a month.\n// Edit this node directly to change your own config.\nreturn [{\"key\":\"BIZ_CLINIC_ID\",\"value\":\"demo-clinic\"},{\"key\":\"BIZ_CLINIC_NAME\",\"value\":\"Radiance Med-Spa\"},{\"key\":\"BIZ_ADDRESS\",\"value\":\"Radiance Med-Spa, Jumeirah Beach Road, Dubai\"},{\"key\":\"BIZ_TZ\",\"value\":\"Asia/Dubai\"},{\"key\":\"BIZ_COUNTRY_CODE\",\"value\":\"971\"},{\"key\":\"BIZ_STAFF_PHONE\",\"value\":\"10000000000\"},{\"key\":\"BIZ_REVIEW_LINK\",\"value\":\"https://g.page/r/PLACEHOLDER/review\"},{\"key\":\"BIZ_OPEN_HOUR\",\"value\":\"10\"},{\"key\":\"BIZ_CLOSE_HOUR\",\"value\":\"19\"},{\"key\":\"BIZ_CLOSED_DAYS\",\"value\":\"Sun\"},{\"key\":\"BIZ_QUIET_START\",\"value\":\"21\"},{\"key\":\"BIZ_QUIET_END\",\"value\":\"9\"},{\"key\":\"BIZ_DEPOSIT_ENABLED\",\"value\":\"false\"},{\"key\":\"BIZ_DEPOSIT_LINK\",\"value\":\"https://buy.stripe.com/PLACEHOLDER?client_reference_id=\"}].map((json) => ({ json }));\n"
      },
      "id": "v-load-config",
      "name": "Load Config",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        560,
        280
      ],
      "notes": "Baked snapshot, not a Sheets read. 835ms/turn saved. Regenerate after editing the sheet."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// =====================================================================\n// THE IDENTITY PROBLEM.\n//\n// A web caller has no phone number, and the phone number is the join key\n// for the entire system — leads, appointments and the message log are all\n// keyed on it, and normPhone() strips every non-digit, so a key\n// like \"voice:<uuid>\" would collapse into garbage digits and silently\n// fork lead rows.\n//\n// So each browser session gets a stable synthetic number derived from its\n// session id, in the 9009 range. Real UAE numbers start 971, so these can\n// never collide, and a glance at the leads tab tells you which rows came\n// from the website demo rather than from a real WhatsApp enquiry.\n//\n// This is its own node, ahead of Find Lead, so that Normalize Inbound can\n// sit AFTER the lead lookup and see the conversation state. See its note.\n// =====================================================================\n\nconst g = $('Guard').first().json;\n\nfunction synthPhone(sessionId) {\n  // FNV-1a, 32-bit. Deterministic, so the same session keeps the same row\n  // across turns — which is what gives the conversation a memory.\n  let h = 0x811c9dc5;\n  for (let i = 0; i < sessionId.length; i++) {\n    h ^= sessionId.charCodeAt(i);\n    h = Math.imul(h, 0x01000193) >>> 0;\n  }\n  return '9009' + String(h).padStart(10, '0').slice(0, 10);\n}\n\nreturn [{ json: { phone: synthPhone(String(g.sessionId || '')), text: g.text } }];\n"
      },
      "id": "v-voice-identity",
      "name": "Voice Identity",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        780,
        280
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "PASTE_YOUR_GOOGLE_SHEET_ID_HERE",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "leads",
          "mode": "name"
        },
        "options": {},
        "filtersUI": {
          "values": [
            {
              "lookupColumn": "phone",
              "lookupValue": "={{ $('Voice Identity').item.json.phone }}"
            }
          ]
        }
      },
      "id": "v-find-lead",
      "name": "Find Lead",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        1000,
        280
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "Google Sheets account"
        }
      },
      "onError": "continueRegularOutput",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// =====================================================================\n// Normalize Inbound  —  runs inside n8n as a Code node.\n//\n// THIS FILE IS COPIED VERBATIM INTO THE GENERATED WORKFLOW.\n// It lives on disk rather than inside a template literal in the generator\n// because regex literals do not survive that trip: \\d, \\s and \\b all lost\n// their backslashes on the way through and silently became the letters\n// d and s and a backspace byte. Three deploys were burned on it. One\n// layer, no escaping, no surprises.\n//\n// Output shape must match what the Brain below expects, because\n// the Brain reads it by name and expects\n// { phone, text, text_lc, profile_name, received_at }.\n// =====================================================================\n\nconst g = $('Guard').first().json;\nconst id = $('Voice Identity').first().json;\n\n// Clinic timezone, so \"Thursday\" means Thursday in Dubai and not in UTC.\n// Same config the Brain reads.\nconst CFG = {};\ntry {\n  for (const r of $('Load Config').all()) {\n    if (r.json && r.json.key) CFG[String(r.json.key).trim()] = r.json.value;\n  }\n} catch (e) { /* fall through to the default */ }\nconst cfg = (k, d) =>\n  (CFG[k] !== undefined && String(CFG[k]).trim() !== '' ? String(CFG[k]).trim() : d);\n\nconst leadRows = $('Find Lead').all().map((i) => i.json).filter((r) => r && r.phone);\nconst lead = leadRows.length ? leadRows[0] : null;\nconst awaiting = lead ? String(lead.awaiting || '') : '';\n\nconst raw = String(g.text || '').trim();\nlet text = raw;\nconst lc = raw.toLowerCase().replace(/[.,!?]/g, '').trim();\n\n// Normalise rather than enumerate. Listing every phrasing (\"second one\",\n// \"the second\", \"the second one\", \"number two\"...) is how you end up with\n// a list that is always missing the one the caller just said.\nconst ORDINALS = {\n  '1': 1, one: 1, first: 1,\n  '2': 2, two: 2, second: 2,\n  '3': 3, three: 3, third: 3,\n  '4': 4, four: 4, fourth: 4,\n  '5': 5, five: 5, fifth: 5,\n};\n\nconst AFFIRMATIVE = /^(yes|yeah|yep|yup|sure|ok|okay|please|go ahead|sounds good|that works|why not|absolutely|definitely|lets do it|let's do it|go on|book it|book me in|i would|i'd like that|yes please|yes thanks)$/;\n\n// Spelled-out days, because speech recognition writes them as words.\nconst WORD_DAYS = {\n  first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6, seventh: 7,\n  eighth: 8, ninth: 9, tenth: 10, eleventh: 11, twelfth: 12, thirteenth: 13,\n  fourteenth: 14, fifteenth: 15, sixteenth: 16, seventeenth: 17,\n  eighteenth: 18, nineteenth: 19, twentieth: 20, thirtieth: 30,\n};\n\n/**\n * Which of the offered slots did the caller mean?\n *\n * THE BUG THIS FIXES: a caller offered three slots asked for \"27 August\"\n * and got the 28th. The Brain only ever understood \"1\", \"2\" or \"3\" —\n * numeric = txt.replace(/[^0-9]/g, '') and then an index lookup — so\n * \"27 august\" matched nothing, fell through, and whatever happened next\n * bore no relation to the date the caller actually named.\n *\n * People do not answer a phone call with an index. They say \"Thursday\",\n * or \"the twenty-seventh\", or \"the tenth of September\".\n *\n * This is exact rather than fuzzy: pending_slots on the lead row holds\n * the real ISO timestamps that were offered, and openSlots() emits at\n * most one slot per day, so a day number or a weekday identifies exactly\n * one of them. A match is only accepted when it is unambiguous — booking\n * the wrong day because two slots half-matched is the failure we are\n * removing, not a different flavour of it.\n */\nfunction matchSlot(said, slots, tz) {\n  const part = (iso, opts) =>\n    new Intl.DateTimeFormat('en-GB', Object.assign({ timeZone: tz }, opts))\n      .format(new Date(iso));\n\n  let spoken = said.replace(/(\\d+)(st|nd|rd|th)\\b/g, '$1');\n  spoken = spoken.replace(\n    /twenty[\\s-]?(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth)/g,\n    (_, w) => String(20 + (WORD_DAYS[w] || 0))\n  );\n  spoken = spoken.replace(/thirty[\\s-]?first/g, '31');\n  for (const [w, n] of Object.entries(WORD_DAYS)) {\n    spoken = spoken.replace(new RegExp('\\\\b' + w + '\\\\b', 'g'), String(n));\n  }\n\n  const numbers = new Set((spoken.match(/\\d{1,2}/g) || []).map(Number));\n  const hits = [];\n\n  slots.forEach((iso, i) => {\n    const dayNum = Number(part(iso, { day: 'numeric' }));\n    const weekday = part(iso, { weekday: 'long' }).toLowerCase();\n    const byDay = numbers.has(dayNum);\n    const byWeekday = said.includes(weekday) || said.includes(weekday.slice(0, 3));\n    if (byDay || byWeekday) hits.push(i + 1);\n  });\n\n  const unique = [...new Set(hits)];\n  return unique.length === 1 ? unique[0] : null;\n}\n\nif (awaiting === 'slot_pick' || awaiting === 'treatment_menu') {\n  const key = lc\n    .replace(/^(yes|yeah|ok|okay|sure|please)[,\\s]+/, '')   // \"yes, the second one\"\n    .replace(/^(the|number|option|slot)\\s+/, '')            // \"the second\", \"number two\"\n    .replace(/\\s+(one|slot|option)$/, '')                   // \"second one\" -> \"second\"\n    .trim();\n  const max = awaiting === 'slot_pick' ? 3 : 5;\n\n  // 1. An index, if that is plainly what they said.\n  const hit = ORDINALS[key] !== undefined ? ORDINALS[key] : ORDINALS[lc];\n  if (hit && hit <= max) {\n    text = String(hit);\n  } else if (awaiting === 'slot_pick') {\n    // 2. Otherwise match a day or a weekday against the real slot times.\n    let slots = [];\n    try { slots = JSON.parse((lead && lead.pending_slots) || '[]'); } catch (e) { slots = []; }\n    if (slots.length) {\n      const picked = matchSlot(lc, slots, cfg('BIZ_TZ', 'Asia/Dubai'));\n      if (picked) text = String(picked);\n    }\n  }\n} else if (AFFIRMATIVE.test(lc)) {\n  // \"Would you like me to check availability?\" — \"yes please\".\n  // The Brain's vocabulary is WhatsApp's: it waits for the literal word\n  // BOOK, and nobody says that out loud.\n  text = 'BOOK';\n}\n\nreturn [{\n  json: {\n    phone: id.phone,\n    text,\n    text_lc: text.toLowerCase(),\n    profile_name: '',\n    received_at: g.receivedAt || new Date().toISOString(),\n    spoken_original: raw,\n    mapped: text !== raw,\n  },\n}];\n"
      },
      "id": "v-normalize-inbound",
      "name": "Normalize Inbound",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1220,
        280
      ],
      "notes": "Maps spoken language onto the WhatsApp keywords the Brain understands. Without this, \"yes please\" is an unknown intent and every caller gets handed to a human."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Baked snapshot of the clinic's `treatments` tab - 799ms per turn saved.\n// Only the fields the Brain reads. Regenerate after editing the sheet.\nreturn [{\"key\":\"consult\",\"display_name\":\"Free Consultation\",\"aliases\":\"consult, consultation, advice, assessment, first visit\",\"duration_min\":\"20\",\"price\":\"0\",\"deposit\":\"0\",\"rebook_days\":\"0\"},{\"key\":\"botox\",\"display_name\":\"Botox / Anti-Wrinkle\",\"aliases\":\"botox, anti wrinkle, antiwrinkle, wrinkle, forehead, frown lines, crows feet\",\"duration_min\":\"45\",\"price\":\"1200\",\"deposit\":\"200\",\"rebook_days\":\"100\"},{\"key\":\"filler\",\"display_name\":\"Dermal Filler\",\"aliases\":\"filler, fillers, lip filler, lips, cheek filler, juvederm, restylane\",\"duration_min\":\"60\",\"price\":\"2200\",\"deposit\":\"400\",\"rebook_days\":\"270\"},{\"key\":\"hydrafacial\",\"display_name\":\"HydraFacial\",\"aliases\":\"hydrafacial, hydra facial, facial, deep cleanse, glow\",\"duration_min\":\"45\",\"price\":\"750\",\"deposit\":\"100\",\"rebook_days\":\"30\"},{\"key\":\"laser_hair_removal\",\"display_name\":\"Laser Hair Removal\",\"aliases\":\"laser, hair removal, lhr, laser hair, underarm, brazilian, full body laser\",\"duration_min\":\"30\",\"price\":\"500\",\"deposit\":\"100\",\"rebook_days\":\"42\"},{\"key\":\"chemical_peel\",\"display_name\":\"Chemical Peel\",\"aliases\":\"peel, chemical peel, pigmentation, melasma, acne scars\",\"duration_min\":\"45\",\"price\":\"900\",\"deposit\":\"150\",\"rebook_days\":\"42\"},{\"key\":\"microneedling\",\"display_name\":\"Microneedling / RF\",\"aliases\":\"microneedling, micro needling, rf, skin booster, collagen, scars\",\"duration_min\":\"60\",\"price\":\"1400\",\"deposit\":\"250\",\"rebook_days\":\"42\"},{\"key\":\"body_contouring\",\"display_name\":\"Body Contouring\",\"aliases\":\"body contouring, coolsculpting, fat freezing, cavitation, slimming, inch loss\",\"duration_min\":\"75\",\"price\":\"1800\",\"deposit\":\"300\",\"rebook_days\":\"60\"}].map((json) => ({ json }));\n"
      },
      "id": "v-load-treatments",
      "name": "Load Treatments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1440,
        280
      ],
      "notes": "Baked snapshot, not a Sheets read. 799ms/turn saved. Regenerate after editing the sheet."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "PASTE_YOUR_GOOGLE_SHEET_ID_HERE",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "appointments",
          "mode": "name"
        },
        "options": {}
      },
      "id": "v-load-appointments",
      "name": "Load Appointments",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        1660,
        280
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "Google Sheets account"
        }
      },
      "onError": "continueRegularOutput",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// ============================================================\n// THE BRAIN. Every conversational decision the clinic makes lives here.\n// Downstream nodes only do I/O — send the message, write the rows.\n// To upgrade to an LLM later, swap this one node; the contract it returns\n// (intent / reply / lead_updates / appointment) stays identical.\n// ============================================================\n\n// Clinic settings come from the `config` tab of the sheet, falling back to the\n// default passed at each call site. Deliberately not environment variables:\n// n8n Cloud never exposes them to Code nodes, and reading them throws in a way\n// this node cannot catch. One mechanism, identical behaviour everywhere, and\n// the clinic can edit their own settings without anyone opening n8n.\nconst CFG = (function () {\n  const m = {};\n  try {\n    for (const r of $('Load Config').all()) {\n      if (r.json && r.json.key) m[String(r.json.key).trim()] = r.json.value;\n    }\n  } catch (e) { /* node absent or empty — fall through to env/defaults */ }\n  return m;\n})();\n\nfunction cfg(key, dflt) {\n  const v = CFG[key];\n  return v !== undefined && v !== null && String(v).trim() !== '' ? String(v).trim() : dflt;\n}\n\nconst TZ = cfg('BIZ_TZ', 'Asia/Dubai');\nconst CLINIC = cfg('BIZ_CLINIC_NAME', 'Radiance Med-Spa');\nconst OPEN_HOUR = Number(cfg('BIZ_OPEN_HOUR', 10));\nconst CLOSE_HOUR = Number(cfg('BIZ_CLOSE_HOUR', 19));\nconst CLOSED_DAYS = String(cfg('BIZ_CLOSED_DAYS', 'Sun')).split(',').map(s => s.trim());\nconst DEPOSIT_ON = String(cfg('BIZ_DEPOSIT_ENABLED', 'false')) === 'true';\nconst DEPOSIT_LINK = cfg('BIZ_DEPOSIT_LINK', '');\nconst ADDRESS = cfg('BIZ_ADDRESS', 'our clinic');\n\nconst inb = $('Normalize Inbound').first().json;\nconst txt = inb.text_lc;\n\nconst treatments = $('Load Treatments').all().map(i => i.json).filter(t => t && t.key);\n// The lookup node continues on error, so a failed read arrives as an item\n// carrying `error` rather than as a thrown exception. Treating that as \"no\n// such lead\" is the dangerous reading: it silently discards the conversation\n// state, so a slot pick becomes an unrecognised message and the client gets\n// greeted by their WhatsApp profile name instead of the one on file.\nconst leadItems = $('Find Lead').all().map((i) => i.json);\nconst lookupFailed = leadItems.some((r) => r && r.error && !r.phone);\nconst leadRows = leadItems.filter((r) => r && r.phone);\nconst lead = leadRows.length ? leadRows[0] : null;\n\nif (lookupFailed) {\n  // Bail out without touching stored state. Writing only `phone` means the\n  // upsert matches the existing row and overwrites nothing.\n  return [{\n    json: {\n      intent: 'lookup_failed',\n      reply:\n        'Sorry ' + (inb.profile_name || 'there') + ', I’m having a brief hiccup on my ' +\n        'end. Could you send that again in a few seconds? I don’t want to lose your place.',\n      should_reply: true,\n      handoff: true,\n      handoff_note: 'Lead lookup failed (Google Sheets error) — conversation state not read',\n      inbound_text: inb.text,\n      phone: inb.phone,\n      name: inb.profile_name || 'there',\n      lead: { phone: inb.phone },\n      has_appointment: false,\n      appointment: null,\n      has_appt_update: false,\n      appt_update: null,\n    },\n  }];\n}\n\nconst allAppts = $('Load Appointments').all().map(i => i.json).filter(a => a && a.starts_at);\nconst myAppts = allAppts.filter(a => String(a.phone) === String(inb.phone));\nconst liveStatuses = ['pending', 'confirmed'];\nconst upcoming = myAppts\n  .filter(a => liveStatuses.includes(String(a.status)) && new Date(a.starts_at).getTime() > Date.now())\n  .sort((x, y) => new Date(x.starts_at) - new Date(y.starts_at))[0] || null;\n\nconst name = (lead && lead.name) || inb.profile_name || 'there';\n\n// ---------- helpers ----------\nfunction tPart(d, opts) { return new Intl.DateTimeFormat('en-GB', Object.assign({ timeZone: TZ }, opts)).format(d); }\nfunction localHour(d) { return Number(tPart(d, { hour: '2-digit', hour12: false })); }\nfunction localDay(d) { return tPart(d, { weekday: 'short' }); }\nfunction pretty(d) { return tPart(d, { weekday: 'long', day: 'numeric', month: 'short' }) + ' at ' + tPart(d, { hour: 'numeric', minute: '2-digit', hour12: true }); }\n\nfunction findTreatment(key) { return treatments.find(t => String(t.key) === String(key)) || null; }\n\nfunction matchTreatmentText(s) {\n  let best = null, bestLen = 0;\n  for (const t of treatments) {\n    const aliases = String(t.aliases || '').split(',').map(a => a.trim().toLowerCase()).filter(Boolean);\n    aliases.push(String(t.key).toLowerCase().replace(/_/g, ' '));\n    for (const a of aliases) {\n      if (a && s.includes(a) && a.length > bestLen) { best = t; bestLen = a.length; }\n    }\n  }\n  return best;\n}\n\n// Open slots = clinic hours, minus anything already in the appointments tab.\n// The sheet IS the calendar, so this needs no Google Calendar credential.\nfunction openSlots(durMin, howMany) {\n  const dur = Math.max(15, Number(durMin) || 45);\n  const busy = allAppts\n    .filter(a => liveStatuses.includes(String(a.status)))\n    .map(a => [new Date(a.starts_at).getTime(), new Date(a.ends_at || a.starts_at).getTime()]);\n  const out = [];\n  const seenDays = new Set();\n  // Start 3h out so we never offer a slot the client cannot physically reach.\n  let cur = new Date(Date.now() + 3 * 60 * 60 * 1000);\n  cur.setSeconds(0, 0);\n  cur.setMinutes(cur.getMinutes() > 30 ? 60 : 30);\n  const limit = Date.now() + 14 * 24 * 60 * 60 * 1000;\n  while (cur.getTime() < limit && out.length < howMany) {\n    const h = localHour(cur);\n    const day = localDay(cur);\n    const dayKey = tPart(cur, { day: '2-digit', month: '2-digit' });\n    const endsOk = h >= OPEN_HOUR && (h + dur / 60) <= CLOSE_HOUR;\n    if (endsOk && !CLOSED_DAYS.includes(day) && !seenDays.has(dayKey)) {\n      const s = cur.getTime();\n      const e = s + dur * 60000;\n      const clash = busy.some(b => s < b[1] && e > b[0]);\n      if (!clash) { out.push(new Date(s).toISOString()); seenDays.add(dayKey); }\n    }\n    cur = new Date(cur.getTime() + 30 * 60000);\n  }\n  return out;\n}\n\nfunction slotMessage(who, treatment, slots) {\n  let m = 'Great choice, ' + who + '! Here are the next available slots for ' + treatment.display_name + ':\\n\\n';\n  slots.forEach((s, i) => { m += (i + 1) + '\\ufe0f\\u20e3 ' + pretty(new Date(s)) + '\\n'; });\n  m += '\\nReply with 1, 2 or 3 to book it. If none of these work, tell me a day that does.';\n  return m;\n}\n\n// ---------- decision ----------\nconst awaiting = String((lead && lead.awaiting) || '');\nlet pendingSlots = [];\ntry { pendingSlots = JSON.parse((lead && lead.pending_slots) || '[]'); } catch (e) { pendingSlots = []; }\n\nlet intent = 'unknown';\nlet reply = '';\nlet leadUpdates = {};\nlet appointment = null;\nlet apptUpdate = null;\nlet handoff = false;\nlet handoffNote = '';\n\nconst has = (arr) => arr.some(k => txt.includes(k));\nconst numeric = txt.replace(/[^0-9]/g, '');\n\nif (has(['stop', 'unsubscribe', 'remove me', 'opt out', 'optout', 'do not message', 'dont message', 'leave me alone'])) {\n  // Compliance first — this branch outranks everything else, always.\n  intent = 'opt_out';\n  reply = 'No problem, ' + name + ' — you\\u2019ve been unsubscribed and won\\u2019t receive any more messages from us. If you ever change your mind, just message us here. Take care!';\n  leadUpdates = { opted_out: 'yes', status: 'opted_out', awaiting: '', next_action_at: '', pending_slots: '' };\n\n} else if (awaiting === 'slot_pick' && ['1', '2', '3'].includes(numeric) && pendingSlots[Number(numeric) - 1]) {\n  intent = 'book_slot';\n  const chosen = pendingSlots[Number(numeric) - 1];\n  const t = findTreatment(lead.treatment_interest) || treatments[0];\n  const start = new Date(chosen);\n  const end = new Date(start.getTime() + (Number(t.duration_min) || 45) * 60000);\n  appointment = {\n    appt_id: 'A' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5),\n    lead_id: lead.lead_id || '',\n    phone: inb.phone,\n    name: name,\n    treatment: t.key,\n    treatment_name: t.display_name,\n    starts_at: start.toISOString(),\n    ends_at: end.toISOString(),\n    status: 'pending',\n    deposit_status: DEPOSIT_ON && Number(t.deposit) > 0 ? 'unpaid' : 'n/a',\n    created_at: new Date().toISOString()\n  };\n  reply = 'Booked! \\u2705\\n\\n' + t.display_name + '\\n' + pretty(start) + '\\n' + ADDRESS + '\\n\\n';\n  if (DEPOSIT_ON && Number(t.deposit) > 0) {\n    reply += 'To secure the slot please pay the ' + t.deposit + ' AED deposit here (it comes off your total):\\n' + DEPOSIT_LINK + inb.phone + '\\n\\n';\n  }\n  reply += 'I\\u2019ll send you a reminder and everything you need to do beforehand. Need to change it? Just message me here.';\n  leadUpdates = { status: 'booked', awaiting: '', pending_slots: '', next_action_at: '', treatment_interest: t.key };\n\n} else if (awaiting === 'treatment_menu' && ['1', '2', '3', '4', '5'].includes(numeric)) {\n  intent = 'pick_treatment';\n  const menu = ['botox', 'filler', 'hydrafacial', 'laser_hair_removal', 'consult'];\n  const t = findTreatment(menu[Number(numeric) - 1]) || treatments[0];\n  const slots = openSlots(t.duration_min, 3);\n  if (slots.length) {\n    reply = slotMessage(name, t, slots);\n    leadUpdates = { treatment_interest: t.key, awaiting: 'slot_pick', pending_slots: JSON.stringify(slots), status: 'qualifying' };\n  } else {\n    reply = 'We\\u2019re fully booked for ' + t.display_name + ' over the next two weeks. Let me get a team member to find you something \\u2014 one moment.';\n    handoff = true; handoffNote = 'No slots available for ' + t.display_name;\n    leadUpdates = { treatment_interest: t.key, awaiting: '', status: 'needs_human' };\n  }\n\n} else if (upcoming && has(['cancel', 'can\\u2019t make', 'cant make', 'cannot make', 'not coming', 'wont make', 'won\\u2019t make'])) {\n  intent = 'cancel';\n  apptUpdate = { appt_id: upcoming.appt_id, status: 'cancelled' };\n  const t = findTreatment(upcoming.treatment) || treatments[0];\n  const slots = openSlots(t.duration_min, 3);\n  reply = 'No problem ' + name + ', I\\u2019ve cancelled your ' + (upcoming.treatment_name || t.display_name) + ' on ' + pretty(new Date(upcoming.starts_at)) + '.\\n\\n';\n  if (slots.length) {\n    reply += 'Want to move it instead? Here\\u2019s what\\u2019s open:\\n\\n' + slots.map((s, i) => (i + 1) + '\\ufe0f\\u20e3 ' + pretty(new Date(s))).join('\\n') + '\\n\\nReply 1, 2 or 3 \\u2014 or ignore this and we\\u2019ll catch you next time.';\n    leadUpdates = { status: 'cancelled_rebooking', awaiting: 'slot_pick', pending_slots: JSON.stringify(slots) };\n  } else {\n    leadUpdates = { status: 'cancelled', awaiting: '' };\n  }\n\n} else if (upcoming && has(['reschedule', 'another time', 'change the time', 'change my appointment', 'move my appointment', 'different day'])) {\n  intent = 'reschedule';\n  apptUpdate = { appt_id: upcoming.appt_id, status: 'rescheduled' };\n  const t = findTreatment(upcoming.treatment) || treatments[0];\n  const slots = openSlots(t.duration_min, 3);\n  reply = 'Of course, ' + name + '. Here\\u2019s what else is open:\\n\\n' + slots.map((s, i) => (i + 1) + '\\ufe0f\\u20e3 ' + pretty(new Date(s))).join('\\n') + '\\n\\nReply 1, 2 or 3 and I\\u2019ll move you across.';\n  leadUpdates = { status: 'rebooking', awaiting: 'slot_pick', pending_slots: JSON.stringify(slots) };\n\n// \"ok\" is how most people actually answer a reminder. Without it those\n// replies fall through to `unknown` and get escalated to a human, which\n// defeats the point of a one-tap confirmation. Only counts as a confirm when\n// there is an appointment to confirm, so it can't hijack other conversations.\n} else if (upcoming && has(['confirm', 'yes', 'yep', 'yes please', 'see you', 'coming', 'i\\u2019ll be there', 'ill be there', 'ok', 'okay', 'sure', 'noted', 'perfect'])) {\n  intent = 'confirm';\n  apptUpdate = { appt_id: upcoming.appt_id, status: 'confirmed' };\n  reply = 'Perfect \\u2014 you\\u2019re confirmed for ' + pretty(new Date(upcoming.starts_at)) + '. See you soon, ' + name + '! \\u2728';\n  leadUpdates = { status: 'booked', awaiting: '' };\n\n} else if (has(['price', 'cost', 'how much', 'rate', 'charges', 'package'])) {\n  intent = 'price';\n  const t = matchTreatmentText(txt) || findTreatment(lead && lead.treatment_interest);\n  if (t) {\n    reply = t.display_name + ' is ' + t.price + ' AED and takes around ' + t.duration_min + ' minutes.\\n\\nWant me to check availability? Reply BOOK and I\\u2019ll send you the next open slots.';\n    leadUpdates = { treatment_interest: t.key, awaiting: '', status: 'qualifying' };\n  } else {\n    const list = treatments.filter(x => Number(x.price) > 0).slice(0, 6)\n      .map(x => '\\u2022 ' + x.display_name + ' \\u2014 ' + x.price + ' AED').join('\\n');\n    reply = 'Here\\u2019s our pricing, ' + name + ':\\n\\n' + list + '\\n\\nWhich one were you thinking about? I can check availability straight away.';\n    leadUpdates = { status: 'qualifying' };\n  }\n\n} else if (has(['book', 'appointment', 'available', 'availability', 'slot', 'when can i', 'schedule'])) {\n  intent = 'book_request';\n  const t = matchTreatmentText(txt) || findTreatment(lead && lead.treatment_interest);\n  if (t) {\n    const slots = openSlots(t.duration_min, 3);\n    reply = slots.length ? slotMessage(name, t, slots)\n      : 'We\\u2019re fully booked for ' + t.display_name + ' right now \\u2014 let me get someone to sort you out personally.';\n    leadUpdates = slots.length\n      ? { treatment_interest: t.key, awaiting: 'slot_pick', pending_slots: JSON.stringify(slots), status: 'qualifying' }\n      : { treatment_interest: t.key, status: 'needs_human' };\n    handoff = !slots.length;\n  } else {\n    reply = 'Happy to book you in, ' + name + '! Which treatment?\\n\\n1\\ufe0f\\u20e3 Botox / anti-wrinkle\\n2\\ufe0f\\u20e3 Dermal filler\\n3\\ufe0f\\u20e3 HydraFacial\\n4\\ufe0f\\u20e3 Laser hair removal\\n5\\ufe0f\\u20e3 Free consultation\\n\\nReply with the number.';\n    leadUpdates = { awaiting: 'treatment_menu', status: 'qualifying' };\n  }\n\n} else if (has(['where', 'location', 'address', 'directions', 'parking'])) {\n  intent = 'location';\n  reply = 'We\\u2019re at ' + ADDRESS + '. Opening hours are ' + OPEN_HOUR + ':00 to ' + CLOSE_HOUR + ':00.\\n\\nWant me to book you in? Reply BOOK.';\n\n} else if (has(['human', 'speak to someone', 'talk to someone', 'call me', 'real person', 'agent', 'manager', 'complaint'])) {\n  intent = 'handoff';\n  reply = 'Of course \\u2014 I\\u2019m getting a member of the team to call you shortly, ' + name + '. They\\u2019ll be with you soon.';\n  handoff = true; handoffNote = 'Client asked for a human';\n  leadUpdates = { status: 'needs_human', automation_paused: 'yes', awaiting: '' };\n\n} else {\n  const t = matchTreatmentText(txt);\n  if (t) {\n    intent = 'treatment_interest';\n    const slots = openSlots(t.duration_min, 3);\n    reply = t.display_name + ' \\u2014 great choice. It\\u2019s ' + t.price + ' AED, about ' + t.duration_min + ' minutes.\\n\\n' +\n      (slots.length ? 'Next available:\\n\\n' + slots.map((s, i) => (i + 1) + '\\ufe0f\\u20e3 ' + pretty(new Date(s))).join('\\n') + '\\n\\nReply 1, 2 or 3 to book.' : 'Let me check the diary and come back to you.');\n    leadUpdates = slots.length\n      ? { treatment_interest: t.key, awaiting: 'slot_pick', pending_slots: JSON.stringify(slots), status: 'qualifying' }\n      : { treatment_interest: t.key, status: 'needs_human' };\n  } else {\n    // Never guess at a medical question. Hold politely, hand to a human.\n    intent = 'unknown';\n    reply = 'Thanks ' + name + ' \\u2014 let me get one of our specialists to answer that properly for you. They\\u2019ll reply here shortly.\\n\\nIn the meantime, if you\\u2019d like to book, just reply BOOK.';\n    handoff = true; handoffNote = 'Unrecognised message';\n    leadUpdates = { status: 'needs_human' };\n  }\n}\n\n// Every inbound message resets the chase clock and clears the pending queue —\n// a lead who is talking to us must never also be receiving nurture blasts.\nconst base = {\n  phone: inb.phone,\n  name: name,\n  last_reply: inb.text,\n  last_message_at: new Date().toISOString(),\n  next_action_at: '',\n  stage: '0'\n};\n\nreturn [{\n  json: {\n    intent: intent,\n    reply: reply,\n    should_reply: reply.length > 0,\n    handoff: handoff,\n    handoff_note: handoffNote,\n    inbound_text: inb.text,\n    phone: inb.phone,\n    name: name,\n    lead: Object.assign(base, leadUpdates),\n    has_appointment: !!appointment,\n    appointment: appointment,\n    has_appt_update: !!apptUpdate,\n    appt_update: apptUpdate\n  }\n}];"
      },
      "id": "v-brain",
      "name": "Brain — Decide Reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1660,
        280
      ],
      "notes": "Every decision that touches the diary happens here: intent, treatment, slot choice, booking. Keyword matching over canned strings, which is why it is fast. Answer & Speak only rephrases what this decides."
    },
    {
      "parameters": {
        "content": "### Answer & Speak\n\nThe Brain writes for text: numbered slot lists, a price line,\nsometimes a link. Read aloud, that becomes \"one emoji Tuesday\neighteen Aug at ten colon zero zero AM\".\n\nThis rewrites the **same** answer for speech, and answers simple\nquestions from a fixed FACTS block in the same call. It may drop and\nreorder; it may never add. If it invented a slot, the caller would be\ntold about an appointment that does not exist, which is what the\nlength check in `Speak or fall back` guards against.\n\n**Attach your Anthropic credential to this node by hand after**\n**import.** n8n picks the first Header Auth credential\nalphabetically, which is very often the wrong one.",
        "width": 400,
        "height": 380,
        "color": 6
      },
      "id": "v-note-2100--120",
      "name": "Note 2100/-120",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2100,
        -120
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'claude-haiku-4-5-20251001', max_tokens: 200, system: \"You are the receptionist at Radiance Med-Spa, speaking on the phone.\\nYou are given what the caller said and a draft reply from the booking system.\\n\\nTHE ONLY FACTS YOU MAY STATE:\\nClinic: Radiance Med-Spa, Jumeirah Beach Road, Dubai.\\nOpen 10:00 to 19:00, closed Sundays. All times Dubai time.\\nTreatments, price in AED, duration in minutes:\\n  Free Consultation - 0 - 20\\n  Botox / Anti-Wrinkle - 1200 - 45\\n  Dermal Filler - 2200 - 60\\n  HydraFacial - 750 - 45\\n  Laser Hair Removal - 500 - 30\\n  Chemical Peel - 900 - 45\\n  Microneedling / RF - 1400 - 60\\n  Body Contouring - 1800 - 75\\n\\nABSOLUTE RULES:\\n- Never state a price, duration, date or time that is not in the FACTS or the draft.\\n- Never promise a result, a discount or an outcome.\\n- CLINICAL QUESTIONS ARE OFF LIMITS. Safety, pregnancy, breastfeeding, medication,\\n  allergies, side effects, whether a treatment suits a condition, how long results\\n  last medically, anything diagnostic. Say plainly that you cannot advise on that\\n  and a member of the clinical team will come on. Set escalate to true.\\n- If the draft already answers, just say it in spoken form. Do not embellish.\\n- If the draft is a generic deflection AND the FACTS answer the question, answer it\\n  properly instead. That is the point of you.\\n- If neither the draft nor the FACTS can answer it, say so honestly and escalate.\\n\\nSTYLE:\\n- Two or three short sentences. Speech, not a paragraph.\\n- No emoji, asterisks, bullet points or URLs.\\n- Say numbers as a person does: two thousand two hundred dirhams, ten in the morning.\\n- Offering APPOINTMENT SLOTS: drop the numbers and say them the way a person\\n  does - \\\"I have Wednesday at two, Thursday at ten, or Friday at eleven.\\\"\\n  The caller can answer with a day or a date and the system understands it.\\n- Offering a list of TREATMENTS: keep the numbers. There is no other way for\\n  the caller to pick one, and the system matches on the number.\\n- Warm, brisk, human. A good receptionist, not a chatbot.\\n\\nReply with ONLY this JSON and nothing else:\\n{\\\"reply\\\":\\\"the spoken words\\\",\\\"escalate\\\":true or false}\", messages: [{ role: 'user', content: ['CALLER SAID: ' + ($('Normalize Inbound').item.json.spoken_original || ''), 'DRAFT REPLY FROM THE BOOKING SYSTEM: ' + ($json.reply || ''), 'BOOKING SYSTEM INTENT: ' + ($json.intent || 'unknown')].join(String.fromCharCode(10)) }] }) }}",
        "options": {
          "timeout": 8000
        }
      },
      "id": "v-answer-speak",
      "name": "Answer & Speak",
      "credentials": {
        "httpHeaderAuth": {
          "name": "Anthropic API key"
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2100,
        280
      ],
      "onError": "continueRegularOutput",
      "notes": "Haiku, not Opus: this is a rewrite, not a decision. The decision already happened in the Brain, so latency matters more than cleverness."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// =====================================================================\n// Speak or fall back  —  runs inside n8n as a Code node.\n//\n// Two jobs:\n//   1. Decide what the caller actually hears.\n//   2. Build the TRACE: a short, honest record of what the automation\n//      just did, which the showcase page renders beside the transcript.\n//\n// THIS FILE IS COPIED VERBATIM INTO THE GENERATED WORKFLOW.\n// Brain — Decide Reply is the only token the generator substitutes.\n// =====================================================================\n\nconst brain = $('Brain — Decide Reply').first().json || {};\nconst guard = $('Guard').first().json || {};\nconst norm = $('Normalize Inbound').first().json || {};\n\nconst original = String(brain.reply || '').trim();\n\n/* ---------------------------------------------------------------------\n   1. What the caller hears.\n\n   The Brain's answer is the floor: if the model times out, errors, or\n   comes back looking like it has started inventing, we send the Brain's\n   own words. A clunky true answer beats a smooth missing one, and on a\n   page whose whole argument is that we do not fabricate proof, a voice\n   demo that invents a price would be the worst possible own goal.\n   ------------------------------------------------------------------ */\nlet spoken = '';\nlet escalate = null;\n\ntry {\n  const blocks = $input.first().json.content;\n  if (Array.isArray(blocks)) {\n    const raw = blocks.filter((b) => b.type === 'text').map((b) => b.text).join(' ').trim();\n    // Tolerate a markdown fence or a stray sentence around the JSON.\n    // indexOf/lastIndexOf rather than a regex on purpose - see the note\n    // in normalize-inbound.js about backslashes and template literals.\n    const a = raw.indexOf('{');\n    const b = raw.lastIndexOf('}');\n    if (a !== -1 && b > a) {\n      const parsed = JSON.parse(raw.slice(a, b + 1));\n      spoken = String(parsed.reply || '').trim();\n      escalate = !!parsed.escalate;\n    } else {\n      spoken = raw;\n    }\n  }\n} catch (e) {\n  spoken = '';\n}\n\n// Empty, or much longer than the draft it was given - condensing should\n// never grow the text, so growth is the signature of invention.\nconst bad = !spoken || (original && spoken.length > original.length * 1.8);\nconst reply = (bad ? original : spoken) || 'Sorry, I did not catch that. Could you say it again?';\n\n// If the model answered something the Brain could not, the caller no\n// longer needs a human - unless the model itself said they do.\nconst needsHuman = bad\n  ? !!brain.handoff\n  : (escalate === null ? !!brain.handoff : escalate);\n\n/* ---------------------------------------------------------------------\n   2. The trace.\n\n   This is the showcase. A prospect watching a chat bubble sees a\n   chatbot; a prospect watching \"checked 19 appointments, offered 3 open\n   slots, wrote appointment A1b2c3 to the diary\" sees the automation they\n   are being sold. Every line below is derived from what actually\n   happened in this execution - nothing here is decorative, and nothing\n   is invented for effect. If a step did not happen, it does not appear.\n   ------------------------------------------------------------------ */\nconst trace = [];\nconst push = (label, detail) => { if (detail) trace.push({ label, detail }); };\n\n// What we heard, and how it was understood. The mapping step is worth\n// showing: \"27 august -> slot 2\" is the moment people realise it is\n// resolving speech against a real diary rather than matching keywords.\nif (norm.mapped && norm.spoken_original) {\n  push('HEARD', String(norm.spoken_original));\n  push('RESOLVED', 'understood as \"' + String(norm.text) + '\"');\n} else if (norm.spoken_original) {\n  push('HEARD', String(norm.spoken_original));\n}\n\npush('INTENT', brain.intent || 'unknown');\n\n// Treatment matched out of the clinic's own price list.\ntry {\n  const key = (brain.lead && brain.lead.treatment_interest) || '';\n  if (key) {\n    const t = $('Load Treatments').all().map((r) => r.json).find((x) => String(x.key) === String(key));\n    if (t) {\n      push('MATCHED', t.display_name + ' - ' + t.price + ' AED, ' + t.duration_min + ' min');\n    }\n  }\n} catch (e) { /* the trace is never worth failing a call over */ }\n\n// Availability actually checked against the diary.\ntry {\n  const slots = JSON.parse((brain.lead && brain.lead.pending_slots) || '[]');\n  if (slots.length) {\n    const booked = $('Load Appointments').all().filter((r) => r.json && r.json.starts_at).length;\n    push('DIARY', 'checked ' + booked + ' existing appointments, offered ' + slots.length + ' open slots');\n  }\n} catch (e) { /* ditto */ }\n\n// The write. This is the payoff line.\nif (brain.has_appointment && brain.appointment) {\n  const a = brain.appointment;\n  push('BOOKED', a.treatment_name + ' - ' + a.appt_id);\n  push('WROTE', 'appointment row created, status ' + a.status);\n}\n\nif (needsHuman) {\n  push('HANDOFF', brain.handoff_note || 'flagged for a human');\n}\n\n// Server-side time, so the number shown is ours and not the network's.\nlet ms = null;\ntry {\n  if (guard.receivedAt) ms = Date.now() - new Date(guard.receivedAt).getTime();\n} catch (e) { ms = null; }\n\nreturn [{\n  json: {\n    ok: true,\n    reply,\n    intent: brain.intent || 'unknown',\n    needs_human: needsHuman,\n    ended: brain.intent === 'opt_out',\n    answeredByModel: !bad,\n    trace,\n    ms,\n    turn: guard.turn || null,\n    phone: brain.phone,\n    sessionId: guard.sessionId,\n  },\n}];\n"
      },
      "id": "v-speak-or-fall-back",
      "name": "Speak or fall back",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2320,
        280
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, reply: $json.reply, intent: $json.intent, needs_human: $json.needs_human, ended: $json.ended, trace: $json.trace, ms: $json.ms, turn: $json.turn }) }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "cache-control",
                "value": "no-store"
              }
            ]
          }
        }
      },
      "id": "v-answer",
      "name": "Answer the caller",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2540,
        180
      ],
      "notes": "Answers first, then the writes happen. The caller is waiting; the spreadsheet is not."
    },
    {
      "parameters": {
        "mode": "raw",
        "jsonOutput": "={{ JSON.stringify($('Brain — Decide Reply').item.json.lead) }}",
        "options": {}
      },
      "id": "v-lead-row",
      "name": "Lead Row",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3,
      "position": [
        2540,
        400
      ],
      "notes": "Flattens the Brain’s nested `lead` object into columns for the upsert. Not optional: without it the conversation has no memory between turns."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "PASTE_YOUR_GOOGLE_SHEET_ID_HERE",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "leads",
          "mode": "name"
        },
        "options": {},
        "operation": "appendOrUpdate",
        "columns": {
          "mappingMode": "autoMapInputData",
          "matchingColumns": [
            "phone"
          ]
        }
      },
      "id": "v-update-lead",
      "name": "Update Lead",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        2760,
        400
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "Google Sheets account"
        }
      },
      "notes": "Upsert keyed on the caller’s phone, which is the join key across leads, appointments and the log."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "ha",
              "leftValue": "={{ $('Brain — Decide Reply').item.json.has_appointment }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "v-newbooking",
      "name": "New Booking?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2980,
        400
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "PASTE_YOUR_GOOGLE_SHEET_ID_HERE",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "appointments",
          "mode": "name"
        },
        "options": {},
        "operation": "append",
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "appt_id": "={{ $('Brain — Decide Reply').item.json.appointment.appt_id }}",
            "lead_id": "={{ $('Brain — Decide Reply').item.json.appointment.lead_id }}",
            "phone": "={{ $('Brain — Decide Reply').item.json.appointment.phone }}",
            "name": "={{ $('Brain — Decide Reply').item.json.appointment.name }}",
            "treatment": "={{ $('Brain — Decide Reply').item.json.appointment.treatment }}",
            "treatment_name": "={{ $('Brain — Decide Reply').item.json.appointment.treatment_name }}",
            "starts_at": "={{ $('Brain — Decide Reply').item.json.appointment.starts_at }}",
            "ends_at": "={{ $('Brain — Decide Reply').item.json.appointment.ends_at }}",
            "status": "={{ $('Brain — Decide Reply').item.json.appointment.status }}",
            "deposit_status": "={{ $('Brain — Decide Reply').item.json.appointment.deposit_status }}",
            "created_at": "={{ $('Brain — Decide Reply').item.json.appointment.created_at }}",
            "prep_sent": "no",
            "reminder_24h_sent": "no",
            "reminder_2h_sent": "no",
            "aftercare_sent": "no",
            "checkin_sent": "no",
            "review_sent": "no",
            "rebook_nudged": "no"
          }
        }
      },
      "id": "v-create-appointment",
      "name": "Create Appointment",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        3200,
        320
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "Google Sheets account"
        }
      },
      "notes": "Writes the booking. Keep this column mapping if you add other channels, so every booking is the same row shape whatever answered the enquiry."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "PASTE_YOUR_GOOGLE_SHEET_ID_HERE",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "message_log",
          "mode": "name"
        },
        "options": {},
        "operation": "append",
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "ts": "={{ $now.toISO() }}",
            "phone": "={{ $('Brain — Decide Reply').item.json.phone }}",
            "direction": "in",
            "type": "={{ $('Brain — Decide Reply').item.json.intent }}",
            "body": "={{ $('Brain — Decide Reply').item.json.inbound_text }}",
            "reply": "={{ $('Brain — Decide Reply').item.json.reply }}",
            "workflow": "06-voice"
          }
        }
      },
      "id": "v-log-conversation",
      "name": "Log Conversation",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        3200,
        480
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "Google Sheets account"
        }
      },
      "onError": "continueRegularOutput",
      "notes": "workflow column reads 06-voice, so the dashboard can tell the two channels apart."
    }
  ],
  "connections": {
    "Voice Webhook": {
      "main": [
        [
          {
            "node": "Guard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Guard": {
      "main": [
        [
          {
            "node": "Allowed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Allowed?": {
      "main": [
        [
          {
            "node": "Load Config",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Refuse politely",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Config": {
      "main": [
        [
          {
            "node": "Voice Identity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Voice Identity": {
      "main": [
        [
          {
            "node": "Find Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Lead": {
      "main": [
        [
          {
            "node": "Normalize Inbound",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Inbound": {
      "main": [
        [
          {
            "node": "Load Treatments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Treatments": {
      "main": [
        [
          {
            "node": "Load Appointments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Appointments": {
      "main": [
        [
          {
            "node": "Brain — Decide Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Brain — Decide Reply": {
      "main": [
        [
          {
            "node": "Answer & Speak",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Answer & Speak": {
      "main": [
        [
          {
            "node": "Speak or fall back",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Speak or fall back": {
      "main": [
        [
          {
            "node": "Answer the caller",
            "type": "main",
            "index": 0
          },
          {
            "node": "Lead Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lead Row": {
      "main": [
        [
          {
            "node": "Update Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Lead": {
      "main": [
        [
          {
            "node": "New Booking?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New Booking?": {
      "main": [
        [
          {
            "node": "Create Appointment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Conversation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Appointment": {
      "main": [
        [
          {
            "node": "Log Conversation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}