{
  "id": "77pJCZrTmSlJBxnh",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "AI Voice Appointment Setter (Vapi + Google Calendar)",
  "tags": [],
  "nodes": [
    {
      "id": "30a2f5b7-b9b8-477d-817c-e180192646d7",
      "name": "1. CONFIGURATION (EDIT ME)",
      "type": "n8n-nodes-base.set",
      "notes": "This node holds all the settings for your appointment setter's availability.",
      "position": [
        -448,
        96
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "1fec2f61-2072-4d2b-b323-b20dc8be1eee",
              "name": "timeZone",
              "type": "string",
              "value": "America/New_York"
            },
            {
              "id": "4262429e-b497-4b14-88a0-c1747465cb2c",
              "name": "workdayStartHour",
              "type": "number",
              "value": 9
            },
            {
              "id": "8ad05af7-a5ae-4838-bf60-e45b0c2021a7",
              "name": "workdayEndHour",
              "type": "number",
              "value": 17
            },
            {
              "id": "d618f077-a077-43ad-9bb5-55eb21445947",
              "name": "meetingDurationMinutes",
              "type": "number",
              "value": 30
            },
            {
              "id": "67453847-1958-45e5-aaf1-a042d9bb1c29",
              "name": "bookingCadenceMinutes",
              "type": "number",
              "value": 30
            },
            {
              "id": "f166930f-a05f-4382-8017-6b3c94717840",
              "name": "bufferBeforeMinutes",
              "type": "number",
              "value": 15
            },
            {
              "id": "dee10f61-c71e-4377-80a3-69de66c8a73a",
              "name": "bufferAfterMinutes",
              "type": "number",
              "value": 15
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "774e305d-252f-4d54-a057-19aeb642e42b",
      "name": "Webhook: Production URL = VAPI Server URL",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -688,
        96
      ],
      "parameters": {
        "path": "AI-Appointment-Setter-Template",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "1f67cedc-9da7-4b7e-8286-6ee3358fd70a",
      "name": "Route by Tool Name",
      "type": "n8n-nodes-base.switch",
      "position": [
        -208,
        96
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "checkAvailability",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "80297dae-adde-4206-a8c3-2b7f77c5b88c",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.name }}",
                    "rightValue": "checkAvailability"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "bookAppointment",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "23430f94-763-403d-a7d6-eae05ad2b801",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.name }}",
                    "rightValue": "bookAppointment"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "ac181bae-191b-42a8-aedf-5ab53c040051",
      "name": "Calculate Potential Slots (do not change)",
      "type": "n8n-nodes-base.code",
      "position": [
        208,
        0
      ],
      "parameters": {
        "jsCode": "const potentialSlots = [];\n\nconst config = $('1. CONFIGURATION (EDIT ME)').item.json;\n\nconst initialISO = $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.initialSearchDateTime;\n\nconst datePart = initialISO.split('T')[0];\nconst offsetPart = initialISO.slice(-6);\nconst startHourString = String(config.workdayStartHour).padStart(2, '0');\nconst endHourString = String(config.workdayEndHour).padStart(2, '0');\n\nconst workdayStartISO = `${datePart}T${startHourString}:00:00${offsetPart}`;\nconst workdayEndISO = `${datePart}T${endHourString}:00:00${offsetPart}`;\n\nlet currentSlot = new Date(workdayStartISO);\nconst endOfWorkday = new Date(workdayEndISO);\n\nwhile (currentSlot.getTime() < endOfWorkday.getTime()) {\n  const slotEnd = new Date(currentSlot.getTime() + config.bookingCadenceMinutes * 60000);\n  potentialSlots.push({\n    start: currentSlot.toISOString(),\n    end: slotEnd.toISOString(),\n  });\n  currentSlot = slotEnd;\n}\n\nreturn [{\n  json: {\n    ...config, \n    potentialSlots: potentialSlots\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "8e2cf90f-9f15-478b-9c4b-acae5c6ee2c9",
      "name": "Filter for Available Slots (do not change)",
      "type": "n8n-nodes-base.code",
      "position": [
        432,
        0
      ],
      "parameters": {
        "jsCode": "const config = $('Calculate Potential Slots (do not change)').item.json;\nconst potentialSlots = config.potentialSlots;\nconst busyEvents = $('2. Get Calendar Events (EDIT ME)').all();\nconst toolCallId = $('Webhook: Production URL = VAPI Server URL').first().json.body.message.toolCalls[0].id;\n\nconst busyIntervals = busyEvents\n  .filter(event => event.json.start && event.json.start.dateTime) \n  .map(event => {\n    const start = new Date(event.json.start.dateTime);\n    const end = new Date(event.json.end.dateTime);\n    const startWithBuffer = new Date(start.getTime() - config.bufferBeforeMinutes * 60000);\n    const endWithBuffer = new Date(end.getTime() + config.bufferAfterMinutes * 60000);\n    return { start: startWithBuffer, end: endWithBuffer };\n  });\n\nconst availableSlots = potentialSlots.filter(slot => {\n  const meetingStart = new Date(slot.start); \n  const meetingEnd = new Date(meetingStart.getTime() + config.meetingDurationMinutes * 60000);\n  if (meetingStart < new Date()) { return false; }\n  const isOverlapping = busyIntervals.some(busy => (meetingStart < busy.end) && (meetingEnd > busy.start));\n  return !isOverlapping;\n});\n\nconst formattedSlots = availableSlots.map(slot => {\n  const utcDate = new Date(slot.start); \n\n  const humanReadable = utcDate.toLocaleString('en-US', {\n    timeZone: config.timeZone,\n    weekday: 'long', month: 'long', day: 'numeric',\n    hour: 'numeric', minute: '2-digit', hour12: true,\n  });\n\n\n  const localFormatter = new Intl.DateTimeFormat('en-US', {\n    year: 'numeric', month: '2-digit', day: '2-digit',\n    hour: '2-digit', minute: '2-digit', second: '2-digit',\n    hourCycle: 'h23', \n    timeZone: config.timeZone\n  });\n\n  const localParts = localFormatter.formatToParts(utcDate);\n  let localYear, localMonth, localDay, localHour, localMinute, localSecond;\n\n  for (const part of localParts) {\n    switch (part.type) {\n      case 'year': localYear = part.value; break;\n      case 'month': localMonth = part.value; break;\n      case 'day': localDay = part.value; break;\n      case 'hour': localHour = part.value; break;\n      case 'minute': localMinute = part.value; break;\n      case 'second': localSecond = part.value; break;\n    }\n  }\n\n  const fakeUtcDateFromLocal = new Date(\n    `${localYear}-${localMonth}-${localDay}T${localHour}:${localMinute}:${localSecond}.000Z`\n  );\n\n  const offsetMillis = fakeUtcDateFromLocal.getTime() - utcDate.getTime();\n  const offsetMinutes = offsetMillis / 60000; \n\n  const absOffsetMinutes = Math.abs(offsetMinutes);\n  const offsetHours = Math.floor(absOffsetMinutes / 60);\n  const remainingOffsetMinutes = absOffsetMinutes % 60;\n\n  const offsetSign = offsetMinutes < 0 ? '-' : '+';\n\n  const isoOffset = `${offsetSign}${String(offsetHours).padStart(2, '0')}:${String(remainingOffsetMinutes).padStart(2, '0')}`;\n\n  const isoDateTime = `${localYear}-${localMonth}-${localDay}T${localHour}:${localMinute}:${localSecond}${isoOffset}`;\n\n  return { humanReadable, isoDateTime };\n});\n\nreturn [{\n  json: {\n    toolCallId: toolCallId,\n    availableSlots: formattedSlots\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "d14d093c-b468-454f-85a4-815c7b99c287",
      "name": "Respond with Available Times (do not change)",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        656,
        0
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{\n  {\n    \"results\": [\n      {\n        \"toolCallId\": $json.toolCallId,\n        \"result\": JSON.stringify({ \"availableSlots\": $json.availableSlots })\n      }\n    ]\n  }\n}}"
      },
      "typeVersion": 1.4
    },
    {
      "id": "7ea10fb1-8966-4bf3-ad42-ed3b52d41c66",
      "name": "I'm a note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        432,
        192
      ],
      "parameters": {
        "color": 7,
        "width": 348,
        "height": 396,
        "content": "This will get you going!\n\nNext level? Add:\n- SMS & Email Confirmations\n- Multilingual Support\n- In-depth Lead Qualification\n- Dynamic FAQ Answering\n- CRM Integration\n- Call Transferring\n- Rescheduling & Cancelling\n- Call Summaries & Analysis\n\nQuestions? Feel free to reach out:\nhttps://streetlamp.agency"
      },
      "typeVersion": 1
    },
    {
      "id": "4f80f53f-c639-448d-ab87-5e58778efece",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -64,
        -288
      ],
      "parameters": {
        "color": 7,
        "height": 272,
        "content": "## Edit Google Calendar nodes \n**1** Connect your Google calendar. Click: Credentials to connect with > Choose your credential OR Create new credential\n**2** Click: Calendar > From list > Choose your calendar"
      },
      "typeVersion": 1
    },
    {
      "id": "c52db039-40de-4458-aaf7-d3c770db190a",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -528,
        -224
      ],
      "parameters": {
        "color": 7,
        "width": 256,
        "height": 304,
        "content": "## Edit Fields\n(Using numbers, no text)\n**1.** Enter your timezone (e.g. \"America/New_York\" for EST, without quotes)\n**2.** Enter your work start hour and your work end hour (e.g. 8 for 8AM, 17 for 5PM)\n**3.** Enter meeting duration, buffer time before and after meeting (e.g. 60 for 1 hour meeting, 15 for 15 minutes buffer)"
      },
      "typeVersion": 1
    },
    {
      "id": "1f925858-22bb-4143-8a57-d37f10508858",
      "name": "3. Book Appointment in Calendar (EDIT ME)",
      "type": "n8n-nodes-base.googleCalendar",
      "notes": "1. Select your Google Account from the 'Credential' dropdown.\n\n2. Select the **SAME** calendar you chose in the previous node to book the new appointment.",
      "position": [
        0,
        192
      ],
      "parameters": {
        "end": "={{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.endDateTime }}",
        "start": "={{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.startDateTime }}",
        "calendar": {
          "__rl": true,
          "mode": "list"
        },
        "additionalFields": {
          "summary": "=Roof Inspection: {{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.clientName }}",
          "description": "=Job Details:\nService Type: {{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.serviceType }}\nProperty Address: {{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.propertyAddress }}\n\nClient Contact:\nName: {{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.clientName }}\nPhone: {{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.customer.number }}\n\nCall Log Id:\n{{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.call.id }}"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1.3
    },
    {
      "id": "d147ba98-9086-4ae0-b5c8-43e8cd2eb8ba",
      "name": "2. Get Calendar Events (EDIT ME)",
      "type": "n8n-nodes-base.googleCalendar",
      "notes": "1. Select your Google Account from the 'Credential' dropdown.\n\n2. Select the calendar you want to check for availability from the 'Calendar' dropdown list.",
      "position": [
        0,
        0
      ],
      "parameters": {
        "options": {},
        "timeMax": "={{ DateTime.fromISO($('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.initialSearchDateTime).endOf('day').toISO() }}",
        "timeMin": "={{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.initialSearchDateTime }}",
        "calendar": {
          "__rl": true,
          "mode": "list"
        },
        "operation": "getAll",
        "returnAll": true
      },
      "retryOnFail": true,
      "typeVersion": 1.3,
      "alwaysOutputData": true
    },
    {
      "id": "4355474c-9edb-4a4f-891d-0a349c75dc43",
      "name": "Booking Confirmation (do not change)",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        208,
        192
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ { \"results\": [ { \"toolCallId\": $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].id, \"result\": \"The appointment has been successfully booked.\" } ] } }}"
      },
      "typeVersion": 1.4
    },
    {
      "id": "2f332449-141c-4d6f-926f-78281aea2920",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1792,
        -672
      ],
      "parameters": {
        "width": 896,
        "height": 2288,
        "content": "## What this template does\n\nConnect a Vapi AI voice agent to Google Calendar to capture contact details and auto-book appointments.\nThe agent asks for name, address, service type, and a preferred time. The workflow checks availability and either proposes times or books the slot\u2014no code needed.\n\n## How it works (node map)\n\n- Webhook: Production URL = VAPI Server URL \u2014 receives tool calls from Vapi and returns results.\n- **1. CONFIGURATION (EDIT ME)** \u2014 your timezone, work hours, meeting length, buffers, and cadence.\n- **Route by Tool Name** \u2014 routes Vapi tool calls:\n\t- `checkAvailability` \u2192 calendar lookup path\n\t- `bookAppointment` \u2192 create event path\n- **2. Get Calendar Events (EDIT ME)** \u2014 reads events for the requested day.\n- **Calculate Potential Slots / Filter for Available Slots** \u2014 builds conflict-free options with buffers.\n- Respond with Available Times \u2014 returns formatted slots to Vapi.\n- **3. Book Appointment in Calendar (EDIT ME)** \u2014 creates the calendar event with details.\n- **Booking Confirmation** \u2014 returns success back to Vapi.\n\n> Sticky notes in the canvas show exactly what to edit (required by n8n).\nNo API keys are hardcoded; Google uses OAuth credentials.\n\n## Requirements\n\n- n8n (Cloud or self-hosted)\n- Google account with Calendar (OAuth credential in n8n)\n- Vapi account + one Assistant\n\n## Setup (5 minutes)\n### A) Vapi \u2192 n8n connection\n\n1. Open the **Webhook** node and copy the **Production URL**.\n2. In **Vapi** \u2192 **Assistant** \u2192 **Messaging**, set **Server URL** = that Production URL.\n3. In **Server Messages**, enable **only** `toolCalls`.\n\n### B) Vapi tools (names must match exactly)\n\nCreate two **Custom Tools** in Vapi and attach them to the assistant:\n\n**Tool 1:** `checkAvailability`\n\n- **Arguments**\n\t- `initialSearchDateTime` (string, ISO-8601 with timezone offset, e.g. `2025-09-09T09:00:00-05:00`)\n\n**Tool 2:** ```bookAppointment```\n\n- **Arguments**\n\t- `startDateTime` (string, ISO-8601 with tz)\n\t- `endDateTime` (string, ISO-8601 with tz)\n\t- `clientName` (string)\n\t- `propertyAddress` (string)\n\t- `serviceType` (string)\n\n> The Switch node routes based on ```message.toolCalls[0].function.name```. If the names differ, nothing will run.\n\n### C) Configure availability\n\nOpen **1. CONFIGURATION (EDIT ME)** and set:\n\n- ```timeZone``` (e.g. ```America/New_York```)\n- ```workdayStartHour``` / ```workdayEndHour``` (24h integers)\n- ```meetingDurationMinutes``` (e.g. ```30``` or ```60```)\n- ```bufferBeforeMinutes``` / ```bufferAfterMinutes``` (e.g. ```15```)\n- ```bookingCadenceMinutes``` (e.g. ```30```)\n\n### D) Connect Google Calendar\n\n1. Open **2. Get Calendar Events (EDIT ME)** \u2192 Credentials: select/create Google Calendar OAuth.\nThen choose the calendar to check availability.\n2. Open **3. Book Appointment in Calendar (EDIT ME)** \u2192 use the same credential and same calendar to book.\n\n### E) Activate & test\n\n- Toggle the workflow **Active**.\n- Call your Vapi number (or start a session) and book a test slot.\n- Verify the event appears with description fields (client, address, service type, call id).\n\n### Customizing\n\n- Change summary/description format in **3. Book Appointment**.\n- Add SMS/Email confirmations, CRM sync, rescheduling, or analytics as follow-ups (see sticky note \u201cI\u2019m a note\u201d).\n\n### Troubleshooting\n\n- **No response back to Vapi** \u2192 confirm Vapi is set to send toolCalls only and the Server URL matches the Production URL.\n- **Switch doesn\u2019t route** \u2192 tool names must be exactly checkAvailability and bookAppointment.\n- **No times returned** \u2192 ensure timezone + work hours + cadence generate at least one future slot; confirm Google credential and calendar selection.\n- **Event not created** \u2192 use the same Google credential & calendar in both nodes; check OAuth scopes/consent.\n\n### Security & privacy\n\n- Google uses OAuth; credentials live in n8n.\n- No API keys hardcoded.\n- Webhook receives only the fields needed to check times or book."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "callerPolicy": "workflowsFromSameOwner",
    "executionOrder": "v1"
  },
  "versionId": "a8b22bbd-b9b6-4d43-b1c7-10d0c5b9aa79",
  "connections": {
    "Route by Tool Name": {
      "main": [
        [
          {
            "node": "2. Get Calendar Events (EDIT ME)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "3. Book Appointment in Calendar (EDIT ME)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1. CONFIGURATION (EDIT ME)": {
      "main": [
        [
          {
            "node": "Route by Tool Name",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2. Get Calendar Events (EDIT ME)": {
      "main": [
        [
          {
            "node": "Calculate Potential Slots (do not change)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3. Book Appointment in Calendar (EDIT ME)": {
      "main": [
        [
          {
            "node": "Booking Confirmation (do not change)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Potential Slots (do not change)": {
      "main": [
        [
          {
            "node": "Filter for Available Slots (do not change)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook: Production URL = VAPI Server URL": {
      "main": [
        [
          {
            "node": "1. CONFIGURATION (EDIT ME)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter for Available Slots (do not change)": {
      "main": [
        [
          {
            "node": "Respond with Available Times (do not change)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}