{
  "id": "oflxzPJ2ugYuM9Xh",
  "name": "AI Voice Receptionist for Dental Clinics",
  "tags": [],
  "nodes": [
    {
      "id": "sticky_overview",
      "name": "OVERVIEW",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -912,
        160
      ],
      "parameters": {
        "color": 4,
        "width": 688,
        "height": 956,
        "content": "## \ud83e\uddb7 AI Voice Receptionist for Dental Clinics\n### Vapi \u2014 Real-Time Booking & Human Escalation\n\nA production-shaped voice receptionist for dental clinics, powered by Vapi. Answers calls, checks availability, checks insurance, books appointments in real time, and **escalates to a real human instead of guessing** whenever something is urgent, the caller sounds distressed, or the request falls outside its scope.\n\n**Why this isn't just another AI receptionist template:** most booking-bot templates try to resolve every call autonomously. This one is built around a narrow-scope + honest-escalation philosophy \u2014 the AI does exactly 4 defined jobs and hands off cleanly the moment it's uncertain, instead of improvising a plausible-sounding wrong answer.\n\n### Stack\n- **Vapi** \u2014 voice orchestration (STT \u2192 LLM \u2192 TTS) and the caller-facing experience; calls this workflow via 4 custom \"API Request\" tools\n- **This workflow** \u2014 routing logic, mocked business logic, Airtable logging (built in n8n)\n- **Airtable** \u2014 call log + insurance reference table\n- **gotoHuman** \u2014 async human review queue for escalations\n\n### Setup checklist\n1. Create an Airtable base with a `Calls` table (see field list in the yellow BOOKING note) and connect your own Airtable credential to both Airtable nodes\n2. Create a gotoHuman review form + agent, connect your own gotoHuman credential to the escalation node, and select your form/agent in its dropdowns\n3. Activate this workflow, copy the **Vapi Tool Call Webhook** production URL\n4. In Vapi: create 4 \"API Request\" tools (`check_availability`, `check_insurance`, `book_appointment`, `escalate_to_human`) all pointing at that URL \n5. **If self-hosting behind ngrok's free tier:** add header `ngrok-skip-browser-warning: true` to every tool, or Vapi's requests will get an HTML interstitial instead of your webhook\n6. Add the system prompt into your Vapi assistant\n7. For the Approve/Reject buttons in gotoHuman to work, paste this workflow's **gotoHuman Review Callback** URL into your gotoHuman agent's webhook settings\n"
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_routing",
      "name": "1\ufe0f\u20e3 CALL ROUTING",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -48,
        176
      ],
      "parameters": {
        "width": 520,
        "height": 356,
        "content": "## 1\ufe0f\u20e3 Receive + Route\nVapi calls this webhook every time it needs to do something \u2014 check availability, check insurance, book, or escalate."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_availability",
      "name": "2\ufe0f\u20e3 AVAILABILITY",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        496,
        176
      ],
      "parameters": {
        "color": 3,
        "width": 404,
        "height": 352,
        "content": "## 2\ufe0f\u20e3 Check Availability\nReturns 3 hardcoded slots. Replace with a real Google Calendar free/busy lookup for production use."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_insurance",
      "name": "3\ufe0f\u20e3 INSURANCE",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        976,
        176
      ],
      "parameters": {
        "color": 5,
        "width": 340,
        "height": 352,
        "content": "## 3\ufe0f\u20e3 Check Insurance\nHardcoded list of 5 providers. Replace with a real Airtable lookup table for production use."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_booking",
      "name": "4\ufe0f\u20e3 BOOKING",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1056,
        688
      ],
      "parameters": {
        "color": 2,
        "width": 1008,
        "height": 420,
        "content": "## 4\ufe0f\u20e3 Book Appointment\nWrites a **real** record to Airtable. Requires a `Calls` table with fields: Caller Name, Phone, Appointment Type, Insurance Provider, Slot Time, Source (single select: mock_logic_test / vapi_live_booking), Reason, Urgency, Needs Human Tone, Insurance Status, Slot Duration."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_escalation",
      "name": "\ud83d\udea8 ESCALATION",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1664,
        1120
      ],
      "parameters": {
        "color": 6,
        "width": 944,
        "height": 356,
        "content": "## \ud83d\udea8 Escalate to Human (gotoHuman)\nFires on emergency language, caller distress, or anything outside the assistant's 4 jobs. Uses **'send'** (fire-and-forget) not 'sendAndWait' \u2014 a live call can't stay open for days waiting on a human. The caller is told someone will follow up; the review happens async in your gotoHuman inbox."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_review_callback",
      "name": "\ud83d\udd01 REVIEW CALLBACK",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -144,
        880
      ],
      "parameters": {
        "color": 7,
        "width": 1028,
        "height": 372,
        "content": "## \ud83d\udd01 gotoHuman Review Callback\nReceives Approve/Reject decisions from gotoHuman and logs them to Airtable."
      },
      "typeVersion": 1
    },
    {
      "id": "vapi_webhook",
      "name": "Vapi Tool Call Webhook",
      "type": "n8n-nodes-base.webhook",
      "notes": "This is the URL you'll paste into Vapi as each tool's Server URL. Vapi calls this MID-CALL whenever the assistant runs check_availability, check_insurance, book_appointment, or escalate_to_human.",
      "position": [
        0,
        400
      ],
      "parameters": {
        "path": "vapi-dental-functions",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "extract_tool_call",
      "name": "Extract Tool Call",
      "type": "n8n-nodes-base.code",
      "notes": "Handles Vapi's 'API Request' tool format: raw params directly, no toolCallList wrapper. Function name inferred from which parameters are present, since all 4 tools share this one URL.",
      "position": [
        272,
        400
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\n\n// Custom Function tool format (message.toolCallList) \u2014 supported in case you switch to this later\nconst toolCall = body.message?.toolCallList?.[0];\nif (toolCall) {\n  return [{\n    json: {\n      toolCallId: toolCall.id,\n      functionName: toolCall.function?.name || '',\n      args: toolCall.function?.arguments || {}\n    }\n  }];\n}\n\n// API Request tool format \u2014 Vapi sends the raw params directly, no function name field.\n// Infer which of the 4 tools was called based on which params are present.\n// Check escalate_to_human FIRST since it can also carry caller_name/phone, which would otherwise collide with book_appointment.\nlet functionName = '';\nif ('reason' in body || 'urgency' in body || 'sentiment_flag' in body) {\n  functionName = 'escalate_to_human';\n} else if ('caller_name' in body || 'slot_time' in body) {\n  functionName = 'book_appointment';\n} else if ('insurance_provider' in body) {\n  functionName = 'check_insurance';\n} else if ('appointment_type' in body) {\n  functionName = 'check_availability';\n}\n\nreturn [{\n  json: {\n    toolCallId: 'direct-call',\n    functionName,\n    args: body\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "if_availability",
      "name": "Is Check Availability?",
      "type": "n8n-nodes-base.if",
      "notes": "Routes to slot-availability logic.",
      "position": [
        528,
        400
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.functionName}}",
              "rightValue": "check_availability"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "if_insurance",
      "name": "Is Check Insurance?",
      "type": "n8n-nodes-base.if",
      "notes": "Routes to insurance-lookup logic.",
      "position": [
        784,
        560
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.functionName}}",
              "rightValue": "check_insurance"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "if_booking",
      "name": "Is Book Appointment?",
      "type": "n8n-nodes-base.if",
      "notes": "Routes to booking logic.",
      "position": [
        1104,
        928
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.functionName}}",
              "rightValue": "book_appointment"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "check_availability_logic",
      "name": "Check Availability Logic",
      "type": "n8n-nodes-base.code",
      "notes": "MOCKED: hardcoded slot list. Replace with a real Google Calendar 'Get Free/Busy' lookup for production.",
      "position": [
        752,
        352
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst args = item.args;\n\nconst durations = { cleaning: 30, filling: 45, 'root canal': 90, extraction: 60, checkup: 20, whitening: 45 };\nconst type = (args.appointment_type || 'cleaning').toLowerCase();\nconst duration = durations[type] || 30;\n\n// MOCK slots \u2014 swap for real Google Calendar availability lookup\nconst mockSlots = ['tomorrow at 10:00 AM', 'tomorrow at 2:30 PM', 'Thursday at 9:00 AM'];\n\nreturn [{\n  json: {\n    toolCallId: item.toolCallId,\n    result: `For a ${type} appointment, which takes about ${duration} minutes, we have openings ${mockSlots.join(', ')}.`\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "check_insurance_logic",
      "name": "Check Insurance Logic",
      "type": "n8n-nodes-base.code",
      "notes": "MOCKED: hardcoded in-network list. Replace with a real Airtable lookup for production.",
      "position": [
        1104,
        384
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst args = item.args;\n\nconst inNetworkPlans = ['delta dental', 'cigna dental', 'metlife', 'aetna dental', 'guardian'];\nconst provider = (args.insurance_provider || '').toLowerCase();\nconst inNetwork = inNetworkPlans.some(p => provider.includes(p));\n\nlet resultText;\nif (!provider) {\n  resultText = \"I didn't catch the insurance provider name \u2014 could you repeat it?\";\n} else if (inNetwork) {\n  resultText = `Good news \u2014 ${args.insurance_provider} is in-network with us.`;\n} else {\n  resultText = `I'm not seeing ${args.insurance_provider} as in-network on my end, but our front desk will confirm your exact coverage before your visit.`;\n}\n\nreturn [{ json: { toolCallId: item.toolCallId, result: resultText } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "book_appointment_prep",
      "name": "Book Appointment Prep",
      "type": "n8n-nodes-base.code",
      "notes": "Vapi assistant should call book_appointment with args: {caller_name, phone, appointment_type, slot_time, insurance_provider}.",
      "position": [
        1328,
        848
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst args = item.args;\n\nreturn [{\n  json: {\n    toolCallId: item.toolCallId,\n    caller_name: args.caller_name || 'Unknown',\n    phone: args.phone || '',\n    appointment_type: (args.appointment_type || 'cleaning').toLowerCase(),\n    slot_time: args.slot_time || 'unspecified',\n    insurance_provider: args.insurance_provider || ''\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "log_booking_airtable",
      "name": "Log Booking to Airtable",
      "type": "n8n-nodes-base.airtable",
      "notes": "SETUP NEEDED: connect your own Airtable credential, then select your base + Calls table from the dropdowns.",
      "position": [
        1552,
        848
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "value": {
            "Phone": "={{$json.phone}}",
            "Source": "vapi_live_booking",
            "Slot Time": "={{$json.slot_time}}",
            "Caller Name": "={{$json.caller_name}}",
            "Appointment Type": "={{$json.appointment_type}}",
            "Insurance Provider": "={{$json.insurance_provider}}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "create"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "format_booking_result",
      "name": "Format Booking Result",
      "type": "n8n-nodes-base.code",
      "notes": "This is what gets spoken back to the caller by Vapi.",
      "position": [
        1792,
        848
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\n\nreturn [{\n  json: {\n    toolCallId: item.toolCallId,\n    result: `You're booked for a ${item.appointment_type} appointment ${item.slot_time}. We'll send a confirmation text to ${item.phone}.`\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "unknown_function_result",
      "name": "Unknown Function Result",
      "type": "n8n-nodes-base.code",
      "notes": "Fallback for any function name that isn't recognized.",
      "position": [
        1920,
        976
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\nreturn [{ json: { toolCallId: item.toolCallId, result: \"Sorry, I'm not able to handle that yet \u2014 let me get you to the front desk.\" } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "merge_function_results",
      "name": "Merge Function Results",
      "type": "n8n-nodes-base.merge",
      "notes": "Uses 'append' mode \u2014 required since only 1 of 5 branches ever fires per call. 'combine' mode will silently produce zero output here.",
      "position": [
        2080,
        400
      ],
      "parameters": {
        "numberInputs": 5
      },
      "typeVersion": 3
    },
    {
      "id": "wrap_vapi_format",
      "name": "Format Response for Vapi",
      "type": "n8n-nodes-base.code",
      "notes": "API Request tools expect a plain JSON response, not a toolCallId-wrapped format.",
      "position": [
        2352,
        400
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\n\nreturn [{\n  json: {\n    result: item.result\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "respond_vapi",
      "name": "Respond To Vapi",
      "type": "n8n-nodes-base.respondToWebhook",
      "notes": "Forces a raw JSON string body with explicit Content-Type header \u2014 more reliable than relying on respondWith:'json' auto-serialization.",
      "position": [
        2608,
        400
      ],
      "parameters": {
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "text",
        "responseBody": "={{ JSON.stringify($json) }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "if_escalate_vapi",
      "name": "Is Escalate To Human?",
      "type": "n8n-nodes-base.if",
      "notes": "Catches the escalate_to_human tool call.",
      "position": [
        1712,
        1312
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.functionName}}",
              "rightValue": "escalate_to_human"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "escalate_prep_vapi",
      "name": "Escalate Prep",
      "type": "n8n-nodes-base.code",
      "notes": "Vapi assistant should call escalate_to_human with args: {caller_name, phone, reason, urgency, sentiment_flag, appointment_type, insurance_provider}.",
      "position": [
        1936,
        1296
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst args = item.args;\n\nreturn [{\n  json: {\n    toolCallId: item.toolCallId,\n    caller_name: args.caller_name || 'Unknown',\n    phone: args.phone || '',\n    reason: args.reason || '',\n    urgency: args.urgency || 'urgent',\n    sentiment_flag: args.sentiment_flag || 'neutral',\n    appointment_type: args.appointment_type || '',\n    insurance_provider: args.insurance_provider || ''\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "gotohuman_vapi",
      "name": "Escalate To Human (gotoHuman)",
      "type": "@gotohuman/n8n-nodes-gotohuman.gotoHuman",
      "notes": "SETUP NEEDED: connect your own gotoHuman credential, create a review form with fields matching reviewData below, then select your form + agent in the dropdowns. Uses 'send' (fire-and-forget) not 'sendAndWait' \u2014 a live call can't stay open waiting on a human review.",
      "position": [
        2192,
        1296
      ],
      "parameters": {
        "agentID": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "operation": "send",
        "reviewTemplateID": {
          "__rl": true,
          "mode": "list",
          "value": ""
        }
      },
      "typeVersion": 2
    },
    {
      "id": "format_escalation_result",
      "name": "Format Escalation Result",
      "type": "n8n-nodes-base.code",
      "notes": "This is what gets spoken back to the caller once escalation is sent.",
      "position": [
        2432,
        1296
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\n\nreturn [{\n  json: {\n    toolCallId: item.toolCallId,\n    result: \"I've flagged this for our team \u2014 someone will reach out to you directly as soon as possible.\"\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "gth_callback_webhook",
      "name": "gotoHuman Review Callback",
      "type": "n8n-nodes-base.webhook",
      "notes": "Paste this webhook's URL into gotoHuman's Agent settings so Approve/Reject decisions have somewhere to go.",
      "position": [
        -96,
        1024
      ],
      "parameters": {
        "path": "gotohuman-review-callback",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "parse_review_event",
      "name": "Parse Review Event",
      "type": "n8n-nodes-base.code",
      "notes": "gotoHuman's exact payload shape wasn't confirmed at build time \u2014 this logs the raw event regardless of exact structure. Refine once you see a real payload.",
      "position": [
        176,
        1024
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\n\nreturn [{\n  json: {\n    reviewId: body.reviewId || body.id || 'unknown',\n    eventType: body.type || body.event || 'unknown',\n    decision: body.status || body.decision || body.result || 'unknown',\n    rawPayload: JSON.stringify(body)\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "log_review_outcome",
      "name": "Log Review Outcome to Airtable",
      "type": "n8n-nodes-base.airtable",
      "notes": "SETUP NEEDED: connect your own Airtable credential and select base + table.",
      "position": [
        432,
        1024
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "value": {
            "Reason": "={{$json.rawPayload}}",
            "Caller Name": "=[Review Decision] {{$json.decision}}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "create"
      },
      "typeVersion": 2.1
    },
    {
      "id": "respond_review_callback",
      "name": "Respond OK to gotoHuman",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        688,
        1024
      ],
      "parameters": {
        "options": {},
        "respondWith": "text",
        "responseBody": "={{ JSON.stringify({received: true}) }}"
      },
      "typeVersion": 1.1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all"
  },
  "versionId": "e866c3f1-adb1-43cb-990f-e97db595a707",
  "nodeGroups": [],
  "connections": {
    "Escalate Prep": {
      "main": [
        [
          {
            "node": "Escalate To Human (gotoHuman)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Tool Call": {
      "main": [
        [
          {
            "node": "Is Check Availability?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Review Event": {
      "main": [
        [
          {
            "node": "Log Review Outcome to Airtable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Check Insurance?": {
      "main": [
        [
          {
            "node": "Check Insurance Logic",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Is Book Appointment?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Book Appointment?": {
      "main": [
        [
          {
            "node": "Book Appointment Prep",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Is Escalate To Human?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Book Appointment Prep": {
      "main": [
        [
          {
            "node": "Log Booking to Airtable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Insurance Logic": {
      "main": [
        [
          {
            "node": "Merge Function Results",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Format Booking Result": {
      "main": [
        [
          {
            "node": "Merge Function Results",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Is Escalate To Human?": {
      "main": [
        [
          {
            "node": "Escalate Prep",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Unknown Function Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Check Availability?": {
      "main": [
        [
          {
            "node": "Check Availability Logic",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Is Check Insurance?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Function Results": {
      "main": [
        [
          {
            "node": "Format Response for Vapi",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Vapi Tool Call Webhook": {
      "main": [
        [
          {
            "node": "Extract Tool Call",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Booking to Airtable": {
      "main": [
        [
          {
            "node": "Format Booking Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Unknown Function Result": {
      "main": [
        [
          {
            "node": "Merge Function Results",
            "type": "main",
            "index": 3
          }
        ]
      ]
    },
    "Check Availability Logic": {
      "main": [
        [
          {
            "node": "Merge Function Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Escalation Result": {
      "main": [
        [
          {
            "node": "Merge Function Results",
            "type": "main",
            "index": 4
          }
        ]
      ]
    },
    "Format Response for Vapi": {
      "main": [
        [
          {
            "node": "Respond To Vapi",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "gotoHuman Review Callback": {
      "main": [
        [
          {
            "node": "Parse Review Event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Escalate To Human (gotoHuman)": {
      "main": [
        [
          {
            "node": "Format Escalation Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Review Outcome to Airtable": {
      "main": [
        [
          {
            "node": "Respond OK to gotoHuman",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}