Connect a Voice Agent to Google Calendar: 2026 Guide
Step-by-step TypeScript guide to connect a voice agent to Google Calendar - check real-time availability and book confirmed appointments mid-call.
Most voice agent tutorials show you how to say "I've booked your appointment" — but stop before showing the plumbing that actually writes to a calendar. This guide fills that gap. You'll build a working integration between a Vapi voice agent and Google Calendar: the agent checks real availability and creates confirmed events during a live phone call, with no manual steps required.
The architecture is production-grade, not demo-grade. A running shoe shop in Bend, OR used this same pattern to handle after-hours fitting appointment bookings. Callers received instant confirmation on the call instead of a "we'll follow up tomorrow" promise — no-shows dropped because a confirmed slot feels different to a caller than an open-ended callback that may never come.
How the integration works
Vapi's function-calling mechanism lets the LLM pause a conversation mid-call, fire a webhook to an external API, receive the result as a plain text string, and then continue speaking. The LLM decides when to invoke a tool based on the caller's stated intent. Your job is to define the tools, write the webhook handler, and craft a system prompt that tells the LLM exactly when and how to use each one.
Two tools handle the full booking flow: check_availability queries Google's freebusy endpoint to find open 30-minute slots on a requested day, and book_appointment calls events.insert to create the confirmed event. The LLM never touches your credentials — all Google API calls happen inside your webhook. The entire round-trip (LLM triggers tool → webhook fires → Calendar API responds → LLM reads the result) must complete within Vapi's 10-second first-response budget, so your webhook needs to return in 2–3 seconds. Cloudflare Workers' global edge network makes this achievable consistently.
Caller (phone)
│
▼
Vapi SIP/PSTN ──► LLM (GPT-4o / Claude) ◄── System Prompt + Tool Definitions
│
function_call triggered mid-conversation
│
▼
Webhook Handler (Cloudflare Workers)
│
┌────────────┴────────────┐
▼ ▼
check_availability book_appointment
│ │
└────────────┬────────────┘
▼
Google Calendar API
(freebusy.query / events.insert)
│
plain-text result string
│
▼
LLM reads result → spoken confirmation
Prerequisites
- A Vapi account with an active assistant and a phone number assigned to it
- A Google Cloud project with the Google Calendar API enabled and an OAuth 2.0 Client ID created
- A Cloudflare Workers project (or any HTTPS host that can respond in under 3 seconds — AWS Lambda, Fly.io, and Railway all work)
- Node.js 20+ installed locally for the one-time OAuth token exchange
- npm packages:
googleapis,google-auth-library,hono,@cloudflare/workers-types
If you have not yet worked with Vapi function calling, read our Vapi function calling guide first — this post builds directly on the mechanics covered there, including how Vapi routes tool calls to your webhook and what the expected request and response shapes look like.
Step 1: Google Cloud setup and OAuth credentials
Google Calendar access requires OAuth 2.0 authorization — there is no unauthenticated path, even for a calendar you own. In production you'll use three environment variables: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REFRESH_TOKEN. Refresh tokens do not expire unless you explicitly revoke them, so this is a one-time local process.
In the Google Cloud Console: enable the Google Calendar API under APIs & Services, create an OAuth 2.0 Client ID with application type Desktop app, and add http://localhost:3000/callback as an authorized redirect URI. Download the client secrets JSON, then run this script once on your local machine:
// oauth-setup.ts — run once: npx ts-node oauth-setup.ts
import { google } from 'googleapis';
import * as readline from 'readline';
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
'http://localhost:3000/callback'
);
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['https://www.googleapis.com/auth/calendar'],
prompt: 'consent', // forces a refresh token even if user previously authorized
});
console.log('Open this URL in your browser:\n', authUrl);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question('\nPaste the code from the redirect URL: ', async (code) => {
rl.close();
const { tokens } = await oauth2Client.getToken(code);
console.log('\nRefresh token — save this as GOOGLE_REFRESH_TOKEN:');
console.log(tokens.refresh_token);
});The script prints an authorization URL. Open it in a browser, grant Calendar access, and you'll land on a localhost redirect with a code query parameter. Paste that code at the terminal prompt. The script exchanges it for a refresh token and prints it. Copy it immediately — you cannot retrieve it again without repeating the full authorization flow.
Add GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN, and GOOGLE_CALENDAR_ID (set to the calendar's email address or primary) to your Worker's .dev.vars for local development and to Cloudflare's environment variable settings for production.
Step 2: Build the Calendar service layer
Isolate all Google Calendar interaction in a single class. This makes it straightforward to add caching later, swap in a service account, or extend to multiple calendars without touching your webhook logic. The checkAvailability method uses freebusy.query rather than events.list — it is faster, returns only the information you need (busy periods, not event titles), and avoids leaking confidential event details to the LLM.
// src/calendar.service.ts
import { google, calendar_v3 } from 'googleapis';
interface TimeSlot {
start: string;
end: string;
label: string;
}
interface BookingParams {
calendarId: string;
summary: string;
description: string;
startTime: string;
endTime: string;
attendeeEmail?: string;
}
export class CalendarService {
private calendar: calendar_v3.Calendar;
constructor() {
const auth = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
);
auth.setCredentials({ refresh_token: process.env.GOOGLE_REFRESH_TOKEN });
this.calendar = google.calendar({ version: 'v3', auth });
}
async checkAvailability(
calendarId: string,
date: string,
timezone = 'America/Los_Angeles'
): Promise<TimeSlot[]> {
const dayStart = new Date(`${date}T08:00:00`);
const dayEnd = new Date(`${date}T18:00:00`);
const { data } = await this.calendar.freebusy.query({
requestBody: {
timeMin: dayStart.toISOString(),
timeMax: dayEnd.toISOString(),
timeZone: timezone,
items: [{ id: calendarId }],
},
});
const busy = data.calendars?.[calendarId]?.busy ?? [];
const slots: TimeSlot[] = [];
const cursor = new Date(dayStart);
while (cursor < dayEnd) {
const slotEnd = new Date(cursor.getTime() + 30 * 60 * 1000);
const blocked = busy.some(({ start, end }) =>
cursor < new Date(end!) && slotEnd > new Date(start!)
);
if (!blocked) {
slots.push({
start: cursor.toISOString(),
end: slotEnd.toISOString(),
label: cursor.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
timeZone: timezone,
}),
});
}
cursor.setTime(slotEnd.getTime());
}
return slots.slice(0, 5); // cap at 5 slots — listing more is painful to hear on a call
}
async bookAppointment(params: BookingParams): Promise<string> {
const { data } = await this.calendar.events.insert({
calendarId: params.calendarId,
requestBody: {
summary: params.summary,
description: params.description,
start: { dateTime: params.startTime, timeZone: 'America/Los_Angeles' },
end: { dateTime: params.endTime, timeZone: 'America/Los_Angeles' },
attendees: params.attendeeEmail ? [{ email: params.attendeeEmail }] : [],
reminders: {
useDefault: false,
overrides: [
{ method: 'email', minutes: 1440 }, // 24 hours before
{ method: 'popup', minutes: 30 },
],
},
},
});
return data.id!;
}
}Three details worth calling out. First, calling setCredentials({ refresh_token }) is sufficient — the googleapis library refreshes the short-lived access token automatically when it expires, roughly every hour. Second, freebusy.query returns only the busy periods, so you generate free slots by iterating through the work day and filtering out any 30-minute window that overlaps a busy block. Third, returning at most five slots keeps the LLM's spoken response short and conversational — a caller should not have to listen to twelve options out loud.
Step 3: Define the Vapi tool schemas
Vapi tool definitions are JSON Schema objects attached to your assistant's model configuration. The LLM uses the description field to decide when to call each tool and the parameters schema to know which values to extract from the conversation. Write descriptions as instructions to the LLM, not documentation for humans.
// src/vapi-tools.ts
export const calendarToolDefinitions = [
{
type: 'function',
function: {
name: 'check_availability',
description:
'Check open appointment slots for a given date. Call this when the caller asks about scheduling, available times, or wants to book something.',
parameters: {
type: 'object',
properties: {
date: {
type: 'string',
description:
'Date to check in YYYY-MM-DD format. Convert spoken phrases like "tomorrow" or "next Tuesday" to this format before calling.',
},
timezone: {
type: 'string',
description:
'IANA timezone string, e.g. America/Los_Angeles. Default to America/Los_Angeles if the caller has not specified.',
default: 'America/Los_Angeles',
},
},
required: ['date'],
},
},
},
{
type: 'function',
function: {
name: 'book_appointment',
description:
'Create a confirmed calendar event. Only call this AFTER the caller has verbally confirmed both the date and the specific time slot.',
parameters: {
type: 'object',
properties: {
caller_name: {
type: 'string',
description: "Full name of the person booking. Ask for it if you do not have it.",
},
caller_email: {
type: 'string',
description: 'Email address for the calendar invite (optional).',
},
start_time: {
type: 'string',
description:
'Start time in ISO 8601 format with timezone offset, e.g. 2026-07-08T14:00:00-07:00.',
},
end_time: {
type: 'string',
description:
'End time in ISO 8601 format with timezone offset. Default to 30 minutes after start_time if not specified.',
},
reason: {
type: 'string',
description: 'Brief purpose or service type for the appointment.',
},
},
required: ['caller_name', 'start_time', 'end_time'],
},
},
},
];The date parameter description explicitly instructs the LLM to convert natural language ("next Tuesday", "this Friday") into YYYY-MM-DD format. Without that instruction, many models pass the spoken phrase as-is and your date constructor silently produces an invalid date. Test this specifically — it's a common failure mode.
The book_appointment description says "Only call this AFTER the caller has verbally confirmed both the date and the specific time slot." This line is load-bearing. Without it, some LLMs will attempt to book the first available slot without asking the caller to confirm, which creates calendar events with wrong names, wrong times, and confused callers.
Step 4: Build the webhook handler
When a function call fires, Vapi POSTs a tool-calls message to your webhook URL. The payload contains a toolCallList array — in principle, multiple tools can fire in the same turn. Your response must be a JSON object with a results array where each entry contains the original toolCallId and a plain-text result string. The LLM reads that string verbatim. Always return a string the LLM can speak aloud if a tool call fails — never throw an unhandled error or return a raw error object or the call will stall silently.
// src/webhook.ts — Cloudflare Workers + Hono
import { Hono } from 'hono';
import { CalendarService } from './calendar.service';
interface ToolCall {
id: string;
function: { name: string; arguments: string };
}
interface VapiPayload {
message: { type: string; toolCallList: ToolCall[] };
}
const app = new Hono<{ Bindings: CloudflareBindings }>();
const calendarService = new CalendarService();
const CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID ?? 'primary';
app.post('/vapi/tool-call', async (c) => {
const { message } = await c.req.json<VapiPayload>();
if (message.type !== 'tool-calls') {
return c.json({ results: [] });
}
const results = await Promise.all(
message.toolCallList.map(async (call) => {
const args = JSON.parse(call.function.arguments);
try {
if (call.function.name === 'check_availability') {
const slots = await calendarService.checkAvailability(
CALENDAR_ID,
args.date,
args.timezone ?? 'America/Los_Angeles'
);
if (!slots.length) {
return {
toolCallId: call.id,
result: `No openings found on ${args.date}. Ask the caller if they'd like to check a different day.`,
};
}
const times = slots.map((s) => s.label).join(', ');
return {
toolCallId: call.id,
result: `Available times on ${args.date}: ${times}. Ask which one works best for the caller.`,
};
}
if (call.function.name === 'book_appointment') {
const eventId = await calendarService.bookAppointment({
calendarId: CALENDAR_ID,
summary: `Appointment: ${args.caller_name}`,
description: args.reason ?? 'Booked via voice agent',
startTime: args.start_time,
endTime: args.end_time,
attendeeEmail: args.caller_email,
});
return {
toolCallId: call.id,
result: `Confirmed. ${args.caller_name} is booked at ${args.start_time}. Event ID: ${eventId}. Tell the caller it is set and ask if there is anything else you can help with.`,
};
}
return {
toolCallId: call.id,
result: `Unknown tool: ${call.function.name}`,
};
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
return {
toolCallId: call.id,
result: `Calendar error: ${msg}. Apologize to the caller and suggest they book online or call back during business hours.`,
};
}
})
);
return c.json({ results });
});
export default app;This handler runs on Cloudflare Workers, which cold-starts in under 5ms globally. The Google Calendar API call itself typically takes 200–600ms from a Workers edge node in North America, keeping total webhook latency well under 2 seconds on the happy path — comfortably inside Vapi's 10-second tool call budget.
Step 5: Configure your Vapi assistant
In the Vapi dashboard, open your assistant's Model settings. Attach the tool definitions from Step 3 under the Tools section. Set the server URL to your deployed webhook endpoint — for a Workers deployment this is typically https://your-worker.your-account.workers.dev/vapi/tool-call.
Your system prompt needs an explicit calendar section. Without it the LLM won't know how to sequence the conversation. Add something like this:
You can check appointment availability and book appointments on behalf of the business. When the caller asks about scheduling, open times, or wants to book anything: first call
check_availabilitywith the date they mention. Present the options naturally — "We have openings at 10 AM, 1:30 PM, and 3 PM. Which works best for you?" After the caller selects a time and verbally confirms, ask for their name and optionally their email, then callbook_appointment. Always confirm the full date, time, and name before booking. If the calendar tool returns an error, apologize briefly and suggest the caller book online or call back — do not attempt to invent availability.
Be specific about what the agent should do on error. A vague system prompt produces confident-sounding agents that improvise when the API is slow or unreachable, which leads to bookings the caller believes are confirmed but are not recorded anywhere.
Production gotchas
Timezone extraction fails silently
If a caller in Bend, OR says "book me for 2 PM Tuesday" and your system defaults to UTC, their appointment lands at 7 AM Oregon time. The LLM will not warn you — it will confidently pass 2026-07-07T14:00:00Z to your webhook, and the Calendar event will be three hours early. Fix this by hardcoding a timezone default in your webhook based on your business location rather than relying on the LLM to extract it from conversation. Also add a validation step: check that start_time is a valid RFC 3339 string with a timezone offset before passing it to the Calendar API. A bare YYYY-MM-DDTHH:MM:SS without an offset is ambiguous and will behave differently depending on the Google API region handling your request.
Double-booking race condition
Two callers can hit your webhook at the same moment. Caller A checks availability, sees 2 PM open. Caller B also checks and sees 2 PM open. Both confirm. Both events.insert calls succeed — Google Calendar permits overlapping events by default and does not reject the second insert. For most small businesses handling under 30 calls per day, this race is extremely rare in practice and may not be worth the engineering cost. For higher volume, add a short-lived optimistic lock in Cloudflare KV keyed on {calendarId}:{startTime} with a 30-second TTL. Acquire the lock between your availability check and your insert call, and release it after the insert completes.
Webhook timeout causes a silent failure
Vapi waits up to 10 seconds for a tool call response. If your webhook exceeds that — because the Calendar API is slow, a cold start compounded with DNS resolution, or a token refresh fires mid-request — Vapi lets the call continue but the tool result arrives empty. The LLM then improvises an answer, often saying "I've confirmed your booking" when nothing was written. Add response-time logging from day one. Wrap your googleapis call in an AbortController with a 5-second timeout and always return a graceful error string before Vapi's timeout fires. An error message the agent can read aloud is far better than silence followed by hallucinated confirmation.
OAuth token refresh adds latency once per hour
The googleapis client refreshes the access token lazily — on the first API call after the token expires, which happens approximately every hour. That refresh adds 200–500ms to one request per hour. In a standard Node.js server this is invisible because the token lives in memory between requests. In Cloudflare Workers, each request starts fresh. To keep p99 latency predictable, store the current access token and its expiry in Cloudflare KV and proactively refresh it when less than five minutes remain. Read the token from KV at the top of your handler, inject it via auth.setCredentials({ access_token, expiry_date }), and only trigger a refresh when the token is stale.
OAuth redirect URI must match the Cloud Console exactly
The redirect URI registered in Google Cloud Console must exactly match the one used in your code — including trailing slashes, protocol, and port. If http://localhost:3000/callback is registered but your script uses http://localhost:3000/callback/, the authorization returns a redirect_uri_mismatch error with a message that does not clearly identify the mismatch. Register exactly http://localhost:3000/callback with no variation, and confirm your script uses the same string. This trips up most developers at least once during initial setup.
When NOT to build this yourself
This integration makes sense when you need precise control — custom booking rules, logic tied to a specific CRM, multi-calendar routing, or a conversation flow that no-code platforms cannot replicate. But it is not the right choice in every situation.
You need to book across multiple staff members. The architecture above works cleanly for a single calendar. If you need to find the first available slot across five team members' schedules and avoid double-booking any individual, that is a meaningfully more complex routing problem. Tools like Cal.com or Calendly solve round-robin and pooled availability out of the box and expose APIs your webhook can call instead of building the scheduling logic yourself.
You need SMS or email confirmation workflows. Creating a calendar event is one step. Sending a branded confirmation SMS, handling rescheduling replies over text, and delivering reminder sequences at 24 hours and 1 hour before the appointment requires additional infrastructure on top of what this guide covers. If that is the real requirement, a purpose-built booking platform integrated via its API may be simpler than building each piece from scratch.
Your call volume is under 20 calls per day. At low volume the benefit of full automation is real, but the ongoing maintenance burden — OAuth token rotation, webhook monitoring, error alerting, Google API quota tracking — may not be worth it compared to a simpler setup. Plenty of small businesses in Central Oregon handle scheduling effectively with a simple voicemail-to-email flow and a human who books appointments from a Google Calendar web tab twice a day.
You are not comfortable with cloud infrastructure. The setup here — Google Cloud Console, OAuth credential management, Cloudflare Workers deployment, Wrangler CLI configuration, and system prompt tuning — requires someone who is at ease with code and cloud environments. If your business just needs a voice agent that handles appointment bookings without any of this setup, book a demo to see how WildRun AI provides calendar integration as a configured feature rather than a from-scratch build.
Architecture
Caller (phone)
|
v
Vapi SIP/PSTN --> LLM (GPT-4o / Claude) <-- System Prompt + Tool Definitions
|
function_call triggered mid-conversation
|
v
Webhook Handler (Cloudflare Workers)
|
+------------+-----------+
v v
check_availability book_appointment
| |
+------------+-----------+
v
Google Calendar API
(freebusy.query / events.insert)
|
plain-text result string
|
v
LLM reads result --> spoken confirmation
Frequently asked questions
Do I need a Google service account or OAuth2 credentials?
OAuth2 with a refresh token is the simpler choice for a single calendar you control. Service accounts are better for accessing calendars across a Google Workspace organization, but they require domain-wide delegation and cannot access personal Google accounts. For most small businesses integrating their own Google Calendar, the refresh token approach in this guide is sufficient.
How do I prevent double-bookings when two callers reach the agent simultaneously?
For low-volume operations (under 30 booking calls per day), calendar race conditions are rare enough that many businesses accept the risk. For higher volume, add a short-lived lock in Cloudflare KV keyed on the calendar ID and time slot with a 30-second TTL. Acquire the lock after checking availability and before calling events.insert, then release it once the insert completes.
What happens if the Google Calendar API times out during a live call?
Set a 5-second AbortController timeout on all googleapis calls and always return a graceful error string the LLM can speak aloud before Vapi's 10-second tool call deadline fires. The LLM will read your error message and offer the caller an alternative. Never let an unhandled error propagate, or the call will stall silently and the LLM may improvise a false confirmation.
Can this integration work with Google Workspace calendars?
Yes. Set GOOGLE_CALENDAR_ID to the calendar's email address (for example, appointments@yourcompany.com) instead of primary. The OAuth user running the authorization flow must have Make changes to events permission on that calendar. Resource calendars such as shared team calendars work the same way.
How much does the Google Calendar API cost?
The Google Calendar API is free up to 1,000,000 quota units per day. A freebusy.query call costs 1 unit; events.insert costs 1 unit. A voice agent handling 200 booking calls per day uses roughly 400 quota units, far below the free limit.
Does this same architecture work with Outlook or Apple Calendar?
Not directly. Microsoft Outlook uses the Microsoft Graph API, which has a different OAuth flow and different endpoint shapes. Apple Calendar has no public booking API. For multi-calendar support across Google, Outlook, and other providers, a middleware like Cal.com or Cronofy provides a unified API your webhook can call instead of building each integration separately.