VoiceAI Integration

VoiceSync MCP Servers

MCP servers VoiceAI calls during a call. Each GHL sub-account is configured in the admin console, which also issues the sub-account's token and shows its server URLs.

Connecting

Service calendar server

POST /api/mcp/servicecalendar/<subAccountId>
Authorization: Bearer <sub-account token>
Content-Type: application/json

Books in-home service appointments. Flow: get_booking_config with the caller's address (returns the one location that serves it) → get_availability over a date range → offer the closest slot → initiate_booking → confirm_booking.

get_booking_config

Call first, with the caller's service address. The server verifies the address, picks the closest service location within its maximum drive time, and returns only the service(s) bookable there, each with the variation's price and duration (price depends on the location). Without an address it lists base services and locations, no prices.

Input

{
  "address": "12 Pine Rd, Garden Valley, ID 83622"   // recommended
}

Output (example)

// Bookable
{
  "ok": true,
  "businessName": "Fire Safe Chimney Cleaning",
  "timezone": "America/Boise",
  "today": "2026-11-09",
  "consentText": "I consent to receive SMS notifications…",
  "notes": "…optional notes from the admin console…",
  "services": [
    { "serviceId": "6af15caa6ade93d794d4c024", "name": "Chimney Cleaning",
      "variationName": "Long Travel", "price": 300, "duration": 90, "durationUnit": "mins",
      "serviceLocationIds": ["6aec9497ada1a6b0dc1a6d24"] }
  ],
  "serviceLocations": [
    { "serviceLocationId": "6aec9497ada1a6b0dc1a6d24", "name": "Garden Valley, Crouch, Banks",
      "driveMinutes": 18, "driveMiles": 9.4, "maxDriveMinutes": 45 }
  ],
  "addressCheck": {
    "input": "12 pine rd garden valley", "valid": true, "status": "VALID_WITH_CORRECTIONS",
    "provider": "google", "formattedAddress": "12 Pine Rd, Garden Valley, ID 83622, USA",
    "location": { "latitude": 44.0896, "longitude": -115.9269 },
    "inServiceArea": true, "bookable": true
  }
}

// In a service area with no variation set: services is empty
{ "ok": true, …, "services": [], "serviceLocations": [ { …, "name": "Treasure Valley" } ],
  "addressCheck": { …, "inServiceArea": true, "bookable": false,
                    "message": "Treasure Valley can't be booked online right now. Offer a callback." } }

// Outside every service area
{ "ok": true, …, "services": [], "serviceLocations": [],
  "addressCheck": { …, "inServiceArea": false, "bookable": false, "message": "…",
                    "nearest": { "name": "Lowman", "driveMinutes": 72, "maxDriveMinutes": 60, … } } }

// Address doesn't verify: an error (isError: true), nothing else
{ "ok": false, "errorCode": "ADDRESS_INVALID", "stage": "config", "retryable": false,
  "message": "The address could not be verified. …",
  "details": { "input": "…", "status": "UNVERIFIED", "provider": "google",
               "suggestedAddress": "…", "missing": ["street_number"] } }

Fields

services[].price / duration
From the variation set for the matched location in the admin console. Pay in person: nothing is collected up front.
addressCheck.bookable
false (with empty services) when the address is out of area or the location has no variation set: take a callback request.
Address verification
Only VALID and VALID_WITH_CORRECTIONS addresses (that can be located) pass. Anything else fails with ADDRESS_INVALID.
Matching
Only bookable locations with a point and a max drive time are considered; the closest within its own limit wins. Drive times ignore live traffic.
get_availability

Open slots for the serviceId and serviceLocationId returned by get_booking_config, across a date range (max 31 days; open days vary by location). Grouped by day.

Input

{
  "serviceId": "6af15caa6ade93d794d4c024",
  "serviceLocationId": "6aec9497ada1a6b0dc1a6d24",
  "startDate": "2026-11-09",   // optional, YYYY-MM-DD; default today
  "endDate": "2026-11-22"      // optional, inclusive; default startDate + 13 days
}

Output (example)

{
  "ok": true,
  "timezone": "America/Boise",
  "serviceId": "…", "serviceLocationId": "…", "staffId": "CZZmqLQmNuvYAFmnBCDm",
  "startDate": "2026-11-09", "endDate": "2026-11-22",
  "totalSlots": 52,
  "days": [
    { "date": "2026-11-11",
      "slots": [ { "startTime": "2026-11-11T09:00:00.000-07:00", "endTime": "2026-11-11T10:30:00.000-07:00" } ] }
  ]
}
initiate_booking

Creates (or updates) the contact and books the appointment through the official GHL API. The server re-checks the address and books the variation and price set for that location, whatever serviceId is passed; the end time follows the variation's configured duration. Bookings are created confirmed with no payment status.

Input

{
  "startTime": "2026-11-11T09:00:00.000-07:00",   // exact startTime from get_availability
  "firstName": "Jane", "lastName": "Doe",
  "phone": "(208) 555-0100",                        // 10-digit US/CA or +country format
  "email": "jane@example.com",
  "meetingLocation": "12 Pine Rd, Garden Valley, ID 83622",
  "smsConsent": true,                               // only if the caller agreed to texts
  "notes": "Gate code 1234",                        // optional; saved as a contact note
  "serviceId": "…"                                  // optional; only if several services
}

Output (example)

{
  "ok": true,
  "serviceBookingId": "wEuZYcuED3Yxa1A3UU8f",
  "contactId": "6stQEjsye0BuqxaSGvvd",
  "status": "confirmed",
  "startTime": "2026-11-11T09:00:00.000-07:00",
  "endTime": "2026-11-11T10:30:00-07:00",
  "serviceLocation": "Garden Valley, Crouch, Banks",
  "service": "Chimney Cleaning", "variationName": "Long Travel", "price": 300,
  "meetingLocation": "12 Pine Rd, Garden Valley, ID 83622, USA"
}

Fields

Contact
Matched by email, then phone (the sub-account's duplicate settings); name, phone and address are updated. Tags booked-voiceai and area: <location>; notes and booking details go in a contact note.
smsConsent
true turns SMS DND off with the consent wording and code voicesync_voiceai, and logs it. Otherwise DND is left unchanged.
Errors
ADDRESS_INVALID, OUT_OF_SERVICE_AREA, LOCATION_NOT_BOOKABLE (nothing is created), SLOT_UNAVAILABLE (offer another time).
Title
"<name> — <service location>", so the area is visible in the GHL calendar.
confirm_booking

Reads the booking back from GHL to verify it exists. Tell the caller it's booked only after this succeeds. Safe to repeat.

Input

{ "serviceBookingId": "wEuZYcuED3Yxa1A3UU8f" }

Output (example)

{ "ok": true, "id": "wEuZYcuED3Yxa1A3UU8f", "status": "confirmed", "startTime": "2026-11-11T09:00:00-07:00" }

Booking page

Each sub-account has a public, embeddable booking page at /book/<subAccountId> that follows the same rules as the service calendar tools. Visitors enter the service address (with Google autocomplete); the server picks the area, variation and price; then they choose a time, enter their details and opt in to texts. Addresses that can't be booked online get a callback form in the same embed. The admin console shows the link and an auto-resizing iframe embed code.

Address server

POST /api/mcp/address/<subAccountId>
Authorization: Bearer <sub-account token>
Content-Type: application/json

Address validation and distance. Uses Google (Address Validation API, Routes API) with the sub-account's own Google Maps API key, set in the admin console, or else the global GOOGLE_MAPS_API_KEY secret. With neither, validation and geocoding use the free US Census geocoder: US only, no driving distance, and many rural addresses come back UNVERIFIED. The provider field says which one answered.

validate_address

Checks and standardises an address and returns its coordinates. Uses Google Address Validation when configured, otherwise the free US Census geocoder (US only).

Input

{
  "address": "123 main st garden valley id",   // one line, as the caller said it
  "regionCode": "US"                           // optional ISO country code, default US
}

Output (example)

{
  "ok": true,
  "provider": "google",                  // "google" | "census"
  "status": "VALID_WITH_CORRECTIONS",
  "deliverable": true,                   // USPS-confirmed (Google, US); null if unknown
  "formattedAddress": "123 Main St, Garden Valley, ID 83622-1234, USA",
  "components": {
    "streetNumber": "123",
    "street": "Main Street",
    "unit": "Apt 2",                     // parts not present are omitted
    "city": "Garden Valley",
    "county": "Boise",
    "state": "ID",
    "postalCode": "83622",
    "country": "USA"
  },
  "location": { "latitude": 44.0896, "longitude": -115.9269 },   // null if not located
  "locationPrecision": "PREMISE",
  "corrections": ["postal_code: 83622 (inferred)"],
  "missing": [],
  "unconfirmed": [],
  "note": "…present for UNVERIFIED and Census results…"
}

Fields

status: VALID
Confirmed to the building.
status: VALID_WITH_CORRECTIONS
Valid after the provider fixed or filled in parts (see corrections). Read the corrected address back to the caller.
status: NEEDS_UNIT
Building found, but an apartment / unit / suite number is needed.
status: INCOMPLETE
Required parts are missing (see missing, e.g. street_number). Ask the caller for them.
status: UNVERIFIED
Couldn't be confirmed. Not necessarily invalid: common for rural or new addresses. Confirm with the caller rather than rejecting.
locationPrecision
How exact location is: PREMISE / SUB_PREMISE / PREMISE_PROXIMITY (the building), ROUTE (the street only), INTERPOLATED_ADDRESS_RANGE (Census estimate along the street), or coarser.
missing / unconfirmed
Google component types, e.g. street_number, subpremise, route.
get_distance

Distance between two places, each an address or a latitude/longitude. Straight-line distance always; driving distance and time when a Google key is available.

Input

{
  "origin":      { "address": "400 Main St, Idaho City, ID" },
  "destination": { "latitude": 44.0896, "longitude": -115.9269 }
}
// each place is EITHER { "address" } OR { "latitude", "longitude" }

Output (example)

{
  "ok": true,
  "provider": "google",                  // "google" | "census"
  "origin":      { "location": { "latitude": 43.8274, "longitude": -115.8330 },
                   "resolvedAddress": "400 MAIN ST, IDAHO CITY, ID, 83631" },   // Census only
  "destination": { "location": { "latitude": 44.0896, "longitude": -115.9269 } },
  "straightLine": { "meters": 30107, "miles": 18.7, "kilometers": 30.1 },
  "driving": {                           // null without a Google key
    "meters": 51200, "miles": 31.8, "kilometers": 51.2,
    "durationSeconds": 3120, "durationText": "52 min"
  },
  "note": "…present when driving is null…"
}

Fields

origin/destination.location
Coordinates used. Given coordinates are echoed back. With Google, an address's point is the nearest road point on the route.
straightLine
Great-circle distance. Can be null with Google if a point couldn't be placed.
driving
Fastest route without live traffic. null when Google isn't configured.

Errors

Failed tool calls return isError: true with this JSON. It describes what happened; the agent decides what to do.

{
  "ok": false,
  "errorCode": "SLOT_UNAVAILABLE",
  "stage": "initiate",            // which step failed
  "retryable": false,             // hint only
  "message": "The selected time slot can no longer be booked: …",
  "upstreamStatus": 400,          // when an external service answered
  "upstreamDetail": "The selected slot is no longer available (…)",
  "details": { … }                 // code-specific context, e.g. ADDRESS_INVALID
}
errorCoderetryableMeaning
INVALID_INPUTnoArguments were invalid (bad date range, phone, email, missing field…).
NOT_CONFIGUREDnoSub-account, service or location isn't configured or is disabled; no location has a point and max drive time; or a provider key is missing or rejected.
SLOT_UNAVAILABLEnoCalendar: the slot was taken or is outside the booking window. Other slots may work.
ADDRESS_INVALIDnoCalendar: the address didn't verify (UNVERIFIED, INCOMPLETE, NEEDS_UNIT, or not locatable). details has status, missing and suggestedAddress. Don't book; fix it with the caller.
OUT_OF_SERVICE_AREAnoCalendar (initiate_booking): the verified address is outside every service area. Take a callback request.
LOCATION_NOT_BOOKABLEnoCalendar (initiate_booking): the area has no variation set or no staff. Take a callback request.
ADDRESS_NOT_FOUNDnoAddress: the address couldn't be located.
NO_ROUTEnoAddress: no driving route between the two points.
UPSTREAM_REJECTEDnoAn external service refused the request; the same request will fail again.
UPSTREAM_UNAVAILABLEyesTimeout, network error, HTTP 5xx or 429. May work later.
UPSTREAM_CONTRACT_CHANGEDnoAn external service answered in an unexpected format; it has probably changed.