Build a Calendar Assistant With Claude + Google Calendar API: End-to-End Python Tutorial (May 2026)
Wire Claude tool-use to the Google Calendar API in Python with list_events, create_event, and find_free_slot tools — full OAuth-to-agent walkthrough.
Image: Anthropic platform docs — Tool use with Claude overview (platform.claude.com), used for editorial coverage of the API surface the assistant below is built on.
TL;DR
This tutorial walks through a working command-line calendar
assistant that turns natural-language requests (“schedule a 30-min
meeting with Sam tomorrow at 3 pm”) into Google Calendar events.
The stack is Python 3.11+, the official anthropic SDK, and
Google’s google-api-python-client + google-auth-oauthlib
libraries. Three client tools are exposed to Claude:
list_events, create_event, and find_free_slot. Total walk-
through time runs roughly 45 minutes for a developer comfortable
with pip and OAuth.
Per Anthropic’s tool-use overview, client tools follow a loop:
Claude responds with stop_reason: "tool_use", your code executes
the call against the Google Calendar API, you send back a
tool_result content block, and Claude either calls another tool
or returns a final text confirmation 1 . Google’s
official Python quickstart is the authoritative reference for the
OAuth credentials flow and service.events() calls used
here 2 .
What you’ll need
- Python 3.11 or newer with
pipavailable on PATH. - A Google account with Google Calendar enabled.
- A Google Cloud project with the Calendar API enabled and an OAuth
2.0 Client ID of type Desktop app, downloaded as
credentials.json. Per Google’s quickstart, this is the file theInstalledAppFlowconsumes on first run 2 . - An Anthropic API key from
console.anthropic.com; new accounts ship with prepaid trial credit visible on the billing page.
Step 1 — Enable the Google Calendar API
Open the Google Cloud Console at console.cloud.google.com,
create a new project (or pick an existing one), and search for
Google Calendar API in the API library. Click Enable.
Then under APIs & Services → Credentials:
- Configure the OAuth consent screen as an External app,
add your own Google account as a Test user, and add the
https://www.googleapis.com/auth/calendarscope. - Create an OAuth client ID, application type Desktop app.
- Download the JSON; rename it to
credentials.jsonand keep it alongside the project source.
The Desktop-app client type is what Google’s Python quickstart
expects; it lets InstalledAppFlow open a local browser, complete
consent, and write a token.json file with the refresh token for
later runs 2 .
Step 2 — Project scaffolding
Create the project and install dependencies:
mkdir claude-calendar-assistant && cd claude-calendar-assistant
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "anthropic>=0.40" \
"google-api-python-client>=2.140" \
"google-auth-httplib2>=0.2" \
"google-auth-oauthlib>=1.2" \
"python-dotenv>=1.0"
pip freeze > requirements.txt
The three google-* packages are the exact set Google’s
quickstart installs 2 . python-dotenv reads a
local .env into os.environ at startup 3 .
Create .env:
ANTHROPIC_API_KEY=sk-ant-your-key-here
Add .env, credentials.json, and token.json to .gitignore
immediately. A leaked refresh token grants ongoing access to the
authorising Google account’s calendar; a leaked Anthropic key is
billable.
Step 3 — Google Calendar authentication helper
Create calendar_auth.py. The flow follows Google’s quickstart
verbatim: check for a cached token.json, refresh if expired,
otherwise run InstalledAppFlow.from_client_secrets_file and
write the resulting credentials back to disk 2 .
import os
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
SCOPES = ["https://www.googleapis.com/auth/calendar"]
def get_calendar_service():
creds = None
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
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") as token_file:
token_file.write(creds.to_json())
return build("calendar", "v3", credentials=creds)
The calendar scope (read + write) is required because the
assistant needs to create events as well as read them; the
read-only calendar.readonly scope from Google’s quickstart isn’t
sufficient for events.insert 4 .
Image: Google Workspace Developers — Calendar API Python quickstart (developers.google.com), used for editorial coverage of the OAuth flow described.
Step 4 — Define the three Calendar tools
Create calendar_tools.py. Each function takes plain-Python
arguments, calls the Google Calendar API, and returns a string
Claude can read in a tool_result block. The
google-api-python-client library exposes the REST surface as
service.events() and service.freebusy() method
chains 5 .
from datetime import datetime, timedelta, timezone
from calendar_auth import get_calendar_service
# Build the service once at import time.
_service = get_calendar_service()
def list_events(time_min_iso: str, time_max_iso: str, max_results: int = 10) -> str:
"""Return upcoming events between two ISO-8601 timestamps."""
events_result = _service.events().list(
calendarId="primary",
timeMin=time_min_iso,
timeMax=time_max_iso,
maxResults=max_results,
singleEvents=True,
orderBy="startTime",
).execute()
items = events_result.get("items", [])
if not items:
return f"No events between {time_min_iso} and {time_max_iso}."
lines = []
for ev in items:
start = ev["start"].get("dateTime") or ev["start"].get("date")
end = ev["end"].get("dateTime") or ev["end"].get("date")
lines.append(f"- {ev.get('summary', '(no title)')} | {start} -> {end}")
return "\n".join(lines)
def create_event(
summary: str,
start_iso: str,
end_iso: str,
time_zone: str = "UTC",
attendees: list | None = None,
description: str | None = None,
) -> str:
"""Create a calendar event and return a confirmation string."""
body = {
"summary": summary,
"start": {"dateTime": start_iso, "timeZone": time_zone},
"end": {"dateTime": end_iso, "timeZone": time_zone},
}
if attendees:
body["attendees"] = [{"email": e} for e in attendees]
if description:
body["description"] = description
created = _service.events().insert(
calendarId="primary",
body=body,
sendUpdates="all" if attendees else "none",
).execute()
return (
f"Created event '{created['summary']}' at {start_iso} "
f"(id={created['id']}, link={created.get('htmlLink')})"
)
def find_free_slot(
time_min_iso: str,
time_max_iso: str,
duration_minutes: int,
time_zone: str = "UTC",
) -> str:
"""Return the earliest free slot of the requested duration within the window."""
fb = _service.freebusy().query(body={
"timeMin": time_min_iso,
"timeMax": time_max_iso,
"timeZone": time_zone,
"items": [{"id": "primary"}],
}).execute()
busy = fb["calendars"]["primary"].get("busy", [])
window_start = datetime.fromisoformat(time_min_iso.replace("Z", "+00:00"))
window_end = datetime.fromisoformat(time_max_iso.replace("Z", "+00:00"))
cursor = window_start
needed = timedelta(minutes=duration_minutes)
for span in busy:
span_start = datetime.fromisoformat(span["start"].replace("Z", "+00:00"))
span_end = datetime.fromisoformat(span["end"].replace("Z", "+00:00"))
if span_start - cursor >= needed:
return f"Free slot: {cursor.isoformat()} -> {(cursor + needed).isoformat()}"
if span_end > cursor:
cursor = span_end
if window_end - cursor >= needed:
return f"Free slot: {cursor.isoformat()} -> {(cursor + needed).isoformat()}"
return f"No {duration_minutes}-minute free slot between {time_min_iso} and {time_max_iso}."
Three details worth flagging. events().list requires
singleEvents=True plus orderBy="startTime" to flatten
recurring events into chronological instances; the API rejects
orderBy="startTime" without the singleEvents flag per Google’s
events.list reference 6 . events().insert only
sends attendee notification emails when sendUpdates is
"all" or "externalOnly" 7 . And the
freebusy.query response groups busy ranges per calendar ID, so
the code reads fb["calendars"]["primary"]["busy"] rather than a
flat list 8 .
Step 5 — Wire the tools into Claude
Create claude_tools.py. The tool definitions follow Anthropic’s
name + description + input_schema shape from the Define
tools reference 9 .
CLAUDE_TOOLS = [
{
"name": "list_events",
"description": (
"List upcoming events on the user's primary calendar between two "
"ISO-8601 timestamps. Use this before scheduling so you can avoid "
"conflicts. time_min_iso must be earlier than time_max_iso."
),
"input_schema": {
"type": "object",
"properties": {
"time_min_iso": {
"type": "string",
"description": "ISO-8601 start of the window, e.g. 2026-05-20T00:00:00Z.",
},
"time_max_iso": {
"type": "string",
"description": "ISO-8601 end of the window.",
},
"max_results": {
"type": "integer",
"description": "Maximum events to return. Default 10.",
},
},
"required": ["time_min_iso", "time_max_iso"],
},
},
{
"name": "create_event",
"description": (
"Create a new event on the user's primary calendar. "
"Always call find_free_slot first if the user has not pinned "
"a specific time."
),
"input_schema": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "Event title."},
"start_iso": {"type": "string", "description": "ISO-8601 start datetime."},
"end_iso": {"type": "string", "description": "ISO-8601 end datetime."},
"time_zone": {
"type": "string",
"description": "IANA tz name, e.g. America/Los_Angeles.",
},
"attendees": {
"type": "array",
"items": {"type": "string"},
"description": "List of attendee email addresses.",
},
"description": {"type": "string", "description": "Optional event body."},
},
"required": ["summary", "start_iso", "end_iso"],
},
},
{
"name": "find_free_slot",
"description": (
"Find the earliest free slot of the requested duration on the "
"user's primary calendar within the given window."
),
"input_schema": {
"type": "object",
"properties": {
"time_min_iso": {"type": "string"},
"time_max_iso": {"type": "string"},
"duration_minutes": {"type": "integer"},
"time_zone": {"type": "string"},
},
"required": ["time_min_iso", "time_max_iso", "duration_minutes"],
},
},
]
Strict, narrow descriptions help Claude pick the right tool. Per Anthropic’s overview, when a required parameter is missing the Opus tier is far more likely to ask a clarifying question than to fill the value in itself 1 , which is the right behaviour for an assistant that’s about to write to your calendar.
Image: Anthropic platform docs — Define tools / How to implement tool use (platform.claude.com), used for editorial coverage of the input_schema format described.
Step 6 — The conversation loop
Create assistant.py. The loop matches Anthropic’s documented
agentic pattern: send the user prompt, dispatch any tool_use
blocks, append the corresponding tool_result blocks, and stop
when Claude returns stop_reason: "end_turn" 10 .
import os
from datetime import datetime, timezone
from dotenv import load_dotenv
import anthropic
from claude_tools import CLAUDE_TOOLS
from calendar_tools import list_events, create_event, find_free_slot
load_dotenv()
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
MODEL = "claude-opus-4-7"
MAX_TURNS = 8
DISPATCH = {
"list_events": list_events,
"create_event": create_event,
"find_free_slot": find_free_slot,
}
def system_prompt() -> str:
now = datetime.now(timezone.utc).isoformat()
return (
"You are a calendar assistant with access to the user's Google "
"Calendar via three tools: list_events, find_free_slot, create_event. "
f"The current UTC time is {now}. Resolve relative phrases like "
"'tomorrow' or 'next Tuesday' against this timestamp. Always confirm "
"a chosen slot in plain English before creating an event. Use IANA "
"timezone names (e.g. America/Los_Angeles) and ISO-8601 datetimes."
)
def run(user_request: str) -> str:
messages = [{"role": "user", "content": user_request}]
for _ in range(MAX_TURNS):
response = client.messages.create(
model=MODEL,
max_tokens=1024,
system=system_prompt(),
tools=CLAUDE_TOOLS,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
text_blocks = [b.text for b in response.content if b.type == "text"]
return "\n".join(text_blocks) or "(no text returned)"
tool_results = []
for block in response.content:
if block.type != "tool_use":
continue
fn = DISPATCH.get(block.name)
if fn is None:
result = f"tool '{block.name}' is not implemented"
else:
try:
result = fn(**block.input)
except Exception as exc:
result = f"{block.name} error: {exc}"
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(result),
})
messages.append({"role": "user", "content": tool_results})
return "Hit the maximum tool-use turns without a final answer."
if __name__ == "__main__":
print("Calendar assistant ready. Ctrl-C to quit.\n")
while True:
try:
request = input("you> ").strip()
except (EOFError, KeyboardInterrupt):
break
if not request:
continue
print(f"\nassistant> {run(request)}\n")
The claude-opus-4-7 identifier matches Anthropic’s current
tool-use overview Python snippet 1 . Swap to
claude-haiku-4-5 if cost matters more than reasoning depth; both
tiers share the 346-token tool-use system-prompt overhead per
Anthropic’s pricing table 11 .
Step 7 — Run it
From the project root:
python assistant.py
The first run opens a browser tab for Google OAuth consent; pick
the test-user account, accept the calendar scope, and the local
server callback writes token.json to disk. Subsequent runs reuse
the cached token without a browser hop.
Sample session:
you> schedule a 30-min meeting with Sam tomorrow afternoon
assistant> I found a free slot tomorrow at 14:00-14:30 UTC. I have
created the event titled "Meeting with Sam" and invited
sam@example.com. Calendar link: https://www.google.com/calendar/...
Under the hood Claude typically calls find_free_slot for the
“tomorrow afternoon” window, then create_event with the
returned slot. If the user did not specify an attendee email,
Claude is expected to ask for it before calling create_event —
the required-parameter behaviour documented in the
overview 1 .
Image: Google Workspace Developers — Calendar API create-events guide (developers.google.com), used for editorial coverage of the events.insert body schema described.
Step 8 — Hardening notes
A few things to add before this graduates from a local demo:
- Confirm-before-write. The system prompt asks Claude to read
back the chosen slot before calling
create_event, but the tool itself does not enforce it. A safer pattern is to gatecreate_eventbehind a typed “yes” from the user — execute it only after a second human turn. - Timezone resolution. The example accepts the user’s local
timezone via Claude’s
time_zoneargument. For a multi-user deployment, fetch the calendar’s own timezone viacalendars().get(calendarId="primary").execute()["timeZone"]and inject it into the system prompt 5 . - Conflict detection on insert.
events.insertdoes not refuse overlapping events; if you want a hard no-conflict guarantee, runfreebusy.queryinsidecreate_eventand raise if the slot is busy 8 . - Token refresh. Google access tokens expire on the order of an
hour; the refresh-token flow in
calendar_auth.pyhandles that silently. Long-running deployments should also catchgoogle.auth.exceptions.RefreshErrorand surface a re-auth prompt rather than crashing.
Cost expectations
Per Anthropic’s tool-use pricing reference, every request with
tools set carries a 346-token system-prompt overhead on Claude
Opus 4.7 (313 tokens when tool_choice is any or
tool) 11 . A single scheduling exchange
typically takes 2-4 model turns (one to plan, one or two tool
calls, one to confirm), so a busy assistant handling 50 requests
a day adds roughly 35K-70K tokens of tool-system overhead on top
of message tokens. The Google Calendar API itself is free within
generous per-project quotas; the default
queries-per-minute-per-user limit is high enough that a single-
user assistant will not hit it during normal use 6 .
Next steps
- Add a
delete_eventtool.service.events().delete( calendarId="primary", eventId=...).execute()follows the same shape; gate it behind a confirmation turn 5 . - Add a
reschedule_eventtool. Useevents().patchwith the event ID plus a newstart/endblock. - Multi-calendar support. The
freebusy.querybody accepts anitemsarray of calendar IDs, so afind_free_slotvariant can intersect availability across a small team’s calendars in one call 8 . - Enable strict mode on the tool definitions. Adding
"strict": trueforces Claude’s tool calls to match the input schema exactly, which prevents the model from inventing optional fields the Calendar API will reject 9 .
How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.
Sources consulted
Cited Sources
- 1. Anthropic — Tool use with Claude overview (client tools, stop_reason behaviour, required-parameter clarification pattern, claude-opus-4-7 Python snippet) (accessed ) ↩
- 2. Google — Calendar API Python quickstart (pip install set, InstalledAppFlow + credentials.json + token.json flow) (accessed ) ↩
- 3. python-dotenv on PyPI (loads .env into os.environ at startup) (accessed ) ↩
- 4. Google — Calendar API create-events guide (calendar vs calendar.readonly scope requirement for inserts) (accessed ) ↩
- 5. Google — Calendar API v3 reference (service.events, service.freebusy, service.calendars method chains) (accessed ) ↩
- 6. Google — events.list reference (singleEvents + orderBy=startTime requirement, per-user quota) (accessed ) ↩
- 7. Google — events.insert reference (body schema, sendUpdates parameter) (accessed ) ↩
- 8. Google — freebusy.query reference (timeMin / timeMax / timeZone / items body, per-calendar busy ranges) (accessed ) ↩
- 9. Anthropic — How to implement tool use (name / description / input_schema shape; strict tool use) (accessed ) ↩
- 10. Anthropic — How tool use works (agentic loop, tool_use to tool_result handoff) (accessed ) ↩
- 11. Anthropic — Tool use pricing table (346 / 313 token system-prompt overhead by tool_choice) (accessed ) ↩
Further Reading
- google-auth-oauthlib on PyPI (accessed )
Anonymous · no cookies set