Classic painting used as the article cover
← Back to blog

Integrations

Letting an AI Agent Book Meetings on Your Users' Calendars

Most scheduling agents ask for full calendar access when freebusy plus events.owned would do. The scope ladder, conferenceData for Meet links, recurrence, client-generated event ids, and sync tokens.

•Sep 29, 2026•14 min
OAuthConnectorsEngineering

TL;DR

A scheduling agent almost never needs https://www.googleapis.com/auth/calendar. Reading availability is calendar.freebusy. Writing the meeting is calendar.events.owned. Google lists about twenty Calendar scopes and most agents are wired to the widest one.

freebusy.query answers with start and end pairs and nothing else. No titles, no attendees, no locations. For "when is this person free", that is the whole answer, and it is a different OAuth grant from "read my calendar".

The common instinct is that you need read access to events in order to know when someone is busy. You don't, and the price of that instinct shows up on the consent screen, where your user reads "See, edit, share, and permanently delete all the calendars you can access" before deciding whether to trust your product.

We're not claiming narrow scopes make the agent safe. An agent on events.owned that retries a timeout can still put four copies of one interview on a candidate's calendar. Scope is one problem, idempotency is another.

Today: ask for freebusy and events.owned, give every booking a deterministic client-generated event id, and leave sendUpdates on none until a human has actually seen the times.

Overview

Take a recruiting-coordinator agent. It sits inside your product, each of your customers' recruiters has connected their own Google account, and the job is to find a 45-minute slot that works for the recruiter and the hiring manager, put it on the calendar with a Meet link, and keep it correct when someone moves it. Nothing exotic. It is the single most common thing anyone builds after "read my email".

The Calendar API is a good API, and it is also older than the agent pattern by about fifteen years. It assumes a client that knows what it intends to do before it starts. An agent doesn't. It decides mid-loop, it retries when a call times out, and it has no memory of whether the previous attempt landed. That mismatch is where the duplicate events and the accidental invitations come from, and almost none of it is a model-quality problem.

This is a Calendar-specific post. OAuth mechanics, token storage and refresh are assumed knowledge here; the scope-tier and CASA assessment side of getting a Google app verified lives in our post on Google OAuth verification, and the general shape of acting as one end user against a Google API is the same one we walked through for sending Gmail from an agent. What's different about Calendar is that the write is visible to other people. A badly-scoped mail agent leaks. A badly-scoped calendar agent emails forty strangers at 6am.

The scope you pick is a product decision, not a config value. Your user reads the scope's description on the Google consent screen and decides there whether your agent is reasonable. "View your availability in your calendars" and "See, edit, share, and permanently delete all the calendars you can access" are the same feature to you and completely different products to them.

The Scope Ladder

Google's Calendar auth page lists about twenty scopes. Five of them matter for a scheduling agent, and the descriptions below are the strings Google publishes, which are also roughly what the user sees at consent.

ScopeGoogle's descriptionWhat an agent gets
calendar.freebusy"View your availability in your calendars"Busy blocks. No event contents at all.
calendar.events.freebusy"See the availability on Google calendars you have access to"Same query, framed around shared calendars.
calendar.events.owned"See, create, change, and delete events on Google calendars you own"Full event read and write, owned calendars only.
calendar.events"View and edit events on all your calendars"Events on every calendar the user can reach, including ones shared to them.
calendar.readonly"See and download any calendar you can access using your Calendar"Everything, read-only.
calendar.app.created"Make secondary Google calendars, and see, create, change, and delete events"A private calendar your app makes and owns.

Table 1 — The rungs that matter, with the scope descriptions Google publishes on its Calendar auth page.

freebusy.query accepts any of calendar.freebusy, calendar.events.freebusy, calendar.readonly or the full calendar scope. events.insert accepts calendar, calendar.events, calendar.events.owned or calendar.app.created. Those two lists barely overlap, which is the useful part: a scheduling agent can hold one narrow read scope and one narrow write scope and never hold a scope that lets it read the contents of a meeting it did not create.

calendar.app.created is the narrowest write rung and is usually the wrong one anyway. It gives your app its own secondary calendar, which is excellent for something like a shift roster and useless for booking an interview, because the whole point is that the event lands on the recruiter's own calendar next to everything else. Reach for it only when your events genuinely belong in their own container.

no, only busy times

yes

only ones we create

calendars the user owns

shared calendars too

yes

no

Needs to read
event contents?

calendar.freebusy

Whose events?

calendar.app.created

calendar.events.owned

calendar.events

Writes events?

freebusy alone

yes: + calendar.events.owned no : freebusy alone yes -> whose events? only ours -> calendar.app.created user's own -> calendar.events.owned shared as well -> calendar.events calendar (full) and calendar.readonly sit above all of these and are rarely the right answer. -->

Figure 1 — The scope decision for a scheduling agent. Most land on freebusy plus events.owned.

Google's own sensitive-scope verification page uses https://www.googleapis.com/auth/calendar as its worked example of a scope that needs review before any account can grant it, which tells you how Google reads the top rung even when your feature is modest.

Reading Availability

POST https://www.googleapis.com/calendar/v3/freeBusy takes a window and a list of calendars and returns busy intervals:

json
{
  "timeMin": "2026-10-05T09:00:00-04:00",
  "timeMax": "2026-10-09T18:00:00-04:00",
  "timeZone": "America/New_York",
  "items": [{ "id": "primary" }, { "id": "hiring-manager@example.com" }]
}

The response gives each calendar a busy array of {start, end} objects, plus a per-calendar errors array when a calendar could not be read. Two limits are worth knowing before you build a team-scheduling feature: calendarExpansionMax tops out at 50 calendars and groupExpansionMax at 100 identifiers per group.

The alternative is events.list with timeMin and timeMax, and it is the wrong tool for availability. It needs a read scope over event contents, it hands your model titles and attendee lists it has no business seeing, and those extra fields go straight into a context window that may be summarized, logged or replayed. Ask for busy blocks and you get busy blocks.

Free/busy is also not a complete picture of "free", and the gaps are specific. An event marked transparency: "transparent" shows as Available and does not block. An invitation the user hasn't answered still has responseStatus: "needsAction". Event types like focusTime, outOfOffice and workingLocation carry intent that free/busy flattens away. So it can tell you 2pm Thursday is busy without telling you the block is focus time the user would gladly give up, and it will show nothing at all for a real commitment the user marked as Available. An agent that reads the busy array as the whole truth is right about the blocks and wrong about the person.

Which brings up double-booking, and we'll take a position: a free/busy read is not a reservation, and no amount of re-reading makes it one. The gap between your query and your insert is a race you cannot close from the client side. The correct design is not a tighter check before the write, it is an insert you can recognize and undo afterwards. Write the event, give it an id you chose, then re-read and reconcile. If the slot got taken in the interim, you have a row you can find and cancel, rather than a mystery event nobody can attribute.

Creating the Event

Only start and end are required. Everything interesting is optional, and two of the optional things have sharp edges.

json
{
  "summary": "Screen: Dana Okafor / Platform Eng",
  "start": { "dateTime": "2026-10-07T14:00:00", "timeZone": "America/New_York" },
  "end":   { "dateTime": "2026-10-07T14:45:00", "timeZone": "America/New_York" },
  "attendees": [{ "email": "hiring-manager@example.com" }],
  "conferenceData": {
    "createRequest": {
      "requestId": "booking-4f2a9c",
      "conferenceSolutionKey": { "type": "hangoutsMeet" }
    }
  }
}

A Meet link needs conferenceDataVersion=1 on the query string. Without it, version 0 "assumes no conference data support and ignores conference data in the event's body", so your request succeeds, returns 200, and produces an event with no link. The conference is also not necessarily ready when the response comes back: createRequest.status.statusCode can be pending, and only success means entry points are populated. An agent that reports the Meet URL straight from the insert response will sometimes report nothing. Re-read the event.

The second edge is attendees, and this is where we'd draw a hard line. Adding an attendee is not a notification setting, it is a write to someone else's calendar: Google's own guide says the event "appears on all the primary Google Calendars of the attendees". sendUpdates takes all, externalOnly or none, and none of those values stops the event from landing on the attendee's calendar. It only controls the email.

An agent should propose times, not issue invitations. Let it call freebusy, let it rank slots, let it draft the event, and let a human press the button that puts it in front of another human. Where the agent does write directly, restrict it to the connected user's own calendar with no attendees, and let the user invite people from the Calendar UI they already know. The one case we think justifies direct invitation is an explicit standing instruction from the user for a defined counterparty, and even then we'd send sendUpdates: "none" and tell the user what went out. This is the same identity question as user tokens versus bot tokens in Slack: the invitation goes out in the user's name whether or not the user was involved in composing it.

Calendar APIGatewayAgentEnd userCalendar APIGatewayAgentEnd userfreebusy.query (window, calendars)POST /freeBusybusy blocks onlycandidate slotsproposed timespicks oneevents.insert (client id, conferenceDataVersion=1)POST /events?conferenceDataVersion=1event, conference status pendingevents.get (re-read)Meet link, final stateconfirmation

Figure 2 — The booking path. The read scope and the write scope are different grants, and the confirmation comes from a re-read rather than from the insert response.

Recurrence and Time Zones

Recurrence is where agents quietly produce garbage, because the data model is not the one they infer from the UI. A recurring event is one resource carrying recurrence, a list of RRULE, EXRULE, RDATE and EXDATE lines in RFC 5545 form. Its occurrences are instances, each with a recurringEventId pointing back at the parent and an originalStartTime that identifies the occurrence even after it has been moved.

events.list returns the parent, not the instances, unless you pass singleEvents=true. And orderBy=startTime is "only available when querying single events", so any agent that wants a chronological view of the next two weeks needs singleEvents=true whether it thinks about recurrence or not. For one known series, events.instances is the direct route.

The time zone rule is worth memorizing because it is load-bearing and easy to get away with in testing. In start and end, dateTime requires "a time zone offset ... unless a time zone is explicitly specified in timeZone", and for recurring events "this field is required and specifies the time zone in which the recurrence is expanded". An agent that normalizes everything to UTC offsets will produce a weekly standup that silently shifts by an hour on the first Monday after a DST change. Carry the IANA name. America/New_York, not -04:00.

One more, quoted because the guide is unusually direct about it: "Do not modify instances individually when you want to modify the entire recurring event, or 'this and following' instances." Doing so generates exceptions, degrades performance and fires a notification per instance. An agent asked to "move my Tuesday sync" will reach for the instance it can see. It should be reaching for the parent, and the tool you hand it should make that distinction explicit rather than hoping the model infers it.

Not Booking It Twice

events.insert is not idempotent by default. Call it twice and you get two meetings. An agent that hits a timeout, gets no body, and decides to retry has no way to tell "the insert failed" from "the insert succeeded and the response was lost", which is the oldest problem in distributed systems arriving somewhere that assumed a human was clicking.

Calendar gives you the fix, and it is underused. You may supply the event id yourself. The rules: "characters allowed in the ID are those used in base32hex encoding, i.e. lowercase letters a-v and digits 0-9", length between 5 and 1024, unique per calendar. Google's create-events guide is explicit about why it exists: a client-generated id "prevents duplicate event creation if the operation fails."

The practical trick is that hex digits are a subset of base32hex's alphabet, so a hex digest is already a legal event id. Derive it from things that do not change on retry:

python
import hashlib

def event_id(booking_id: str, attempt_of: str) -> str:
    # booking_id is OUR primary key for this booking; attempt_of is the slot start.
    digest = hashlib.sha256(f"{booking_id}|{attempt_of}".encode()).hexdigest()
    return f"bk{digest[:40]}"   # 42 chars, all within a-v0-9

Retry with the same id and the second insert conflicts instead of duplicating. Google notes that because the backend is distributed, a 409 is not guaranteed on every collision, so treat the conflict as the fast path and a re-read as the correctness path. And don't confuse id with iCalUID: "only one of them should be supplied at event creation time", and every instance of a recurring event has a different id while sharing one iCalUID.

The Meet request has its own version of this. conferenceData.createRequest.requestId is documented as "client-generated unique ID for this request", and "if an ID provided is the same as for the previous request, the request is ignored". Regenerate it for a genuinely new booking. Keep it stable across retries of the same booking, or you will provision conferences you never use.

Idempotency belongs in the tool, not in the prompt. Any write tool an agent can reach should derive its key from the work item rather than accepting one the model invented, because a model asked to supply a unique id will cheerfully supply a different one on the retry. That is the failure the key exists to prevent.

Staying in Sync

Once you have written an event, the user owns it. They will move it, decline it, or delete it from their phone, and your agent's view goes stale in minutes. Polling events.list on a timer is the wrong answer and also the expensive one: the quota is 10,000 queries per minute per project and 600 per minute per user per project, with a billing threshold at 1,000,000 requests per day per project. A hundred users polled every minute is not a rounding error.

Incremental sync is the mechanism. A full events.list returns nextSyncToken; pass it back as syncToken and you get only what changed, including deletions, because "the result will always contain deleted entries, so that the clients get the chance to remove them from storage." Deleted events arrive with status of cancelled.

The trap is parameter compatibility. syncToken cannot be combined with iCalUID, orderBy, privateExtendedProperty, q, sharedExtendedProperty, timeMin, timeMax or updatedMin. You cannot window an incremental sync by date. Teams discover this after building the full sync with a time range and finding the incremental request returns 400. Pick your parameter set at the first request and keep it identical forever, because "each list request should use the same set of query parameters, including the initial request."

Tokens also expire. An incremental request against a dead token returns 410, and the documented response is blunt: it "should trigger a full wipe of the client's store and a new full sync." Build that path first. It will run.

Push notifications tell you when to sync rather than replacing the sync. events.watch takes an id, a type of "web_hook" and an HTTPS address with a valid certificate. params.ttl defaults to 604800 seconds, seven days, and "there's no automatic way to renew a notification channel" — you re-watch with a fresh id before the expiration timestamp the watch response hands you. Notifications carry X-Goog-Resource-State of sync, exists or not_exists, and crucially "do not include a message body". The webhook is a doorbell. The sync token is the delivery.

store nextSyncToken

events.watch, ttl 604800s

X-Goog-Resource-State exists

new nextSyncToken

expiration approaching

new channel id

410 GONE

FullSync

Steady

Watching

Incremental

Rewatch

Figure 3 — The sync loop. Webhooks say something changed; sync tokens say what.

For retries on 403 and 429, Google publishes the backoff it wants: min(((2^n)+random_number_milliseconds), maximum_backoff), with the random component up to 1,000ms and the ceiling typically 32 or 64 seconds. Put that in the tool, not in the agent loop. A model deciding when to retry will retry immediately.

Conclusion

The interesting decisions in a calendar agent are all made before the model runs. Which scope you requested determines what the user reads at consent and what the agent can do on its worst day. Whether your insert carries an id you chose determines whether a lost response becomes a duplicate meeting. Whether your tool distinguishes a recurring parent from one of its instances determines whether "move my Tuesday sync" moves one meeting or shatters a series into exceptions.

Our defaults, then: calendar.freebusy for reads and calendar.events.owned for writes, so the agent can see when the user is busy but never what they are busy with. A deterministic event id on every booking. A re-read after every write, because the insert response is not the final state when a Meet link is involved. IANA time zone names everywhere. Sync tokens with a working 410 path. And a human between the proposal and the invitation, because the person receiving that invitation never agreed to be part of your product.

Fabriq Developer is where we put this for our own users: your end users connect their own Google accounts under an external user id you choose, the credentials sit in our vault rather than your database, and the agent mints a short-lived user-scoped token, 900 seconds, per unit of work. The Calendar surface is its own provider slug, google_calendar, separate from Gmail and Drive, so a scheduling agent's grant stays a scheduling agent's grant.

Calls go to /mcp/external with that user token, and providers/capabilities tells you which providers this deployment can actually connect before you render a connect button. The scope decisions above are still yours to make. We'd rather they be explicit than inherited from whichever example you copied first.

The consent screen is the contract. Everything after it is you keeping your side of a promise your user read once, quickly, before clicking Allow. Ask for the narrow scope even when the wide one is less work, because the wide one is what you'll have to defend the day something goes wrong.

Sources