Scheduling a meeting takes three API calls: check availability, create the event, and draft the confirmation.
In a human workflow, those three steps share a session.
In an agent, every tool calling crosses an independent OAuth boundary - and each boundary requires its own token, its own refresh logic, its own error handling.
This is the part of agentic development that doesn't show up in agent demos. The demo shows the agent booking a meeting. The production reality is that you've written token-fetching code three times, your refresh logic has a race condition you'll discover at 2 am, and your error messages could be indistinguishable between "token expired" and "wrong scope."
This cookbook solves that by delegating OAuth lifecycle management to Scalekit entirely, so the agent code is just workflow logic.
What You're Actually Building
A Python agent that:
- Authorizes against Google Calendar and Gmail independently
- Queries the freeBusy endpoint to find open slots
- Creates a calendar event with attendee(s)
- Drafts (not sends - more on that), a follow-up email with the event link
The complete source code is in python/meeting_scheduler_agent.py in the agent-auth-examples repo.
Why OAuth Gets Complicated in Multi-Step Agents
Single-API agents are manageable: You fetch a token, store it, and refresh it when it expires. It's boilerplate, but contained boilerplate.
Multi-API agents have a compounding problem. Google Calendar and Gmail use separate OAuth scopes and issue separate access tokens. Your agent must manage both independently, and the failure modes interact in non-obvious ways.
The four OAuth problems that make this harder than it looks:
- One token per connector. Calendar and Gmail have different scope requirements and different token lifetimes. You can't share a token between them, and the refresh logic for each has to be independent.
- First-run authorization is blocking. If the user hasn't authorized a connector yet, your agent can't proceed until they complete the OAuth flow in a browser. That blocking behavior needs to be handled explicitly - not just as an error case, but as a designed state in the workflow.
- Token expiry is silent. A token that worked yesterday fails today, and the failure looks identical to a permissions error. Without explicit expiry tracking, your agent degrades silently.
- Chaining tool outputs is fragile. The Calendar API returns an event link. That link needs to appear in the Gmail draft. If the Calendar call succeeds but the Gmail call fails, you have an orphaned calendar event with no follow-up email. The compensation logic is real work.
These aren't edge cases. They're the normal operating conditions of any agent that touches more than one external system.
The Architecture: Delegated OAuth Lifecycle
Scalekit exposes a connected_accounts abstraction: a mapping from a user identifier to an authorized OAuth session per connector. When your agent calls get_or_create_connected_account, Scalekit either returns an existing active session with a valid token or creates a new one and returns an authorization URL. After the user authorizes, get_connected_account returns the token. From that point, Scalekit handles refresh automatically - including rotation, expiry detection, and re-issuance.
The consequence: your agent's authorization step is a single function regardless of which connector you're targeting. Calendar, Gmail, Slack, Jira - the pattern is identical. The connector-specific OAuth complexity lives in Scalekit's infrastructure, not in your agent code.
This is the same principle behind why enterprises centralize OAuth in a gateway rather than distributing token management across individual services. When credentials live in one place, you get consistent refresh handling, consistent revocation, and a coherent audit trail. For an agent, Scalekit is that centralized layer.
Setup
Create a .env file at the project root with your Scalekit credentials:
SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com
SCALEKIT_CLIENT_ID=your-client-id
SCALEKIT_CLIENT_SECRET=your-client-secret
Install dependencies:
pip install scalekit-sdk python-dotenv requests
In the Scalekit Dashboard, create 2 connections:
-
googlecalendar- Google Calendar OAuth connection -
gmail- Gmail OAuth connection
The connection names must match exactly what you pass to authorize(). A mismatch returns an error, not a helpful message.
The Code for Building Meeting Scheduler Agent using Google Calendar, Gmail, and Scalekit
Initialize the Scalekit client
import os
import base64
from datetime import datetime, timezone, timedelta
from email.mime.text import MIMEText
import requests
from dotenv import load_dotenv
from scalekit import ScalekitClient
load_dotenv()
# Never hard-code credentials - they would be exposed in source control
# and CI logs. Pull them from environment variables instead.
scalekit_client = ScalekitClient(
environment_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"),
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
)
actions = scalekit_client.actions
# Replace with a real user identifier from your application's session
USER_ID = "user_123"
ATTENDEE_EMAIL = "attendee@example.com"
MEETING_TITLE = "Quick Sync"
DURATION_MINUTES = 60
SEARCH_DAYS = 3
WORK_START_HOUR = 9 # UTC
WORK_END_HOUR = 17 # UTC
scalekit_client.actions is the entry point for all connected-account operations. Initialize it once and pass actions to the functions below.
Authorize each connector
The authorize function handles the first-run prompt and returns a valid access token:
def authorize(connector: str) -> str:
"""Ensure the user has an active connected account and return its access token.
On first run, this prints an authorization URL and waits for the user
to complete the browser OAuth flow before continuing.
"""
account = actions.get_or_create_connected_account(connector, USER_ID)
if account.status != "active":
auth_link = actions.get_authorization_link(connector, USER_ID)
print(f"\nOpen this link to authorize {connector}:\n{auth_link}\n")
input("Press Enter after completing authorization in your browser...")
account = actions.get_connected_account(connector, USER_ID)
return account.authorization_details["oauth_token"]["access_token"]
Call this once per connector before any API calls:
calendar_token = authorize("googlecalendar")
gmail_token = authorize("gmail")
After the first successful authorization, get_or_create_connected_account returns status == "active" on subsequent runs and the if block is skipped. Scalekit refreshes expired tokens automatically - you never write refresh logic.
The critical property: this function is identical regardless of which connector you're authorizing. Swap "googlecalendar" for "slack" and the same code handles Slack's OAuth flow, token storage, and refresh lifecycle. The connector-specific behavior lives in Scalekit's configuration, not in your agent.
Query calendar availability
With a valid Calendar token, query the freeBusy endpoint to get the user’s busy intervals:
def get_busy_slots(token: str) -> list[dict]:
"""Fetch busy intervals for the user's primary calendar."""
now = datetime.now(timezone.utc)
window_end = now + timedelta(days=SEARCH_DAYS)
response = requests.post(
"https://www.googleapis.com/calendar/v3/freeBusy",
headers={"Authorization": f"Bearer {token}"},
json={
"timeMin": now.isoformat(),
"timeMax": window_end.isoformat(),
"items": [{"id": "primary"}],
},
)
response.raise_for_status()
return response.json()["calendars"]["primary"]["busy"]
raise_for_status() converts 4xx and 5xx responses into exceptions. Without it, a 403 from a missing scope fails silently and returns empty busy slots - which means your agent will try to book into a slot that doesn't exist. Always raising an error.
The busy list contains {"start": "...", "end": "..."} dicts in ISO 8601 format.
Find the first open slot
Walk forward in one-hour increments from now and return the first candidate that falls within working hours and does not overlap a busy interval:
def find_free_slot(busy_slots: list[dict]) -> tuple[datetime, datetime] | None:
"""Return the first open one-hour slot during working hours in UTC.
Returns None if no slot is available in the search window.
"""
now = datetime.now(timezone.utc)
# Round up to the next whole hour so the candidate is always in the future
candidate = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
window_end = now + timedelta(days=SEARCH_DAYS)
while candidate < window_end:
slot_end = candidate + timedelta(minutes=DURATION_MINUTES)
if WORK_START_HOUR <= candidate.hour < WORK_END_HOUR:
overlap = any(
candidate < datetime.fromisoformat(b["end"])
and slot_end > datetime.fromisoformat(b["start"])
for b in busy_slots
)
if not overlap:
return candidate, slot_end
candidate += timedelta(hours=1)
return None
This is a deliberate first-draft implementation: one-hour granularity, UTC-only, primary calendar. The limits are real and addressed in the production notes below - but starting simple makes the logic auditable. You can see exactly what the agent will do before it does it.
Create the calendar event
Post the event to the Google Calendar API and return its HTML link, which you’ll include in the email draft:
def create_event(token: str, start: datetime, end: datetime) -> str:
"""Create a calendar event and return its HTML link."""
response = requests.post(
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
headers={"Authorization": f"Bearer {token}"},
json={
"summary": MEETING_TITLE,
"description": "Scheduled by agent",
"start": {"dateTime": start.isoformat(), "timeZone": "UTC"},
"end": {"dateTime": end.isoformat(), "timeZone": "UTC"},
"attendees": [{"email": ATTENDEE_EMAIL}],
},
)
response.raise_for_status()
return response.json()["htmlLink"]
The htmlLink in the response is the calendar event URL. Google sends an invitation to each attendee automatically - the draft in the next step is a separate follow-up, not a duplicate of the invitation.
Draft the confirmation email
def create_draft(token: str, event_link: str, start: datetime) -> None:
"""Create a Gmail draft with the meeting details."""
body = (
f"Hi,\n\n"
f"I've scheduled '{MEETING_TITLE}' for "
f"{start.strftime('%A, %B %d at %H:%M UTC')} ({DURATION_MINUTES} min).\n\n"
f"Calendar link: {event_link}\n\n"
f"Looking forward to it!"
)
message = MIMEText(body)
message["to"] = ATTENDEE_EMAIL
message["subject"] = f"Invitation: {MEETING_TITLE}"
# Gmail's API requires the raw RFC 2822 message encoded as URL-safe base64
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
response = requests.post(
"https://gmail.googleapis.com/gmail/v1/users/me/drafts",
headers={"Authorization": f"Bearer {token}"},
json={"message": {"raw": raw}},
)
response.raise_for_status()
print("Draft created in Gmail.")
This creates a draft, not a sent message. The user reviews it before sending. For an agent taking actions on behalf of a user, draft-then-review is the right default - it keeps a human in the loop for outbound communication while still eliminating the scheduling work. When you're confident in the agent's output quality, switching to /messages/send is a one-line change.
Wire it together
def main() -> None:
print("Authorizing Google Calendar...")
calendar_token = authorize("googlecalendar")
print("Authorizing Gmail...")
gmail_token = authorize("gmail")
print("Checking calendar availability...")
busy_slots = get_busy_slots(calendar_token)
slot = find_free_slot(busy_slots)
if not slot:
print(f"No free slot found in the next {SEARCH_DAYS} days.")
return
start, end = slot
print(f"Found slot: {start.strftime('%A %B %d, %H:%M')} UTC")
print("Creating calendar event...")
event_link = create_event(calendar_token, start, end)
print(f"Event created: {event_link}")
print("Creating Gmail draft...")
create_draft(gmail_token, event_link, start)
if __name__ == "__main__":
main()
Testing Your Agent
Run the agent:
python meeting_scheduler_agent.py
On first run:
Authorizing Google Calendar...
Open this link to authorize googlecalendar:
https://accounts.google.com/o/oauth2/auth?...
Press Enter after completing authorization in your browser...
Authorizing Gmail...
Open this link to authorize gmail:
https://accounts.google.com/o/oauth2/auth?...
Press Enter after completing authorization in your browser...
Checking calendar availability...
Found slot: Wednesday March 11, 10:00 UTC
Creating calendar event...
Event created: https://calendar.google.com/calendar/event?eid=...
Creating Gmail draft...
Draft created in Gmail.
On subsequent runs, authorization prompts are skipped. The agent goes straight to availability checking.
Verify:
- Open Google Calendar - the event should appear on the chosen date
- Open Gmail Drafts - the draft should contain the event link
Common Mistakes while Building Your Agent
Connection name mismatch.
If you name the Scalekit connectiongoogle-calendarinstead ofgooglecalendar,get_or_create_connected_accountreturns an error. The name in the Dashboard must exactly match the string you pass toauthorize().Missing OAuth scopes.
A 403 when calling the Calendar or Gmail API means the OAuth app in Google Cloud Console is missing required scopes. Calendar needshttps://www.googleapis.com/auth/calendar, Gmail needshttps://www.googleapis.com/auth/gmail.compose.raise_for_status()swallowing context.
The default exception message from requests truncates the response body. In development, addprint(response.text)beforeraise_for_status()to see the full error from Google.UTC times without timezone info.
Passing a naive datetime (withouttimezone.utc) toisoformat()produces a string without a Z suffix. Google Calendar rejects this with a 400. Always construct datetimes withtimezone.utc.USER_IDnot matching your session.
The script uses a hardcoded"user_123". In production, replace this with the actual user ID from your application's session. A mismatch means the connected account query returns the wrong user's tokens - and you'll get valid tokens for the wrong person.
Production Notes
Timezone handling.
The working-hours check is UTC-only. In production, convert the user's local timezone and the attendee's timezone before searching. Thezoneinfomodule (Python 3.9+) handles this without third-party dependencies.Slot granularity.
One-hour increments miss 30- and 15-minute openings. Use the busy intervals directly to calculate gaps between events, then filter by minimum duration.Multiple calendars.
The freeBusy query checks onlyprimary. Users who split work and personal calendars will show false availability. Expand theitemslist to include all calendars they've shared access to.Error recovery.
Ifcreate_eventsucceeds butcreate_draftfails, you have an orphaned event with no follow-up email. Wrap the two calls in a compensation pattern: store the event ID and delete it if draft creation fails.Credentials in multi-tenant deployments.
TheUSER_IDhere is a single hardcoded identifier. In a real multi-tenant system, each user needs their own connected account - and those accounts need to be isolated from each other. Scalekit's connected account model handles this by design:get_or_create_connected_account(connector, user_id)creates a separate authorized session per user, per connector. No token sharing, no cross-user access.Rate limits.
Google Calendar and Gmail have per-user quotas. If your agent runs frequently for the same user, add exponential backoff around therequests.postcalls.
What This Pattern Generalizes To
The authorize() function in this recipe is connector-agnostic. The same four lines handle authorization for any Scalekit-supported connector: swap "googlecalendar" for "slack", "notion", or "jira" and the OAuth lifecycle - initial authorization, token storage, automatic refresh, revocation handling - is managed identically.
This matters as agents get more capable. An agent that starts by booking meetings will eventually need to create Notion docs summarizing those meetings, post Slack updates to the relevant channel, and open Jira tickets for action items. Each of those integrations would traditionally mean writing OAuth boilerplate again. With the connected account model, it means adding one authorize() call per connector and focusing the rest of the code on the workflow.
The engineering leverage is the point. OAuth is infrastructure. Agents are products. The less time you spend on the former, the more you can invest in the latter.
Next Steps
-
Add natural language input - Replace hardcoded
ATTENDEE_EMAIL,MEETING_TITLE, andDURATION_MINUTESwith parameters parsed from an LLM tool call - Build the JavaScript equivalent - The agent-auth-examples repo includes a JavaScript track with the same pattern
-
Handle re-authorization - If a user revokes access,
get_connected_accountreturns an inactive account; add a re-authorization path instead of crashing -
Add more connectors - The
authorize()pattern works for Slack, Notion, Jira - swap the connector name and replace the Google API calls with the target service's API - Review the Scalekit agent auth quickstart - For a broader overview of the connected-accounts model and how it handles multi-tenant deployments

Top comments (0)