{
  "name": "AI Voice Call Center — 24/7 AI Receptionist (Twilio + Claude)",
  "nodes": [
    {
      "parameters": {
        "content": "# 📞 AI Voice Call Center — 24/7 AI Receptionist\n**Problem:** home-service businesses miss a big share of inbound calls (after hours, lunch breaks, busy lines) and every missed call is a job that goes to a competitor.\n**Solution:** a Twilio phone line answered by a Claude-powered agent that talks like a trained receptionist and actually *does the work*.\n\n**What it handles**\n• Recognises returning callers from the CRM and greets them by name\n• Books service visits against live Google Calendar availability\n• Checks job status (with phone/ZIP verification — no data leaks)\n• Answers pricing & policy questions from a Postgres full-text knowledge base\n• Opens tickets, warm-transfers to humans, routes emergencies to the on-call tech\n• Deterministic guardrails *before* the LLM: silence, gas/CO/fire keywords, \"give me a human\", runaway calls\n• After every call: AI QA scoring, sentiment, CRM summary, Slack escalations, consented SMS recap, missed-call text-back\n\n**Stack:** Twilio Voice · Claude (Anthropic) · n8n AI Agent · Postgres · Google Calendar · Slack",
        "height": 760,
        "width": 860,
        "color": 1
      },
      "id": "cde2648f-041b-581e-a8fb-2f60a85bc147",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -980,
        -50
      ]
    },
    {
      "parameters": {
        "content": "## 🛠️ Setup — about 15 minutes\n1. **Postgres** credential → select it on every Postgres node, then click ▶ on **Run Once: Create Tables** (creates tables + demo data).\n2. **Basic Auth** credential (any user/password) → select it on the 3 Twilio webhooks.\n3. **Twilio console → Phone Number → Voice:**\n   • *A call comes in* → `https://USER:PASS@your-n8n.com/webhook/ai-call-center/incoming` (HTTP POST)\n   • *Call status changes* → `https://USER:PASS@your-n8n.com/webhook/ai-call-center/status`\n   The conversation URL (`/turn`) is reached automatically via relative TwiML links.\n4. **Anthropic** credential on both Claude nodes · **Google Calendar** on the two calendar tools · **Slack** + **Twilio API** credentials.\n5. Edit the **Call Center Config** node (name, hours, numbers, Slack channel ID, calendar ID).\n6. Activate the workflow and call your Twilio number.\n\n💡 Latency: the voice agent uses Claude Opus 5. If you want snappier turns, switch that one model node to Claude Haiku 4.5.",
        "height": 620,
        "width": 860,
        "color": 3
      },
      "id": "cd634c05-00ac-54c0-9742-382c350d225e",
      "name": "Setup Guide",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -980,
        760
      ]
    },
    {
      "parameters": {
        "content": "## 📥 Twilio entry points\nThree webhooks → one config → one router.",
        "height": 1680,
        "width": 740,
        "color": 7
      },
      "id": "ed53abb9-6964-59cf-a774-191a31e581cc",
      "name": "Frame: Entry",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -70,
        -50
      ]
    },
    {
      "parameters": {
        "content": "## ① Incoming call → personalised greeting\nCRM lookup by caller ID + business-hours check → TwiML `<Gather>` with barge-in. Responds first, logs after, so pickup stays instant.",
        "height": 280,
        "width": 1020,
        "color": 5
      },
      "id": "7b56212e-0c79-5052-be9a-fe7a4b43f4d6",
      "name": "Frame: Lane 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        730,
        -50
      ]
    },
    {
      "parameters": {
        "content": "## ② Conversation engine — runs on every caller utterance\nDeterministic guardrails decide first (silence, gas/CO/fire, flooding, 'get me a human', runaway calls). Ordinary talk goes to the Claude agent with 7 tools + per-call Postgres memory. Tool calls signal transfer / hang-up. If the agent errors, the caller is handed to a human: never dead air.",
        "height": 780,
        "width": 2788,
        "color": 6
      },
      "id": "3796eba0-f382-5bc3-935d-78fc57268880",
      "name": "Frame: Lane 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        730,
        290
      ]
    },
    {
      "parameters": {
        "content": "## ③ Post-call intelligence\nTranscript → Claude QA analyst (structured JSON) → CRM record → Slack escalation + SMS recap (only with consent). Hang-ups during the greeting get a missed-call text-back. Raw LLM memory is purged after analysis.",
        "height": 820,
        "width": 2034,
        "color": 4
      },
      "id": "a90b85b8-4f61-5ad6-b0a3-52d5ec0f0e4d",
      "name": "Frame: Lane 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        730,
        1110
      ]
    },
    {
      "parameters": {
        "content": "## 🧱 Run once\nCreates all tables, indexes and demo data.",
        "height": 280,
        "width": 520,
        "color": 7
      },
      "id": "aa6e7fc5-b877-56ee-93b9-247a59051969",
      "name": "Frame: Setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -70,
        1970
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "ai-call-center/incoming",
        "authentication": "basicAuth",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "1db66817-ac74-5755-af3d-b081a78f0de1",
      "name": "Twilio: Incoming Call",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        100
      ],
      "webhookId": "cd48361a-bb8e-539f-8ebe-a054988107e0"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "ai-call-center/turn",
        "authentication": "basicAuth",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "a8d4b43a-3b51-5b4e-ab68-2ea80cf7147b",
      "name": "Twilio: Caller Spoke",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        680
      ],
      "webhookId": "37825878-d554-5cc5-8f52-832a1f7fc0ae"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "ai-call-center/status",
        "authentication": "basicAuth",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "616bd249-4c0c-5d88-a7ca-adaffdf7927f",
      "name": "Twilio: Call Status",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        1480
      ],
      "webhookId": "6dcc3642-2ce6-5ef2-b031-2427eeb682c1"
    },
    {
      "parameters": {
        "mode": "manual",
        "assignments": {
          "assignments": [
            {
              "id": "0eb2dc78-bc27-542c-81dd-da72f45613ad",
              "name": "businessName",
              "value": "Brightline Home Services",
              "type": "string"
            },
            {
              "id": "87e5d7d8-2c5b-53e7-b2ba-79a33f80182f",
              "name": "agentName",
              "value": "Ava",
              "type": "string"
            },
            {
              "id": "3239112f-a65e-563d-b6fb-d500549d77d8",
              "name": "timezone",
              "value": "America/New_York",
              "type": "string"
            },
            {
              "id": "9b0f8c66-7286-543d-b9eb-3a36364b1ad8",
              "name": "openDays",
              "value": "1,2,3,4,5,6",
              "type": "string"
            },
            {
              "id": "aa2e6c51-fce7-5aba-b01f-4ea3d8f6d9ed",
              "name": "openHour",
              "value": 8,
              "type": "number"
            },
            {
              "id": "998c75cb-e07f-55e4-937c-9599edabdf5a",
              "name": "closeHour",
              "value": 18,
              "type": "number"
            },
            {
              "id": "31abfaa3-a92c-5e85-a8f9-909cd015ddb8",
              "name": "voice",
              "value": "Polly.Joanna-Neural",
              "type": "string"
            },
            {
              "id": "e5d03fb0-47e2-5f77-a5b6-b917f8338274",
              "name": "language",
              "value": "en-US",
              "type": "string"
            },
            {
              "id": "0172cc68-bacd-5b0c-b090-0f49283b4a02",
              "name": "transferNumber",
              "value": "+15555550100",
              "type": "string"
            },
            {
              "id": "a3511865-1304-5f69-a3f3-7bb5b4353171",
              "name": "onCallNumber",
              "value": "+15555550199",
              "type": "string"
            },
            {
              "id": "a0b62df4-f9b4-592c-97d2-f49de26615ed",
              "name": "maxTurns",
              "value": 14,
              "type": "number"
            },
            {
              "id": "7ccda3b6-0acb-5b2d-bd26-fb1589405b5a",
              "name": "calendarId",
              "value": "primary",
              "type": "string"
            },
            {
              "id": "17e79693-1475-5d07-afd3-81c3342f9262",
              "name": "slackChannelId",
              "value": "C0123456789",
              "type": "string"
            },
            {
              "id": "10badf61-c37b-5fc6-b479-cc6b4eb2921c",
              "name": "recordingDisclosure",
              "value": "This call is recorded and transcribed for quality.",
              "type": "string"
            },
            {
              "id": "87b939a4-3a82-562b-8728-2bab55fd569d",
              "name": "speechHints",
              "value": "appointment, reschedule, job number, water heater, furnace, boiler, AC, leak, drain, breaker, estimate, membership",
              "type": "string"
            },
            {
              "id": "9590504e-1095-5088-a3dc-1c1b456c54a9",
              "name": "event",
              "value": "={{ $('Twilio: Incoming Call').isExecuted ? 'incoming' : ($('Twilio: Caller Spoke').isExecuted ? 'turn' : 'status') }}",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "0195e7b9-a016-5d0a-bc6b-3a265938f21d",
      "name": "Call Center Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        260,
        680
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "cdb190dd-65a1-58e0-a764-0878b1834093",
                    "leftValue": "={{ $json.event }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "incoming"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Incoming Call"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "80536b4b-b09f-561d-9d66-863dbdfeb180",
                    "leftValue": "={{ $json.event }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "turn"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Conversation Turn"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "f77c614b-b8bc-50e9-9ad1-cfde520b2c28",
                    "leftValue": "={{ $json.event }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "status"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Call Ended"
            }
          ]
        },
        "options": {}
      },
      "id": "5090640d-ce2a-5ea1-9769-e173727b684c",
      "name": "Route Twilio Event",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        520,
        680
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "-- One row even for unknown callers, so the greeting always renders\nSELECT c.id AS customer_id, c.first_name, c.last_name, c.tier,\n       (SELECT count(*) FROM calls k\n         WHERE k.from_number = v.phone AND k.started_at > now() - interval '7 days')::int AS calls_last_7d,\n       (SELECT j.job_number || ' (' || j.service_type || ', ' || j.status || ')'\n          FROM service_jobs j WHERE j.customer_id = c.id\n         ORDER BY j.scheduled_start DESC NULLS LAST LIMIT 1) AS latest_job\nFROM (SELECT $1::text AS phone) v\nLEFT JOIN customers c ON c.phone = v.phone\nLIMIT 1;",
        "options": {
          "queryReplacement": "={{ [ $('Call Center Config').first().json.body.From ] }}"
        }
      },
      "id": "cd4d1be4-0b1c-50a3-b422-2cae9b718471",
      "name": "Identify Caller",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        780,
        100
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Personalised greeting + business-hours awareness, rendered as TwiML\nconst cfg = $('Call Center Config').first().json;\nconst tw = cfg.body || {};\nconst caller = $input.first().json || {};\n\nconst now = DateTime.now().setZone(cfg.timezone);\nconst openDays = String(cfg.openDays).split(',').map(Number);\nconst isOpen = openDays.includes(now.weekday) && now.hour >= cfg.openHour && now.hour < cfg.closeHour;\n\nconst esc = (s) => String(s ?? '')\n  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n  .replace(/\"/g, '&quot;').replace(/'/g, '&apos;');\n\nconst parts = [];\nif (caller.first_name) {\n  parts.push(`Hi ${caller.first_name}, thanks for calling ${cfg.businessName}. This is ${cfg.agentName}, the virtual assistant.`);\n  if (caller.calls_last_7d > 0) parts.push(\"Welcome back, I'm happy to pick up where we left off.\");\n} else {\n  parts.push(`Thanks for calling ${cfg.businessName}! This is ${cfg.agentName}, the virtual assistant. I can book a visit, check on a job, or answer questions.`);\n}\nif (!isOpen) parts.push(\"Our office is closed right now, but I can still book appointments, and I'll connect emergencies to our on-call technician.\");\nparts.push(cfg.recordingDisclosure);\nparts.push('How can I help today?');\nconst greeting = parts.join(' ');\n\nconst twiml = '<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response>'\n  + `<Gather input=\"speech\" action=\"turn?s=0\" method=\"POST\" speechTimeout=\"auto\" speechModel=\"phone_call\" enhanced=\"true\" language=\"${cfg.language}\" actionOnEmptyResult=\"true\" hints=\"${esc(cfg.speechHints)}\">`\n  + `<Say voice=\"${cfg.voice}\" language=\"${cfg.language}\">${esc(greeting)}</Say>`\n  + '</Gather></Response>';\n\nreturn [{\n  json: {\n    twiml,\n    greeting,\n    callSid: tw.CallSid,\n    from: tw.From,\n    to: tw.To,\n    customerId: caller.customer_id ?? null,\n    afterHours: !isOpen,\n    callerCity: tw.FromCity || null,\n    callerState: tw.FromState || null,\n  },\n}];"
      },
      "id": "60147e08-8be8-564a-b899-9ceadc9489a7",
      "name": "Compose Greeting",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        100
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ $json.twiml }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/xml"
              }
            ]
          }
        }
      },
      "id": "33d6bda3-363b-5f0f-8d5e-a53c7ff8af16",
      "name": "Respond: Greeting",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1300,
        100
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH new_call AS (\n  INSERT INTO calls (call_sid, from_number, to_number, customer_id, after_hours, caller_city, caller_state)\n  VALUES ($1, $2, $3, $4, $5, $6, $7)\n  ON CONFLICT (call_sid) DO NOTHING\n  RETURNING call_sid\n)\nINSERT INTO call_turns (call_sid, turn_index, caller_text, agent_text, action)\nSELECT call_sid, 0, NULL, $8, 'greeting' FROM new_call;",
        "options": {
          "queryReplacement": "={{ [ $('Compose Greeting').first().json.callSid, $('Compose Greeting').first().json.from, $('Compose Greeting').first().json.to, $('Compose Greeting').first().json.customerId, $('Compose Greeting').first().json.afterHours, $('Compose Greeting').first().json.callerCity, $('Compose Greeting').first().json.callerState, $('Compose Greeting').first().json.greeting ] }}"
        }
      },
      "id": "efc43f14-aaf0-5cb4-bba3-db732a9855f0",
      "name": "Log Call Start",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1560,
        100
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "-- Atomically bump the turn counter and pull caller context in one round trip\nWITH bumped AS (\n  UPDATE calls SET turn_count = turn_count + 1, last_activity_at = now()\n  WHERE call_sid = $1\n  RETURNING call_sid, customer_id, turn_count, after_hours\n)\nSELECT v.call_sid, b.turn_count, b.after_hours,\n       c.id AS customer_id, c.first_name, c.last_name, c.tier\nFROM (SELECT $1::text AS call_sid) v\nLEFT JOIN bumped b ON b.call_sid = v.call_sid\nLEFT JOIN customers c ON c.id = b.customer_id;",
        "options": {
          "queryReplacement": "={{ [ $('Call Center Config').first().json.body.CallSid ] }}"
        }
      },
      "id": "f4cbb511-9f2c-5fcf-aae7-c8b75cae70c8",
      "name": "Load Call State",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        780,
        680
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Deterministic guardrails BEFORE the LLM: silence, emergencies, human requests, runaway calls,\n// and the return leg of a <Dial> transfer. Only ordinary conversation reaches the AI agent.\nconst cfg = $('Call Center Config').first().json;\nconst tw = cfg.body || {};\nconst q = cfg.query || {};\nconst state = $input.first().json || {};\n\nconst now = DateTime.now().setZone(cfg.timezone);\nconst openDays = String(cfg.openDays).split(',').map(Number);\nconst isOpen = openDays.includes(now.weekday) && now.hour >= cfg.openHour && now.hour < cfg.closeHour;\n\nconst speech = String(tw.SpeechResult || '').trim();\nconst silence = Number(q.s || 0);\nconst turn = Number(state.turn_count || 1);\nconst transferTo = isOpen ? cfg.transferNumber : cfg.onCallNumber;\n\nconst LIFE_SAFETY = /\\b(smell(s|ing)? (of )?gas|gas leak|carbon monoxide|co (alarm|detector)|smoke|fire|sparks?|sparking|burning smell)\\b/i;\nconst URGENT = /\\b(flood(ing|ed)?|burst pipe|pipe (burst|broke)|water everywhere|sewage|no heat|no power)\\b/i;\nconst HUMAN = /\\b(human|real person|representative|operator|live agent|talk to (a )?(person|someone|somebody))\\b/i;\n\nlet decision;\nif (q.dial) {\n  const ok = ['completed', 'answered'].includes(tw.DialCallStatus);\n  decision = ok\n    ? { route: 'direct', nextAction: 'hangup', say: '', reason: 'transfer_completed' }\n    : { route: 'direct', nextAction: 'hangup', needsCallback: true, reason: `transfer_failed:${tw.DialCallStatus}`,\n        say: \"I'm sorry, our team couldn't pick up right now. I've flagged your call for a priority callback and someone will reach you shortly. Thank you for calling.\" };\n} else if (!speech) {\n  decision = silence >= 2\n    ? { route: 'direct', nextAction: 'hangup', needsCallback: turn > 1, reason: 'silence',\n        say: \"I still can't hear anything, so I'll let you go. Please call back any time. Goodbye!\" }\n    : { route: 'direct', nextAction: 'continue', silence: silence + 1, reason: 'silence',\n        say: silence === 0 ? \"Sorry, I didn't catch that. How can I help you today?\"\n                           : 'Are you still there? You can tell me what you need, like booking a visit or checking on a job.' };\n} else if (LIFE_SAFETY.test(speech)) {\n  decision = { route: 'direct', nextAction: 'transfer', reason: 'life_safety',\n    say: 'Your safety comes first. If you smell gas, see smoke, or hear a carbon monoxide alarm, leave the building now and call 911 from outside. I am connecting you to our emergency technician right now.' };\n} else if (URGENT.test(speech)) {\n  decision = { route: 'direct', nextAction: 'transfer', reason: 'urgent_property',\n    say: 'That sounds urgent. I am connecting you with our on-call technician right now.' };\n} else if (HUMAN.test(speech) && isOpen) {\n  decision = { route: 'direct', nextAction: 'transfer', reason: 'caller_requested_human',\n    say: 'Of course. Connecting you with a team member now.' };\n} else if (turn > cfg.maxTurns) {\n  decision = isOpen\n    ? { route: 'direct', nextAction: 'transfer', reason: 'max_turns', say: 'Let me bring in a teammate who can finish this up with you.' }\n    : { route: 'direct', nextAction: 'hangup', needsCallback: true, reason: 'max_turns',\n        say: \"I want to make sure this gets fully handled, so I've asked a teammate to call you back first thing. Thanks for calling!\" };\n} else {\n  decision = { route: 'ai' };\n}\n\nreturn [{\n  json: {\n    callSid: tw.CallSid,\n    callerPhone: tw.From,\n    speech,\n    confidence: Number(tw.Confidence || 0),\n    turn,\n    isOpen,\n    localTime: now.toFormat(\"cccc, LLLL d yyyy, h:mm a ZZZZ\"),\n    customerId: state.customer_id ?? null,\n    callerName: [state.first_name, state.last_name].filter(Boolean).join(' ') || null,\n    tier: state.tier || 'new',\n    transferTo,\n    silence: 0,\n    needsCallback: false,\n    ...decision,\n  },\n}];"
      },
      "id": "59022582-5113-5f79-903a-ed803bad0ee1",
      "name": "Turn Guardrails",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        680
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "cf4a789c-b9aa-5978-9f63-66ff10d79d38",
                    "leftValue": "={{ $json.route }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "ai"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "AI Conversation"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "ac39406b-b067-5742-a1e5-91c67c9fa947",
                    "leftValue": "={{ $json.nextAction }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "transfer"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Escalate to Human"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "208a57c1-d950-5ee8-8480-45f497d8e188",
                    "leftValue": "={{ $json.route }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "direct"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Reprompt / Wrap-up"
            }
          ]
        },
        "options": {}
      },
      "id": "981dd0cc-4883-5b85-8d78-a0f9adc84d30",
      "name": "Route Turn",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1300,
        680
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "={{ $json.speech }}",
        "options": {
          "systemMessage": "=You are {{ $('Call Center Config').first().json.agentName }}, the AI phone receptionist for {{ $('Call Center Config').first().json.businessName }}, a plumbing, heating, cooling and electrical company. You are on a live phone call and everything you write is spoken aloud by a text-to-speech voice.\n\n## Call context\n- Local time: {{ $json.localTime }}. Office is {{ $json.isOpen ? 'OPEN: live staff can take transfers.' : 'CLOSED: only the emergency on-call technician is available.' }}\n- Caller phone: {{ $json.callerPhone }}\n- Caller: {{ $json.callerName ? $json.callerName + ' (existing customer, tier ' + $json.tier + ')' : 'not recognized, treat as a new customer' }}\n- This is turn {{ $json.turn }} of the call. Speech recognition confidence: {{ $json.confidence }}\n\n## How to speak\n- One to three short sentences, then at most one question.\n- Plain spoken English only: no markdown, lists, emojis, links or symbols. Say times like \"two thirty PM\" and dates like \"Tuesday, March third\".\n- If the words look garbled or confidence is below 0.5, ask the caller to repeat instead of guessing.\n- Read important details back (date, arrival window, address, job number) before acting on them.\n\n## Tools\n- Search_Knowledge_Base: prices, service area, warranties, membership, hours, policies. Never invent a price or policy. If the answer is not there, say a teammate will follow up and create a ticket.\n- Lookup_Service_Job: status of an existing job. Needs the job number. If the caller is not recognized, ask for the ZIP code of the service address first.\n- Check_Availability, then Book_Appointment: find a free two hour arrival window during business hours within the next 14 days. Collect the caller's name, service address and a short description of the problem first, and read the window back for a clear yes before booking.\n- Create_Ticket: billing questions, complaints, quote requests, callbacks, or anything you cannot finish. Tell the caller their ticket number.\n- Transfer_to_Human: when the caller insists on a person{{ $json.isOpen ? '' : ' (office closed: offer a callback ticket instead unless it is an emergency)' }}, stays upset after one attempt to help, or the request needs staff judgement. After calling it, say one short line such as \"Connecting you now.\"\n- End_Call: when the caller is finished. After calling it, say a short, warm goodbye.\n\n## Rules\n- Only share job details after verification: the phone number on file or the matching ZIP code.\n- Never promise exact arrival times, discounts or refunds.\n- If you booked something or created a ticket, ask whether they want a text message summary before ending, and remember the answer.",
          "maxIterations": 6,
          "returnIntermediateSteps": true
        }
      },
      "id": "5fcd7de0-d58c-51d5-98db-4b307536b725",
      "name": "AI Receptionist",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "typeVersion": 2,
      "position": [
        1638,
        680
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "claude-opus-5"
        },
        "options": {
          "maxTokensToSample": 400
        }
      },
      "id": "cd72d5b8-7962-503f-be8d-17d3142f0e46",
      "name": "Claude Opus 5 · Voice",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.3,
      "position": [
        1300,
        940
      ]
    },
    {
      "parameters": {
        "sessionIdType": "customKey",
        "sessionKey": "={{ $('Turn Guardrails').first().json.callSid }}",
        "tableName": "n8n_chat_histories",
        "contextWindowLength": 30
      },
      "id": "eff1c667-5763-5f8d-a225-cc328084da94",
      "name": "Call Memory",
      "type": "@n8n/n8n-nodes-langchain.memoryPostgresChat",
      "typeVersion": 1.3,
      "position": [
        1456,
        940
      ]
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Search the company knowledge base (prices, service area, warranties, membership, hours, policies). Returns up to 3 matching articles; an empty list means nothing was found.",
        "operation": "executeQuery",
        "query": "SELECT coalesce(json_agg(json_build_object('title', title, 'answer', answer)), '[]'::json) AS articles\nFROM (\n  SELECT title, answer\n  FROM kb_articles\n  WHERE search_vector @@ websearch_to_tsquery('english', $1)\n  ORDER BY ts_rank(search_vector, websearch_to_tsquery('english', $1)) DESC\n  LIMIT 3\n) hits;",
        "options": {
          "queryReplacement": "={{ [ $fromAI('keywords', 'Two to five search keywords. Put the word or between alternatives, e.g. warranty or guarantee', 'string') ] }}"
        }
      },
      "id": "3ae3838c-54f8-51d7-b362-8ad5e1d5373d",
      "name": "Search Knowledge Base",
      "type": "n8n-nodes-base.postgresTool",
      "typeVersion": 2.5,
      "position": [
        1612,
        940
      ]
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Look up an existing service job by job number. Returns status, technician, arrival window and notes. Returns an empty list if the job does not exist or verification failed.",
        "operation": "executeQuery",
        "query": "-- Only returns a job when the caller's phone OR the service ZIP matches (no data leaks)\nSELECT coalesce(json_agg(row_to_json(j)), '[]'::json) AS jobs\nFROM (\n  SELECT job_number, service_type, status, technician_name, scheduled_start, arrival_window, notes\n  FROM service_jobs\n  WHERE upper(job_number) = upper($1)\n    AND (customer_phone = $2 OR service_zip = $3)\n  LIMIT 1\n) j;",
        "options": {
          "queryReplacement": "={{ [ $fromAI('job_number', 'Job number the caller gives, e.g. BL-10492', 'string'), $('Turn Guardrails').first().json.callerPhone, $fromAI('zip_code', 'ZIP code of the service address if the caller gave one, otherwise the word none', 'string') ] }}"
        }
      },
      "id": "1cf9a15b-dc57-5638-993c-16c5393a7361",
      "name": "Lookup Service Job",
      "type": "n8n-nodes-base.postgresTool",
      "typeVersion": 2.5,
      "position": [
        1768,
        940
      ]
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Returns the BOOKED slots in the service calendar between two times. Any time inside business hours that is not listed is free. Check one full day at a time.",
        "resource": "calendar",
        "operation": "availability",
        "calendar": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Call Center Config').first().json.calendarId }}"
        },
        "timeMin": "={{ $fromAI('window_start', 'Start of the window to check, ISO 8601 with UTC offset, e.g. 2026-10-02T08:00:00-04:00', 'string') }}",
        "timeMax": "={{ $fromAI('window_end', 'End of the window to check, ISO 8601 with UTC offset', 'string') }}",
        "options": {
          "outputFormat": "bookedSlots"
        }
      },
      "id": "30566c89-8718-58e0-81f1-85a5ca32b58b",
      "name": "Check Availability",
      "type": "n8n-nodes-base.googleCalendarTool",
      "typeVersion": 1.3,
      "position": [
        1924,
        940
      ]
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Book a two hour arrival window in the service calendar. Only call this after the caller clearly confirmed the exact window.",
        "resource": "event",
        "operation": "create",
        "calendar": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Call Center Config').first().json.calendarId }}"
        },
        "start": "={{ $fromAI('start', 'Arrival window start, ISO 8601 with UTC offset', 'string') }}",
        "end": "={{ $fromAI('end', 'Arrival window end, ISO 8601 with UTC offset, two hours after start', 'string') }}",
        "useDefaultReminders": true,
        "additionalFields": {
          "summary": "={{ $fromAI('service_type', 'Short service label, e.g. Water heater repair', 'string') }} for {{ $fromAI('customer_name', 'Full name of the caller', 'string') }}",
          "location": "={{ $fromAI('service_address', 'Full service address including ZIP code', 'string') }}",
          "description": "=Problem: {{ $fromAI('problem', 'What the caller described, in one or two sentences', 'string') }}\nPhone: {{ $('Turn Guardrails').first().json.callerPhone }}\nBooked by the AI receptionist on call {{ $('Turn Guardrails').first().json.callSid }}"
        }
      },
      "id": "fce52ff3-f8ce-5395-b6f2-41a8f67fe3ba",
      "name": "Book Appointment",
      "type": "n8n-nodes-base.googleCalendarTool",
      "typeVersion": 1.3,
      "position": [
        2080,
        940
      ]
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Create a support ticket for billing questions, complaints, quote requests, callbacks or anything unresolved. Returns the ticket number to read to the caller.",
        "operation": "executeQuery",
        "query": "INSERT INTO support_tickets (call_sid, customer_id, caller_phone, category, priority, summary, callback_requested)\nVALUES ($1, $2, $3, $4, $5, $6, $7)\nRETURNING ticket_number, priority;",
        "options": {
          "queryReplacement": "={{ [ $('Turn Guardrails').first().json.callSid, $('Turn Guardrails').first().json.customerId, $('Turn Guardrails').first().json.callerPhone, $fromAI('category', 'One of: billing, complaint, quote, callback, other', 'string'), $fromAI('priority', 'One of: low, normal, high, urgent', 'string'), $fromAI('summary', 'One or two sentences describing what the caller needs', 'string'), $fromAI('callback_requested', 'True if the caller wants a call back', 'boolean') ] }}"
        }
      },
      "id": "ccf678b9-4700-5ba5-9da8-66c5d9029d9b",
      "name": "Create Ticket",
      "type": "n8n-nodes-base.postgresTool",
      "typeVersion": 2.5,
      "position": [
        2236,
        940
      ]
    },
    {
      "parameters": {
        "name": "Transfer_to_Human",
        "description": "Hand the live call to a human teammate. Input: one short sentence explaining why.",
        "language": "javaScript",
        "jsCode": "return `Transfer approved (${query}). Say one short line such as: Connecting you with a teammate now. Do not ask anything else.`;"
      },
      "id": "fbdb5c38-344c-5a13-bbb3-d20b88a7e0a6",
      "name": "Transfer to Human",
      "type": "@n8n/n8n-nodes-langchain.toolCode",
      "typeVersion": 1.1,
      "position": [
        2392,
        940
      ]
    },
    {
      "parameters": {
        "name": "End_Call",
        "description": "End the phone call once the caller has nothing else. Input: a short reason.",
        "language": "javaScript",
        "jsCode": "return 'The call will end right after your reply. Say a brief, warm goodbye.';"
      },
      "id": "27642481-2e54-5498-b6c3-fdf293b9cb33",
      "name": "End Call",
      "type": "@n8n/n8n-nodes-langchain.toolCode",
      "typeVersion": 1.1,
      "position": [
        2548,
        940
      ]
    },
    {
      "parameters": {
        "mode": "manual",
        "assignments": {
          "assignments": [
            {
              "id": "58dff297-7d6c-5497-908b-9b6c0dcd5b45",
              "name": "say",
              "value": "={{ $('Turn Guardrails').first().json.isOpen ? \"I'm having a little trouble on my end, so let me connect you with a teammate.\" : \"I'm having a little trouble on my end. I've asked a teammate to call you back first thing. Sorry about that!\" }}",
              "type": "string"
            },
            {
              "id": "9c915ca5-408c-577d-9ec9-6662d6e8e65a",
              "name": "nextAction",
              "value": "={{ $('Turn Guardrails').first().json.isOpen ? 'transfer' : 'hangup' }}",
              "type": "string"
            },
            {
              "id": "0423bf4a-b467-5ca7-afbb-e7987e2da7cb",
              "name": "transferTo",
              "value": "={{ $('Turn Guardrails').first().json.transferTo }}",
              "type": "string"
            },
            {
              "id": "31f2eac6-c7cc-515e-ae69-fe3606ced306",
              "name": "needsCallback",
              "value": "={{ !$('Turn Guardrails').first().json.isOpen }}",
              "type": "boolean"
            },
            {
              "id": "a35179a5-646b-52f8-8629-e9388eb4b6f5",
              "name": "reason",
              "value": "ai_error",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": false,
        "options": {}
      },
      "id": "8639fecc-abce-5436-97da-6ddcace563cf",
      "name": "AI Failure Fallback",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1976,
        480
      ]
    },
    {
      "parameters": {
        "jsCode": "// Turn the agent's answer + tool trace into a call action. Tools signal intent; text is what gets spoken.\nconst ctx = $('Turn Guardrails').first().json;\nconst out = $input.first().json;\nconst steps = out.intermediateSteps || [];\nconst toolsUsed = steps.map((s) => s.action?.tool).filter(Boolean);\nconst transfer = steps.find((s) => /transfer/i.test(s.action?.tool || ''));\nconst endCall = toolsUsed.some((t) => /end_call/i.test(t));\n\nlet say = String(out.output || '')\n  .replace(/[*_#`>\\[\\]]/g, '')      // strip markdown the TTS would read aloud\n  .replace(/\\s+/g, ' ')\n  .trim();\nif (say.length > 600) say = say.slice(0, 600).replace(/[^.!?]*$/, '');  // keep it short, end on a sentence\nif (!say) say = transfer ? 'Connecting you now.' : endCall ? 'Thanks for calling. Goodbye!' : 'Could you say that one more time?';\n\nconst reasonInput = transfer?.action?.toolInput;\nreturn [{\n  json: {\n    say,\n    nextAction: transfer ? 'transfer' : endCall ? 'hangup' : 'continue',\n    transferTo: ctx.transferTo,\n    reason: transfer ? `ai_transfer: ${reasonInput?.input ?? JSON.stringify(reasonInput ?? '')}` : endCall ? 'ai_end' : 'ai',\n    toolsUsed,\n    needsCallback: false,\n    silence: 0,\n  },\n}];"
      },
      "id": "606524fe-abd7-5942-9189-258b05b8f770",
      "name": "Interpret Agent Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1976,
        680
      ]
    },
    {
      "parameters": {
        "jsCode": "// Single place that renders TwiML for every branch (AI, guardrail, fallback)\nconst cfg = $('Call Center Config').first().json;\nconst ctx = $('Turn Guardrails').first().json;\nconst d = $input.first().json;\n\nconst esc = (s) => String(s ?? '')\n  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n  .replace(/\"/g, '&quot;').replace(/'/g, '&apos;');\nconst say = d.say ? `<Say voice=\"${cfg.voice}\" language=\"${cfg.language}\">${esc(d.say)}</Say>` : '';\n\nlet body;\nif (d.nextAction === 'transfer') {\n  body = `${say}<Dial action=\"turn?dial=1\" timeout=\"25\" answerOnBridge=\"true\"><Number>${esc(d.transferTo)}</Number></Dial>`;\n} else if (d.nextAction === 'hangup') {\n  body = `${say}<Hangup/>`;\n} else {\n  body = `<Gather input=\"speech\" action=\"turn?s=${Number(d.silence || 0)}\" method=\"POST\" speechTimeout=\"auto\" `\n    + `speechModel=\"phone_call\" enhanced=\"true\" language=\"${cfg.language}\" actionOnEmptyResult=\"true\" `\n    + `hints=\"${esc(cfg.speechHints)}\">${say}</Gather>`;\n}\n\nreturn [{\n  json: {\n    twiml: `<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response>${body}</Response>`,\n    callSid: ctx.callSid,\n    turn: ctx.turn,\n    callerText: ctx.speech || null,\n    agentText: d.say || '',\n    action: d.nextAction,\n    reason: d.reason || null,\n    toolsUsed: d.toolsUsed || [],\n    needsCallback: Boolean(d.needsCallback),\n    transferTo: d.nextAction === 'transfer' ? d.transferTo : null,\n    callerName: ctx.callerName,\n    callerPhone: ctx.callerPhone,\n    tier: ctx.tier,\n  },\n}];"
      },
      "id": "a0dbb9f4-97d6-5092-a1c7-aaa37ba1960b",
      "name": "Build TwiML",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2288,
        680
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ $json.twiml }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/xml"
              }
            ]
          }
        }
      },
      "id": "e6db1927-edc3-5a01-899f-793e7afb0a3d",
      "name": "Respond: TwiML",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2548,
        680
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH turn AS (\n  INSERT INTO call_turns (call_sid, turn_index, caller_text, agent_text, action, reason, tools_used)\n  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)\n)\nUPDATE calls\nSET needs_callback   = needs_callback OR $8,\n    transferred      = transferred OR ($5 = 'transfer'),\n    last_activity_at = now()\nWHERE call_sid = $1;",
        "options": {
          "queryReplacement": "={{ [ $('Build TwiML').first().json.callSid, $('Build TwiML').first().json.turn, $('Build TwiML').first().json.callerText, $('Build TwiML').first().json.agentText, $('Build TwiML').first().json.action, $('Build TwiML').first().json.reason, JSON.stringify($('Build TwiML').first().json.toolsUsed), $('Build TwiML').first().json.needsCallback ] }}"
        }
      },
      "id": "ae3794b9-c96f-5acd-9fd1-7cf09b3c9a13",
      "name": "Save Turn",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        2808,
        680
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "9ce0a35f-3138-5905-a648-177f2f749f6b",
              "leftValue": "={{ $('Build TwiML').first().json.action }}",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "rightValue": "transfer"
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "15a6c7d8-30df-5f8f-9d6f-9a55b5c981ca",
      "name": "Transferring?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3068,
        680
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Call Center Config').first().json.slackChannelId }}"
        },
        "text": "=:telephone_receiver: *Live AI transfer* to {{ $('Build TwiML').first().json.transferTo }}\n*Caller:* {{ $('Build TwiML').first().json.callerName || 'Unknown caller' }} ({{ $('Build TwiML').first().json.callerPhone }}), tier {{ $('Build TwiML').first().json.tier }}\n*Why:* {{ $('Build TwiML').first().json.reason }}\n*Last said:* \"{{ $('Build TwiML').first().json.callerText || '(silence)' }}\"",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "f4bd8b39-5546-53ae-bcc0-3a4604627517",
      "name": "Slack: Live Transfer Alert",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        3328,
        680
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "2ee5146c-670d-54f8-93e6-730c1723e473",
              "leftValue": "={{ $json.body.CallStatus }}",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "rightValue": "completed"
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "11d5e7c6-9c70-5852-a15f-e67e81bfff8d",
      "name": "Call Completed?",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        780,
        1480
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE calls\nSET ended_at = now(), duration_seconds = $2, status = 'completed'\nWHERE call_sid = $1\nRETURNING call_sid, from_number, to_number, customer_id, turn_count,\n          needs_callback, transferred, after_hours,\n          (SELECT first_name FROM customers c WHERE c.id = calls.customer_id) AS first_name,\n          (SELECT coalesce(string_agg(\n                    coalesce('Caller: ' || t.caller_text || E'\\n', '') || 'Agent: ' || t.agent_text,\n                    E'\\n' ORDER BY t.turn_index), '')\n             FROM call_turns t WHERE t.call_sid = calls.call_sid) AS transcript;",
        "options": {
          "queryReplacement": "={{ [ $json.body.CallSid, Number($json.body.CallDuration || 0) ] }}"
        }
      },
      "id": "ca801cc8-8430-5bdf-a31b-0205e5a891b2",
      "name": "Assemble Transcript",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1040,
        1480
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "bc63e62a-dc58-55fb-8f6c-df3fa0294229",
              "leftValue": "={{ String($json.transcript || '').includes('Caller:') }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "2911ed4c-6f00-56f5-a498-23c4f40fef0a",
      "name": "Caller Engaged?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1300,
        1480
      ]
    },
    {
      "parameters": {
        "text": "=Call transcript ({{ $json.after_hours ? 'after hours' : 'business hours' }}, transferred: {{ $json.transferred }}, callback flagged: {{ $json.needs_callback }}):\n\n{{ $json.transcript }}",
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"summary\": { \"type\": \"string\", \"description\": \"2-3 sentence CRM summary of the call\" },\n    \"intent\": { \"type\": \"string\", \"enum\": [\"book_service\", \"job_status\", \"pricing_question\", \"billing\", \"complaint\", \"emergency\", \"other\"] },\n    \"outcome\": { \"type\": \"string\", \"enum\": [\"resolved\", \"booked\", \"ticket_created\", \"transferred\", \"callback_needed\", \"unresolved\"] },\n    \"sentiment\": { \"type\": \"number\", \"description\": \"Caller sentiment from -1 (very negative) to 1 (very positive)\" },\n    \"urgency\": { \"type\": \"string\", \"enum\": [\"low\", \"normal\", \"high\", \"emergency\"] },\n    \"qa_score\": { \"type\": \"integer\", \"description\": \"0-100 score for how well the AI agent handled the call\" },\n    \"qa_notes\": { \"type\": \"string\", \"description\": \"What the agent did well or should improve\" },\n    \"escalate\": { \"type\": \"boolean\", \"description\": \"True if a manager should review this call today\" },\n    \"sms_consent\": { \"type\": \"boolean\", \"description\": \"True ONLY if the caller explicitly agreed to receive a text summary\" },\n    \"sms_summary\": { \"type\": \"string\", \"description\": \"Friendly recap for the caller, under 300 characters, including any booked window or ticket number\" },\n    \"follow_up_actions\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }\n  },\n  \"required\": [\"summary\", \"intent\", \"outcome\", \"sentiment\", \"urgency\", \"qa_score\", \"qa_notes\", \"escalate\", \"sms_consent\", \"sms_summary\", \"follow_up_actions\"]\n}",
        "options": {
          "systemPromptTemplate": "You are a call-center quality analyst for a home services company. Read the transcript of a call between a caller and the AI receptionist and extract the requested fields. Be strict: sms_consent is true only when the caller clearly said yes to a text. Set escalate to true for emergencies, angry or repeat-complaint callers, failed transfers, safety issues, or any promise the company must honour. Score QA on accuracy, empathy, brevity and whether the caller's goal was met."
        }
      },
      "id": "22d61654-5da0-5c5c-848f-a24023e8f504",
      "name": "Post-Call Analyst",
      "type": "@n8n/n8n-nodes-langchain.informationExtractor",
      "typeVersion": 1,
      "position": [
        1638,
        1380
      ]
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "claude-opus-5"
        },
        "options": {
          "maxTokensToSample": 2000
        }
      },
      "id": "83b21e1c-86d8-59ba-bda0-9ad75b96a086",
      "name": "Claude Opus 5 · Analyst",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.3,
      "position": [
        1638,
        1600
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE calls\nSET summary = $2, intent = $3, outcome = $4, sentiment = $5, urgency = $6,\n    qa_score = $7, qa_notes = $8, escalate = $9, analysis = $10::jsonb\nWHERE call_sid = $1\nRETURNING call_sid;",
        "options": {
          "queryReplacement": "={{ [ $('Assemble Transcript').first().json.call_sid, $json.output.summary, $json.output.intent, $json.output.outcome, $json.output.sentiment, $json.output.urgency, $json.output.qa_score, $json.output.qa_notes, $json.output.escalate, JSON.stringify($json.output) ] }}"
        }
      },
      "id": "f8b1e900-7b20-52b9-b119-e7c7429bf881",
      "name": "Save Call Analysis",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1976,
        1380
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "ecc509c4-1ddc-5e09-a1cb-eec2c61c3c34",
                    "leftValue": "={{ $('Post-Call Analyst').first().json.output.escalate || $('Assemble Transcript').first().json.needs_callback || Number($('Post-Call Analyst').first().json.output.sentiment) <= -0.4 }}",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "rightValue": ""
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Escalate"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "00d4b588-86f5-553d-a251-1a0d63b37ca8",
                    "leftValue": "={{ $('Post-Call Analyst').first().json.output.sms_consent }}",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "rightValue": ""
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Text Recap"
            }
          ]
        },
        "options": {
          "allMatchingOutputs": true
        }
      },
      "id": "8887ade0-765e-5794-8430-f9e3480ee69d",
      "name": "Route Follow-ups",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        2262,
        1380
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Call Center Config').first().json.slackChannelId }}"
        },
        "text": "=:rotating_light: *Call needs review* ({{ $('Post-Call Analyst').first().json.output.intent }}, {{ $('Post-Call Analyst').first().json.output.outcome }}, urgency {{ $('Post-Call Analyst').first().json.output.urgency }})\n*Caller:* {{ $('Assemble Transcript').first().json.first_name || 'Unknown' }}, {{ $('Assemble Transcript').first().json.from_number }}\n*Summary:* {{ $('Post-Call Analyst').first().json.output.summary }}\n*Sentiment:* {{ $('Post-Call Analyst').first().json.output.sentiment }}  *QA:* {{ $('Post-Call Analyst').first().json.output.qa_score }}/100  *Callback needed:* {{ $('Assemble Transcript').first().json.needs_callback ? 'YES' : 'no' }}\n*Next steps:* {{ ($('Post-Call Analyst').first().json.output.follow_up_actions || []).join(' / ') || 'none listed' }}",
        "otherOptions": {
          "includeLinkToWorkflow": true
        }
      },
      "id": "6a4579c2-8be5-5b25-8a27-9516b1792614",
      "name": "Slack: Escalation",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2574,
        1280
      ]
    },
    {
      "parameters": {
        "resource": "sms",
        "operation": "send",
        "from": "={{ $('Assemble Transcript').first().json.to_number }}",
        "to": "={{ $('Assemble Transcript').first().json.from_number }}",
        "message": "={{ $('Post-Call Analyst').first().json.output.sms_summary }} - {{ $('Call Center Config').first().json.businessName }}",
        "options": {}
      },
      "id": "3c064231-f846-57cf-bf1a-1cf06b52be13",
      "name": "SMS: Call Recap",
      "type": "n8n-nodes-base.twilio",
      "typeVersion": 1,
      "position": [
        2574,
        1480
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "-- Transcript lives in call_turns; drop the raw LLM memory once the call is analysed\nDELETE FROM n8n_chat_histories WHERE session_id = $1;",
        "options": {
          "queryReplacement": "={{ [ $('Assemble Transcript').first().json.call_sid ] }}"
        }
      },
      "id": "dc50bb8d-92c3-5e3c-84f3-0f956ddb45cc",
      "name": "Purge Session Memory",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        2262,
        1600
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "resource": "sms",
        "operation": "send",
        "from": "={{ $json.to_number }}",
        "to": "={{ $json.from_number }}",
        "message": "=Sorry we got cut off! This is {{ $('Call Center Config').first().json.businessName }}. Reply here or call back anytime and we will get you sorted.",
        "options": {}
      },
      "id": "b572bef3-21a1-52c8-aacb-b72a838c1e89",
      "name": "SMS: Missed-Call Text-Back",
      "type": "n8n-nodes-base.twilio",
      "typeVersion": 1,
      "position": [
        1638,
        1800
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE calls SET outcome = 'abandoned', summary = 'Caller hung up during the greeting; text-back sent.'\nWHERE call_sid = $1;",
        "options": {
          "queryReplacement": "={{ [ $('Assemble Transcript').first().json.call_sid ] }}"
        }
      },
      "id": "93615fb4-94b3-5361-8b6b-d7ee18ca7bb7",
      "name": "Mark Abandoned",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1976,
        1800
      ]
    },
    {
      "parameters": {},
      "id": "fe9e4dee-bee9-56fd-a40c-304e4bb8e95f",
      "name": "Run Once: Create Tables",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        2120
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "-- AI Call Center schema + demo data. Safe to re-run.\nCREATE TABLE IF NOT EXISTS customers (\n  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  first_name   text NOT NULL,\n  last_name    text,\n  phone        text UNIQUE NOT NULL,          -- E.164, matches Twilio \"From\"\n  email        text,\n  address_zip  text,\n  tier         text NOT NULL DEFAULT 'standard',  -- standard | member | vip\n  created_at   timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE IF NOT EXISTS service_jobs (\n  id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  job_number      text UNIQUE NOT NULL,\n  customer_id     bigint REFERENCES customers(id),\n  customer_phone  text,\n  service_zip     text,\n  service_type    text NOT NULL,\n  status          text NOT NULL DEFAULT 'scheduled',  -- scheduled | en_route | in_progress | awaiting_parts | completed\n  technician_name text,\n  scheduled_start timestamptz,\n  arrival_window  text,\n  notes           text\n);\n\nCREATE TABLE IF NOT EXISTS kb_articles (\n  id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  title         text NOT NULL,\n  answer        text NOT NULL,\n  search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || answer)) STORED\n);\nCREATE INDEX IF NOT EXISTS kb_articles_search_idx ON kb_articles USING gin (search_vector);\n\nCREATE TABLE IF NOT EXISTS calls (\n  call_sid         text PRIMARY KEY,\n  from_number      text,\n  to_number        text,\n  customer_id      bigint REFERENCES customers(id),\n  after_hours      boolean NOT NULL DEFAULT false,\n  caller_city      text,\n  caller_state     text,\n  turn_count       int NOT NULL DEFAULT 0,\n  transferred      boolean NOT NULL DEFAULT false,\n  needs_callback   boolean NOT NULL DEFAULT false,\n  status           text NOT NULL DEFAULT 'in_progress',\n  started_at       timestamptz NOT NULL DEFAULT now(),\n  last_activity_at timestamptz NOT NULL DEFAULT now(),\n  ended_at         timestamptz,\n  duration_seconds int,\n  summary          text,\n  intent           text,\n  outcome          text,\n  sentiment        numeric(3,2),\n  urgency          text,\n  qa_score         int,\n  qa_notes         text,\n  escalate         boolean,\n  analysis         jsonb\n);\nCREATE INDEX IF NOT EXISTS calls_from_idx ON calls (from_number, started_at DESC);\n\nCREATE TABLE IF NOT EXISTS call_turns (\n  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  call_sid    text NOT NULL REFERENCES calls(call_sid) ON DELETE CASCADE,\n  turn_index  int NOT NULL,\n  caller_text text,\n  agent_text  text,\n  action      text,\n  reason      text,\n  tools_used  jsonb NOT NULL DEFAULT '[]',\n  created_at  timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS call_turns_call_idx ON call_turns (call_sid, turn_index);\n\nCREATE SEQUENCE IF NOT EXISTS ticket_seq START 1001;\nCREATE TABLE IF NOT EXISTS support_tickets (\n  id                 bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  ticket_number      text UNIQUE NOT NULL DEFAULT ('TCK-' || nextval('ticket_seq')),\n  call_sid           text,\n  customer_id        bigint REFERENCES customers(id),\n  caller_phone       text,\n  category           text,\n  priority           text NOT NULL DEFAULT 'normal',\n  summary            text NOT NULL,\n  callback_requested boolean NOT NULL DEFAULT false,\n  status             text NOT NULL DEFAULT 'open',\n  created_at         timestamptz NOT NULL DEFAULT now()\n);\n\n-- Demo data (replace with your CRM sync)\nINSERT INTO customers (first_name, last_name, phone, email, address_zip, tier) VALUES\n  ('Maria', 'Lopez',  '+15555550123', 'maria@example.com', '10001', 'vip'),\n  ('James', 'Carter', '+15555550124', 'james@example.com', '11201', 'member')\nON CONFLICT (phone) DO NOTHING;\n\nINSERT INTO service_jobs (job_number, customer_id, customer_phone, service_zip, service_type, status, technician_name, scheduled_start, arrival_window, notes)\nSELECT 'BL-10492', id, phone, address_zip, 'Water heater replacement', 'awaiting_parts', 'Dev Patel',\n       now() + interval '2 days', '8 to 10 AM', 'New 50 gallon tank ordered, arriving Thursday'\nFROM customers WHERE phone = '+15555550123'\nON CONFLICT (job_number) DO NOTHING;\n\nINSERT INTO service_jobs (job_number, customer_id, customer_phone, service_zip, service_type, status, technician_name, scheduled_start, arrival_window, notes)\nSELECT 'BL-10517', id, phone, address_zip, 'AC tune-up', 'en_route', 'Sam Okafor',\n       now() + interval '1 hour', '1 to 3 PM', 'Technician is on the way'\nFROM customers WHERE phone = '+15555550124'\nON CONFLICT (job_number) DO NOTHING;\n\nINSERT INTO kb_articles (title, answer)\nSELECT v.title, v.answer FROM (VALUES\n  ('Service call and diagnostic fee', 'A diagnostic visit costs 89 dollars and is waived if you approve the repair. Brightline Care members pay nothing for diagnostics.'),\n  ('Service area', 'We serve Manhattan, Brooklyn and Queens, plus Jersey City and Hoboken. Outside that area we can refer a trusted partner.'),\n  ('Business hours and emergencies', 'The office is open Monday to Saturday, 8 AM to 6 PM. Emergency plumbing, heating and electrical service is available around the clock through our on-call technician.'),\n  ('Warranty', 'Every repair carries a one year parts and labor warranty. New installations carry a ten year parts warranty once registered.'),\n  ('Membership plan', 'Brightline Care costs 19 dollars a month and includes two tune-ups per year, no diagnostic fees, 15 percent off repairs and priority scheduling.'),\n  ('Payment and financing', 'We accept all major cards and ACH. Financing is available for installations over 1000 dollars with approved credit.'),\n  ('Rescheduling and cancellation', 'Appointments can be rescheduled or cancelled for free up to two hours before the arrival window.'),\n  ('Water heater replacement', 'Standard tank water heater replacement usually takes 3 to 4 hours. Tankless upgrades take most of a day and need a site survey first.')\n) AS v(title, answer)\nWHERE NOT EXISTS (SELECT 1 FROM kb_articles);\n",
        "options": {}
      },
      "id": "5dac21b9-6fbb-50af-951e-23b3dc891738",
      "name": "Create Schema & Demo Data",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        260,
        2120
      ]
    }
  ],
  "pinData": {},
  "connections": {
    "Twilio: Incoming Call": {
      "main": [
        [
          {
            "node": "Call Center Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Twilio: Caller Spoke": {
      "main": [
        [
          {
            "node": "Call Center Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Twilio: Call Status": {
      "main": [
        [
          {
            "node": "Call Center Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call Center Config": {
      "main": [
        [
          {
            "node": "Route Twilio Event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Twilio Event": {
      "main": [
        [
          {
            "node": "Identify Caller",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Load Call State",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Call Completed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Identify Caller": {
      "main": [
        [
          {
            "node": "Compose Greeting",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compose Greeting": {
      "main": [
        [
          {
            "node": "Respond: Greeting",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond: Greeting": {
      "main": [
        [
          {
            "node": "Log Call Start",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search Knowledge Base": {
      "ai_tool": [
        [
          {
            "node": "AI Receptionist",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Lookup Service Job": {
      "ai_tool": [
        [
          {
            "node": "AI Receptionist",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Check Availability": {
      "ai_tool": [
        [
          {
            "node": "AI Receptionist",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Book Appointment": {
      "ai_tool": [
        [
          {
            "node": "AI Receptionist",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Create Ticket": {
      "ai_tool": [
        [
          {
            "node": "AI Receptionist",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Transfer to Human": {
      "ai_tool": [
        [
          {
            "node": "AI Receptionist",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "End Call": {
      "ai_tool": [
        [
          {
            "node": "AI Receptionist",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Claude Opus 5 · Voice": {
      "ai_languageModel": [
        [
          {
            "node": "AI Receptionist",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Call Memory": {
      "ai_memory": [
        [
          {
            "node": "AI Receptionist",
            "type": "ai_memory",
            "index": 0
          }
        ]
      ]
    },
    "Load Call State": {
      "main": [
        [
          {
            "node": "Turn Guardrails",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Turn Guardrails": {
      "main": [
        [
          {
            "node": "Route Turn",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Turn": {
      "main": [
        [
          {
            "node": "AI Receptionist",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build TwiML",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build TwiML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Receptionist": {
      "main": [
        [
          {
            "node": "Interpret Agent Result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "AI Failure Fallback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Interpret Agent Result": {
      "main": [
        [
          {
            "node": "Build TwiML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Failure Fallback": {
      "main": [
        [
          {
            "node": "Build TwiML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build TwiML": {
      "main": [
        [
          {
            "node": "Respond: TwiML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond: TwiML": {
      "main": [
        [
          {
            "node": "Save Turn",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Turn": {
      "main": [
        [
          {
            "node": "Transferring?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Transferring?": {
      "main": [
        [
          {
            "node": "Slack: Live Transfer Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude Opus 5 · Analyst": {
      "ai_languageModel": [
        [
          {
            "node": "Post-Call Analyst",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Call Completed?": {
      "main": [
        [
          {
            "node": "Assemble Transcript",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assemble Transcript": {
      "main": [
        [
          {
            "node": "Caller Engaged?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Caller Engaged?": {
      "main": [
        [
          {
            "node": "Post-Call Analyst",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "SMS: Missed-Call Text-Back",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SMS: Missed-Call Text-Back": {
      "main": [
        [
          {
            "node": "Mark Abandoned",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post-Call Analyst": {
      "main": [
        [
          {
            "node": "Save Call Analysis",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Call Analysis": {
      "main": [
        [
          {
            "node": "Route Follow-ups",
            "type": "main",
            "index": 0
          },
          {
            "node": "Purge Session Memory",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Follow-ups": {
      "main": [
        [
          {
            "node": "Slack: Escalation",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "SMS: Call Recap",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Once: Create Tables": {
      "main": [
        [
          {
            "node": "Create Schema & Demo Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}