

Imagine telling your calendar:
Schedule a meeting with Sarah next Tuesday at 10 AM for 1 hour.
— and it just happens.
In this tutorial, you’ll build your own AI calendar assistant using Google’s Agent Development Kit (ADK) and the Google Calendar API. Your agent will understand natural language, create and list events, and handle details like times, dates, and descriptions automatically.
We’ll dive deep into the practical development process, leveraging Google’s Agent Development Kit (ADK) and the robust capabilities of the Gemini model, tightly integrated with the Google Calendar API.
By the end, you’ll have a working personal scheduling assistant you can talk to — and a solid blueprint for building more AI-powered productivity agents.
How It Works
Step 1 — Setting Up the Foundation: Connecting to Google Calendar
Before our AI agent can begin managing your schedule, it needs the proper authorization to interact with your Google Calendar. This involves a one-time setup process with the Google Cloud Platform and ensuring your Python environment has the necessary libraries.
- Go to the Google Cloud Console
- Create a new project
- Go to APIs & Services → Library and enable Google Calendar API
- Go to APIs & Services → Credentials
- Click Create Credentials → OAuth client ID App type: Desktop App
- Download the credentials.json file Save it in your project folder
Install the required libraries:
pip install google-api-python-client google-auth-oauthlib google-auth dateparser python-dateutil pytz tzlocal
Now, let’s set up our initial Python script with the necessary imports and the model definition:
import datetime
import os.path
import re
from dateutil import parser as dateutil_parser
import dateparser
import pytz
from tzlocal import get_localzone
from typing import Optional, List, Dict from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from google.adk.agents import Agent
from google.genai import types
MODEL = "gemini-2.0-flash-001"
SCOPES = ["https://www.googleapis.com/auth/calendar"]
The get_calendar_service() Function: Interact with Google Calendar
This function is the heart of our integration. It handles the entire OAuth 2.0 authorization flow, ensuring our agent always has valid credentials to make API calls.
def get_calendar_service():
creds = None
if os.path.exists("token.json"):
try:
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
except (UnicodeDecodeError, ValueError):
print("Warning: 'token.json' is invalid or has an encoding issue. Attempting to re-authorize.")
os.remove("token.json")if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
creds = flow.run_local_server(port=0)
with open("token.json", "w", encoding="utf-8") as token:
token.write(creds.to_json())
return build("calendar", "v3", credentials=creds)
With get_calendar_service() in place, our agent now has a secure and persistent connection to Google Calendar, ready to perform actions.
Step 2 — Syncing Up with Your User’s Local Timezone
One of the most critical aspects of a truly user-friendly calendar agent is its awareness of time zones. While the Google Calendar API often prefers UTC for storage efficiency, users think and operate in their local time. Our agent needed to seamlessly interpret local times and display results in the user’s context.
Automatically Detecting the User’s Local Timezone (get_user_timezone)
To achieve a localized experience, our first step was to programmatically determine the user’s local timezone. We use the tzlocal library for this, providing a fallback in case detection fails (a common practice for robustness).
def get_user_timezone() -> str:
"""
Detect the user's local time zone. Falls back to 'Asia/Kolkata' if detection fails.
"""
try:
return str(get_localzone())
except Exception as e:
print(f"Warning: Could not detect local time zone ({str(e)}). Falling back to 'Asia/Kolkata'.")
return "Asia/Kolkata"
This function returns an IANA timezone identifier (e.g., Asia/Kolkata, America/New_York), which is the standard format used by Google Calendar.
Displaying Events in Local Time (search_events)
Similarly, when the agent retrieves events from the calendar, it converts any UTC dateTime values into the user’s local timezone before presenting them. This ensures that the user sees their schedule exactly as they expect.
def search_events(
query: Optional[str] = None,
time_min: Optional[str] = None,
time_max: Optional[str] = None,
max_results: int = 10,
calendar_id: str = "primary"
) -> List[str]:
service = get_calendar_service()
params = {
"calendarId": calendar_id,
"maxResults": max_results,
"singleEvents": True,
"orderBy": "startTime"
}
if query:
params["q"] = query
if time_min:
params["timeMin"] = time_min
if time_max:
params["timeMax"] = time_maxtry:
events_result = service.events().list(**params).execute()
events = events_result.get("items", [])
if not events:
return ["No events found."]
user_tz = pytz.timezone(get_user_timezone())
formatted_events = []
for event in events:
start = event['start'].get('dateTime', event['start'].get('date'))
if 'dateTime' in event['start']:
utc_time = datetime.datetime.fromisoformat(start.replace('Z', '+00:00'))
local_time = utc_time.astimezone(user_tz)
formatted_time = local_time.strftime("%Y-%m-%d %I:%M %p %Z")
else:
formatted_time = start
formatted_events.append(f"{formatted_time} - {event['summary']} - ID: {event['id']}")
return formatted_events
except HttpError as error:
raise ValueError(f"Failed to search events: {str(error)}")
Dynamic Timezone Interpretation in parse_natural_language_datetime
The parse_natural_language_datetime function, which handles incoming natural language date/time strings, is also made timezone-aware. It uses the user_timezone in its dateparser settings (TIMEZONE, TO_TIMEZONE) to correctly interpret ambiguous times (e.g., 9 AM could mean 9 AM local time) before converting them to UTC for API calls.
def parse_natural_language_datetime(datetime_string: str, duration: Optional[str] = None, time_preference: Optional[str] = None) -> tuple[str, str, Optional[tuple[datetime.time, datetime.time]]]:
"""
Parses a natural language date/time string in the user's local time zone
and returns start and end times in ISO 8601 UTC format, plus optional time window.Args:
datetime_string: Natural language input (e.g., "next Friday at 11 AM").
duration: Optional duration (e.g., "1 hour", "for 30 minutes").
time_preference: Optional preference (e.g., "morning", "9 AM to 2 PM").
Returns:
Tuple of (start_datetime, end_datetime, time_window) in ISO 8601 UTC and optional (start_time, end_time).
"""
user_timezone = get_user_timezone()
settings = {
'TIMEZONE': user_timezone,
'TO_TIMEZONE': 'UTC',
'RETURN_AS_TIMEZONE_AWARE': True,
'PREFER_DATES_FROM': 'future',
'DATE_ORDER': 'DMY',
'STRICT_PARSING': False
}
time_window = None
if time_preference:
if time_preference.lower() in ["morning", "afternoon", "evening"]:
time_ranges = {
"morning": (datetime.time(9, 0), datetime.time(12, 0)),
"afternoon": (datetime.time(12, 0), datetime.time(17, 0)),
"evening": (datetime.time(17, 0), datetime.time(21, 0))
}
time_window = time_ranges.get(time_preference.lower())
else:
try:
match = re.match(r'(\d+\s*(?:AM|PM|am|pm))\s*to\s*(\d+\s*(?:AM|PM|am|pm))', time_preference, re.IGNORECASE)
if match:
start_str, end_str = match.groups()
start_time = dateutil_parser.parse(start_str).time()
end_time = dateutil_parser.parse(end_str).time()
time_window = (start_time, end_time)
except ValueError:
print(f"Could not parse time preference: {time_preference}")
# Try parsing with dateparser first
parsed_datetime = dateparser.parse(
datetime_string,
languages=['en'],
settings=settings
)
if not parsed_datetime:
# Handle "next [day]" patterns with optional time part
match = re.match(r'next\s+([a-zA-Z]+)(?:\s+at\s+(.+?))?(?:\s+(morning|afternoon|evening))?$', datetime_string, re.IGNORECASE)
if match:
day_name, time_part, period = match.groups()
print(f"Manual parsing: day_name={day_name}, time_part={time_part}, period={period}")
day_map = {
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
'friday': 4, 'saturday': 5, 'sunday': 6
}
if day_name.lower() not in day_map:
raise ValueError(f"Invalid day name: {day_name}")
target_weekday = day_map[day_name.lower()]
current_date = datetime.datetime.now(pytz.timezone(user_timezone))
current_weekday = current_date.weekday()
days_ahead = (target_weekday - current_weekday + 7) % 7 or 7
target_date = current_date + datetime.timedelta(days=days_ahead)
# Default to 9 AM if no time part or period is provided
default_hour = 9
if period:
period_map = {
'morning': 9,
'afternoon': 13,
'evening': 18
}
default_hour = period_map.get(period.lower(), 9)
time_part = time_part or f"{default_hour}:00"
if time_part:
try:
time_parsed = dateutil_parser.parse(time_part, fuzzy=True)
parsed_datetime = target_date.replace(
hour=time_parsed.hour,
minute=time_parsed.minute,
second=0,
microsecond=0
)
except ValueError:
raise ValueError(f"Could not parse time part: {time_part}")
else:
parsed_datetime = target_date.replace(
hour=default_hour,
minute=0,
second=0,
microsecond=0
)
if not parsed_datetime:
try:
# Fallback to dateutil for general parsing
parsed_datetime = dateutil_parser.parse(datetime_string, fuzzy=True)
parsed_datetime = pytz.timezone(user_timezone).localize(parsed_datetime)
except ValueError:
raise ValueError(f"Could not parse date/time: {datetime_string}")
parsed_datetime = parsed_datetime.astimezone(pytz.UTC)
start_datetime = parsed_datetime.isoformat().replace('+00:00', 'Z')
if duration:
duration_minutes = parse_duration(duration)
end_datetime = (parsed_datetime + datetime.timedelta(minutes=duration_minutes)).isoformat().replace('+00:00', 'Z')
else:
end_datetime = (parsed_datetime + datetime.timedelta(hours=1)).isoformat().replace('+00:00', 'Z')
return start_datetime, end_datetime, time_window
By integrating timezone awareness at multiple levels, our agent ensures a consistent and intuitive experience, allowing users to interact with their calendar naturally, without worrying about timezone conversions.
Understanding Natural Language — Date/Time & Duration Parsing
Enabling our agent to understand phrases like next Friday at 11 AM or for 45 minutes was one of the most significant and iterative challenges. This section details how we built a robust parsing pipeline for dates, times, and durations.
The NLP Challenge for Dates and Times
Human language for dates and times is incredibly flexible and often ambiguous. Phrases like next Tuesday, in two weeks, tonight, or 9 to 5 all require intelligent interpretation far beyond simple string matching. Our initial attempts to solely rely on the LLM’s instructions for pre-processing proved inconsistent, leading to frequent Could not parse date/time errors. We realized the robustness needed to be within our dedicated Python tools.
Introducing dateparser and dateutil.parser
To tackle this, we brought in two powerful Python libraries:
- dateparser: Excellent for a wide range of natural language date/time expressions and highly configurable with settings.
- python-dateutil’s parser: Provides a robust, fuzzy parsing capability that often catches what dateparser might miss, serving as an invaluable fallback.
We ensured these, along with pytz and tzlocal for timezone handling, were part of our project’s dependencies.
Parsing Durations with parse_duration()
Before diving into complex date/time parsing, we needed a helper to convert natural language durations into a standard unit (minutes).
def parse_duration(duration: str) -> int:
"""
Parse a duration string into minutes.Args:
duration: Duration string (e.g., "30 minutes", "for 1 hour").
Returns:
Duration in minutes.
Raises:
ValueError: If duration cannot be parsed.
"""
duration_match = re.match(r'(?:for\s+)?(\d+)\s*(hour|hours|minute|minutes)', duration, re.IGNORECASE)
if duration_match:
value, unit = duration_match.groups()
value = int(value)
return value * 60 if unit.lower().startswith('hour') else value
raise ValueError(f"Could not parse duration: {duration}")
This simple regex-based function efficiently extracts the numerical value and unit (hours/minutes) and returns the total duration in minutes.
Building the parse_natural_language_datetime() Function
This function is the workhorse for all date and time parsing. It combines dateparser, dateutil.parser, regex, and manual logic to handle a wide array of inputs.
def parse_natural_language_datetime(datetime_string: str, duration: Optional[str] = None, time_preference: Optional[str] = None) -> tuple[str, str, Optional[tuple[datetime.time, datetime.time]]]:
"""
Parses a natural language date/time string in the user's local time zone
and returns start and end times in ISO 8601 UTC format, plus optional time window.Args:
datetime_string: Natural language input (e.g., "next Friday at 11 AM").
duration: Optional duration (e.g., "1 hour", "for 30 minutes").
time_preference: Optional preference (e.g., "morning", "9 AM to 2 PM").
Returns:
Tuple of (start_datetime, end_datetime, time_window) in ISO 8601 UTC and optional (start_time, end_time).
"""
user_timezone = get_user_timezone() # Get the user's local timezone
settings = {
'TIMEZONE': user_timezone, # Interpret ambiguous times relative to user's TZ
'TO_TIMEZONE': 'UTC', # Always convert final output to UTC for Google Calendar API
'RETURN_AS_TIMEZONE_AWARE': True,
'PREFER_DATES_FROM': 'future', # Prioritize future dates for ambiguous phrases (e.g., "Friday")
'DATE_ORDER': 'DMY', # Explicitly set preferred date order
'STRICT_PARSING': False # Allow some flexibility in parsing
}
time_window = None
# 1. Handle Time Preferences (e.g., "morning", "9 AM to 2 PM")
if time_preference:
if time_preference.lower() in ["morning", "afternoon", "evening"]:
time_ranges = {
"morning": (datetime.time(9, 0), datetime.time(12, 0)),
"afternoon": (datetime.time(12, 0), datetime.time(17, 0)),
"evening": (datetime.time(17, 0), datetime.time(21, 0))
}
time_window = time_ranges.get(time_preference.lower())
else:
try:
# Regex for "HH AM/PM to HH AM/PM"
match = re.match(r'(\d+\s*(?:AM|PM|am|pm))\s*to\s*(\d+\s*(?:AM|PM|am|pm))', time_preference, re.IGNORECASE)
if match:
start_str, end_str = match.groups()
start_time = dateutil_parser.parse(start_str).time()
end_time = dateutil_parser.parse(end_str).time()
time_window = (start_time, end_time)
except ValueError:
print(f"Could not parse time preference: {time_preference}")
# 2. First attempt: Parse with dateparser
parsed_datetime = dateparser.parse(
datetime_string,
languages=['en'],
settings=settings
)
# 3. Fallback for "next [day]" patterns (e.g., "next Friday at 11 AM") if dateparser fails
if not parsed_datetime:
match = re.match(r'next\s+([a-zA-Z]+)(?:\s+at\s+(.+?))?(?:\s+(morning|afternoon|evening))?$', datetime_string, re.IGNORECASE)
if match:
day_name, time_part, period = match.groups()
day_map = {
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
'friday': 4, 'saturday': 5, 'sunday': 6
}
if day_name.lower() not in day_map:
raise ValueError(f"Invalid day name: {day_name}")
target_weekday = day_map[day_name.lower()]
current_date = datetime.datetime.now(pytz.timezone(user_timezone))
current_weekday = current_date.weekday()
days_ahead = (target_weekday - current_weekday + 7) % 7 # Calculate days until next target weekday
if days_ahead == 0: days_ahead = 7 # If target is today, get next week's
target_date = current_date + datetime.timedelta(days=days_ahead)
# Determine default hour based on period if no explicit time_part
default_hour = 9
if period:
period_map = {'morning': 9, 'afternoon': 13, 'evening': 18}
default_hour = period_map.get(period.lower(), 9)
time_part = time_part or f"{default_hour}:00" # Use default period time if no specific time
if time_part:
try:
time_parsed = dateutil_parser.parse(time_part, fuzzy=True)
parsed_datetime = target_date.replace(
hour=time_parsed.hour, minute=time_parsed.minute, second=0, microsecond=0
)
except ValueError:
raise ValueError(f"Could not parse time part: {time_part}")
else:
parsed_datetime = target_date.replace(hour=default_hour, minute=0, second=0, microsecond=0)
# 4. Final Fallback: Try dateutil.parser for general fuzzy parsing
if not parsed_datetime:
try:
parsed_datetime = dateutil_parser.parse(datetime_string, fuzzy=True)
# Make the parsed datetime timezone-aware in the user's local timezone
parsed_datetime = pytz.timezone(user_timezone).localize(parsed_datetime)
except ValueError:
raise ValueError(f"Could not parse date/time: {datetime_string}")
# 5. Convert final parsed datetime to UTC for Google Calendar API
parsed_datetime = parsed_datetime.astimezone(pytz.UTC)
start_datetime_iso = parsed_datetime.isoformat().replace('+00:00', 'Z')
# 6. Calculate end time based on duration or default
if duration:
duration_minutes = parse_duration(duration)
end_datetime_iso = (parsed_datetime + datetime.timedelta(minutes=duration_minutes)).isoformat().replace('+00:00', 'Z')
else:
# Default to 1 hour if no duration specified
end_datetime_iso = (parsed_datetime + datetime.timedelta(hours=1)).isoformat().replace('+00:00', 'Z')
return start_datetime_iso, end_datetime_iso, time_window
This layered approach within parse_natural_language_datetime
ensures that our agent can robustly interpret a wide variety of date/time expressions, seamlessly handling timezones and durations, and making the agent’s interaction feel truly natural.
Core Event Management — CRUD, Search, and Recurrence
With robust timezone handling and natural language parsing in place, our agent is ready to perform the full suite of calendar management operations. This section details the functions that enable event creation, retrieval, updates, deletion, and searching, alongside handling recurring events and inviting attendees.
create_event(): Adding Events to Your Calendar
The create_event function is the agent’s primary tool for scheduling. It now leverages our timezone-aware parsing and handles optional details gracefully.
def create_event(
summary: str,
start_datetime: str,
end_datetime: str,
location: str = "",
description: str = "",
recurrence: Optional[str] = None,
attendees: Optional[List[Dict[str, str]]] = None
):
user_timezone = get_user_timezone()
service = get_calendar_service()
event = {
"summary": summary,
"start": {"dateTime": start_datetime, "timeZone": user_timezone},
"end": {"dateTime": end_datetime, "timeZone": user_timezone},
}if location and location.strip() != "":
event["location"] = location
if description and description.strip() != "":
event["description"] = description
if recurrence:
event["recurrence"] = [recurrence]
if attendees:
event["attendees"] = attendees
try:
created = service.events().insert(calendarId="primary", body=event).execute()
return f"Event created: {created.get('htmlLink')}"
except HttpError as error:
raise ValueError(f"Failed to create event: {str(error)}")
parse_recurrence(): Scheduling Repeating Events
To support recurring events, we developed a parse_recurrence function that translates natural language (e.g., “every Tuesday for 5 weeks”) into Google Calendar’s RRULE format.
def parse_recurrence(recurrence_string: str) -> str:
"""
Parse natural language recurrence into RRULE format.Args:
recurrence_string: Natural language (e.g., "every Tuesday for 5 weeks").
Returns:
RRULE string (e.g., "RRULE:FREQ=WEEKLY;WKST=TU;COUNT=5").
Raises:
ValueError: If recurrence cannot be parsed.
"""
# Basic parsing for common patterns
match = re.match(r'every\s+(\w+)\s*(for\s+(\d+)\s*(week|month|year)s?)?', recurrence_string, re.IGNORECASE)
if match:
freq_map = {
'daily': 'DAILY', 'weekly': 'WEEKLY', 'monthly': 'MONTHLY', 'yearly': 'YEARLY',
'monday': 'WEEKLY;BYDAY=MO', 'tuesday': 'WEEKLY;BYDAY=TU', 'wednesday': 'WEEKLY;BYDAY=WE',
'thursday': 'WEEKLY;BYDAY=TH', 'friday': 'WEEKLY;BYDAY=FR', 'saturday': 'WEEKLY;BYDAY=SA', 'sunday': 'WEEKLY;BYDAY=SU'
}
day_or_freq = match.group(1).lower()
rrule = f"RRULE:FREQ={freq_map.get(day_or_freq, 'WEEKLY')}" # Default to WEEKLY if not specific day/freq
if match.group(2): # Check if 'for X weeks/months/years' part exists
count = match.group(3)
unit = match.group(4).upper()
if unit.startswith('WEEK'):
rrule += f";COUNT={count}"
elif unit.startswith('MONTH'):
rrule += f";COUNT={int(count)*4}" # Approximate weeks in a month
elif unit.startswith('YEAR'):
rrule += f";COUNT={int(count)*52}" # Approximate weeks in a year
return rrule
raise ValueError(f"Could not parse recurrence: {recurrence_string}")
This function provides a foundation for parsing common recurrence patterns, which can be further expanded for more complex RRULE scenarios.
get_event(): Retrieving Event Details
To enable updates or deeper inspection, the agent can fetch specific event details using its ID.
def get_event(event_id: str, calendar_id: str = "primary") -> Dict:
service = get_calendar_service()
try:
event = service.events().get(calendarId=calendar_id, eventId=event_id).execute()
return event
except HttpError as error:
raise ValueError(f"Failed to get event: {str(error)}")
update_event(): Modifying Existing Events
This powerful function allows the agent to change any aspect of an existing event, including its time, location, description, recurrence, or attendees. It intelligently constructs an update body with only the fields that have been modified.
def update_event(
event_id: str,
summary: Optional[str] = None,
start_datetime: Optional[str] = None,
end_datetime: Optional[str] = None,
location: Optional[str] = None,
description: Optional[str] = None,
recurrence: Optional[str] = None,
attendees: Optional[List[Dict[str, str]]] = None,
calendar_id: str = "primary",
send_updates: str = "none" # "all", "externalOnly", or "none"
) -> str:
service = get_calendar_service()
update_body = {}# Conditionally add fields to update_body only if they are provided
if summary is not None:
update_body["summary"] = summary
if start_datetime is not None:
update_body["start"] = {"dateTime": start_datetime, "timeZone": get_user_timezone()}
if end_datetime is not None:
update_body["end"] = {"dateTime": end_datetime, "timeZone": get_user_timezone()}
if location is not None:
update_body["location"] = location
if description is not None:
update_body["description"] = description
if recurrence is not None:
update_body["recurrence"] = [recurrence]
if attendees is not None:
update_body["attendees"] = attendees
if not update_body:
raise ValueError("No fields provided to update.")
try:
updated = service.events().patch(
calendarId=calendar_id,
eventId=event_id,
body=update_body,
sendUpdates=send_updates # Control notifications to attendees
).execute()
return f"Event updated: {updated.get('htmlLink')}"
except HttpError as error:
raise ValueError(f"Failed to update event: {str(error)}")
delete_event(): Removing Events
Deleting an event is straightforward, requiring only the event ID.
def delete_event(event_id: str, calendar_id: str = "primary", send_updates: str = "none") -> str:
service = get_calendar_service()
try:
service.events().delete(
calendarId=calendar_id,
eventId=event_id,
sendUpdates=send_updates
).execute()
return "Event deleted successfully."
except HttpError as error:
raise ValueError(f"Failed to delete event: {str(error)}")
search_events() and list_events(): Finding Your Schedule
Beyond just listing upcoming events, search_events provides powerful filtering capabilities by keywords, time range, and displays results in the user’s local timezone. list_events acts as a convenient wrapper for quick access to upcoming items.
def search_events(
query: Optional[str] = None,
time_min: Optional[str] = None,
time_max: Optional[str] = None,
max_results: int = 10,
calendar_id: str = "primary"
) -> List[str]:
service = get_calendar_service()
params = {
"calendarId": calendar_id,
"maxResults": max_results,
"singleEvents": True, # Expand recurring events into individual instances
"orderBy": "startTime"
}
if query:
params["q"] = query # Keyword search
if time_min:
params["timeMin"] = time_min # ISO 8601 UTC start time
if time_max:
params["timeMax"] = time_max # ISO 8601 UTC end timetry:
events_result = service.events().list(**params).execute()
events = events_result.get("items", [])
if not events:
return ["No events found."]
user_tz = pytz.timezone(get_user_timezone())
formatted_events = []
for event in events:
start = event['start'].get('dateTime', event['start'].get('date'))
if 'dateTime' in event['start']: # If it's a timed event
utc_time = datetime.datetime.fromisoformat(start.replace('Z', '+00:00'))
local_time = utc_time.astimezone(user_tz) # Convert to local TZ
formatted_time = local_time.strftime("%Y-%m-%d %I:%M %p %Z")
else: # All-day event
formatted_time = start
formatted_events.append(f"{formatted_time} - {event['summary']} - ID: {event['id']}")
return formatted_events
except HttpError as error:
raise ValueError(f"Failed to search events: {str(error)}")
def list_events(max_results: int = 10):
now = datetime.datetime.now(tz=pytz.UTC).isoformat()
return search_events(time_min=now, max_results=max_results)
Intelligent Scheduling — Meeting Time Suggestions
Beyond simply managing existing events, a truly intelligent calendar assistant should be able to help proactively. One of the most valuable features we integrated is the ability to suggest free meeting times, reducing the back-and-forth of finding a suitable slot.
The Power of suggest_meeting_times()
This function leverages the Google Calendar API’s Free/Busy endpoint to analyze your calendar and identify available slots based on a target date, desired duration, and even preferred time windows (e.g., “morning,” “9 AM to 2 PM”).
def suggest_meeting_times(
date_string: str,
duration: Optional[str] = "1 hour",
time_preference: Optional[str] = None,
calendar_id: str = "primary",
max_suggestions: int = 3
) -> List[str]:
"""
Suggest available meeting times based on calendar free/busy status.Args:
date_string: Target date (e.g., "next Tuesday").
duration: Meeting duration (e.g., "1 hour", "30 minutes").
time_preference: Optional time window (e.g., "morning", "9 AM to 2 PM").
calendar_id: Calendar ID (default: "primary").
max_suggestions: Maximum number of suggested slots.
Returns:
List of formatted time slots in local time zone (e.g., "2025-09-23 10:00 AM IST").
"""
service = get_calendar_service()
user_timezone = get_user_timezone()
user_tz = pytz.timezone(user_timezone)
# Parse date and duration
start_datetime, end_datetime, time_window = parse_natural_language_datetime(date_string, duration, time_preference)
parsed_date = datetime.datetime.fromisoformat(start_datetime.replace('Z', '+00:00')).astimezone(user_tz)
day_start = parsed_date.replace(hour=0, minute=0, second=0, microsecond=0)
day_end = day_start + datetime.timedelta(days=1)
# Parse duration
duration_minutes = parse_duration(duration)
# Query free/busy status
body = {
"timeMin": day_start.astimezone(pytz.UTC).isoformat(),
"timeMax": day_end.astimezone(pytz.UTC).isoformat(),
"items": [{"id": calendar_id}]
}
try:
freebusy = service.freebusy().query(body=body).execute()
busy_periods = freebusy.get("calendars", {}).get(calendar_id, {}).get("busy", [])
except HttpError as error:
raise ValueError(f"Failed to query free/busy status: {str(error)}")
# Convert busy periods to user TZ
busy_slots = []
for period in busy_periods:
start = datetime.datetime.fromisoformat(period["start"].replace('Z', '+00:00')).astimezone(user_tz)
end = datetime.datetime.fromisoformat(period["end"].replace('Z', '+00:00')).astimezone(user_tz)
busy_slots.append((start, end))
# Find free slots
free_slots = []
current_time = day_start
while current_time + datetime.timedelta(minutes=duration_minutes) <= day_end:
slot_end = current_time + datetime.timedelta(minutes=duration_minutes)
is_free = True
for busy_start, busy_end in busy_slots:
if not (slot_end <= busy_start or current_time >= busy_end):
is_free = False
break
if is_free and (not time_window or (time_window[0] <= current_time.time() <= time_window[1])):
free_slots.append(current_time)
current_time += datetime.timedelta(minutes=30) # Check every 30 minutes
# Format suggestions
if not free_slots:
return [f"No available slots found for a {duration} meeting on {day_start.strftime('%Y-%m-%d')}. Would you like suggestions for another day or a shorter duration?"]
formatted_slots = []
for slot in free_slots[:max_suggestions]:
slot_end = slot + datetime.timedelta(minutes=duration_minutes)
formatted_slots.append(f"{slot.strftime('%Y-%m-%d %I:%M %p %Z')} - {slot_end.strftime('%I:%M %p %Z')}")
return formatted_slots
This function demonstrates a sophisticated use of the Google Calendar API, combining dateparser for input flexibility, tzlocal for timezone awareness, and the freebusy endpoint to deliver genuinely helpful scheduling suggestions.
Architecting the Agent — Instructions and Interaction Flow
The intelligence of our calendar agent isn’t solely in its Python tools; it’s equally dependent on how it’s instructed. The Agent framework uses a descriptive prompt (calendar_agent_instruction_text) to guide the LLM’s reasoning, tool selection, and conversational flow. This section details how we crafted these instructions to build a seamless and reliable interaction.
Our agent is instantiated with the Agent class, specifying the Gemini model, a descriptive name, configuration, and critically, the comprehensive list of tools it can utilize.
root_agent = Agent(
model=MODEL,
name="google_calendar_agent",
description="An AI assistant that manages your Google Calendar using natural language, including creating (with recurrence and attendees), updating, deleting, searching, and suggesting meeting times in your local time zone." + calendar_agent_instruction_text,
generate_content_config=types.GenerateContentConfig(temperature=0.2),
tools=[create_event, get_event, update_event, delete_event, search_events, list_events, parse_natural_language_datetime, suggest_meeting_times, parse_recurrence],
)
The description field here is augmented with calendar_agent_instruction_text, which forms the bulk of the agent’s behavioral guidance. generate_content_config allows us to tune the model’s creativity (a lower temperature like 0.2 is often good for agents requiring precise, factual responses).
The Comprehensive calendar_agent_instruction_text
This multi-faceted instruction set is crucial for the agent’s effectiveness:
You are a helpful and precise calendar assistant that operates in the user's local time zone (e.g., IST for Asia/Kolkata).Event Creation Instructions:
When the user wants to create an event:
- Collect essential details: title, start time, end time/duration.
- Use `parse_natural_language_datetime` to parse dates/times/durations into ISO 8601 UTC.
- Location and description are optional; only include if provided.
- For recurring events, parse recurrence (e.g., "every Tuesday for 5 weeks") using `parse_recurrence` and pass as RRULE string.
- For attendees, parse emails (e.g., "invite bob@example.com and alice@example.com") as list of dicts [{email: "bob@example.com"}, {email: "alice@example.com"}].
- Call `create_event` with parsed values, including recurrence and attendees if provided.
- Respond with confirmation, title/time in local TZ, and link.
Event Updating/Editing Instructions:
When the user wants to update or edit an event:
- Identify the event: Use `search_events` or `get_event` if ID is known.
- Ask for clarification if multiple matches or ambiguous.
- Use `parse_natural_language_datetime` if updating times/durations.
- For updating recurrence or attendees, parse and pass as in creation.
- Call `update_event` with the event ID and only changed fields (pass None for unchanged), including recurrence or attendees.
- Set `send_updates` to "all" if attendees might be affected, else "none".
- Respond with confirmation and updated details in local TZ.
Event Deletion Instructions:
When the user wants to delete an event:
- Identify the event: Use `search_events` to find the event ID.
- Confirm with the user if needed (e.g., show details via `get_event`).
- Call `delete_event` with the event ID.
- Set `send_updates` to "all" if notifying others, else "none".
- Respond with confirmation.
Event Search and Querying Instructions:
When the user asks to search or query events:
- Use `search_events` with query (keywords), time_min/max (parsed via `parse_natural_language_datetime`).
- Display results in local TZ, including event ID for reference.
- If no results, say so politely.
- For upcoming events, use `list_events`.
Meeting Time Suggestions Instructions:
When the user asks to suggest meeting times (e.g., "Suggest a time for a meeting next Tuesday"):
- Use `suggest_meeting_times` with the target date, duration, and optional time preference (e.g., "morning", "9 AM to 2 PM").
- Parse inputs using `parse_natural_language_datetime` to get the date and duration.
- Return 2-3 free time slots in local TZ (e.g., IST).
- If no slots are available, suggest alternative days or durations.
- Offer to create an event with the chosen slot (e.g., "Shall I schedule the meeting at 2 PM?").
- Example: "Suggest a 1-hour meeting next Tuesday morning" returns slots like "2025-09-23 10:00 AM IST - 11:00 AM IST".
General Instructions:
- Always use local time zone (e.g., IST) for inputs/outputs; convert to UTC for API.
- For "next [day]" (e.g., "next Friday"), interpret as next occurrence.
- If event ID unknown for update/delete, search first.
- Handle ambiguities by asking questions.
- Keep responses short, user-friendly; no raw JSON.
- Prioritize clarity and correctness.
This approach to crafting the calendar_agent_instruction_text is as vital in enabling the LLM to effectively orchestrate complex tasks and interact intelligently with the user.
Interacting with the Agent
Having crafted our agent’s tools and fine-tuned its instructions, the true test lies in its real-world interaction. We will now run the agent and walk through several demonstration scenarios, complete with example prompts and expected responses, showcasing its capabilities.
To run your agent go ahead and run the adk web command to launch the web UI.
Our first interaction is fundamental: understanding what the agent can actually do. This tests the agent’s ability to summarize its description and instruction_text.
Prompt: What can you do for me?
The agent provides a concise summary of its abilities, drawing from its description and instruction set.
Basic Event Creation
This tests the core functionality of creating an event, validating natural language date/time parsing, and confirming that optional fields are not requested if not provided.
Prompt: Create an event Morning Jog for tomorrow at 6 AM until 6:30 AM.
Source Credit: https://medium.com/google-cloud/build-your-own-ai-google-calendar-assistant-with-agent-development-kit-29f917be9e07?source=rss—-e52cf94d98af—4