Neural Tech Daily
ai-tutorials

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.

~12 min read
Share
Anthropic platform docs tool-use overview page showing the Python SDK example with model claude-opus-4-7 and a tool definition using the input_schema shape this calendar assistant follows

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 pip available 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 the InstalledAppFlow consumes 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:

  1. 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/calendar scope.
  2. Create an OAuth client ID, application type Desktop app.
  3. Download the JSON; rename it to credentials.json and 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 .

Google Workspace Developers Calendar API Python quickstart page showing the InstalledAppFlow credentials.json and token.json setup code

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.

Anthropic platform docs Define tools page showing the JSON tool definition with name, description, and input_schema fields used in this calendar assistant

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 .

Google Workspace Developers Calendar API guide page on creating events showing the events.insert body schema with summary, start, end, attendees fields

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 gate create_event behind 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_zone argument. For a multi-user deployment, fetch the calendar’s own timezone via calendars().get(calendarId="primary").execute()["timeZone"] and inject it into the system prompt 5 .
  • Conflict detection on insert. events.insert does not refuse overlapping events; if you want a hard no-conflict guarantee, run freebusy.query inside create_event and 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.py handles that silently. Long-running deployments should also catch google.auth.exceptions.RefreshError and 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_event tool. service.events().delete( calendarId="primary", eventId=...).execute() follows the same shape; gate it behind a confirmation turn 5 .
  • Add a reschedule_event tool. Use events().patch with the event ID plus a new start / end block.
  • Multi-calendar support. The freebusy.query body accepts an items array of calendar IDs, so a find_free_slot variant can intersect availability across a small team’s calendars in one call 8 .
  • Enable strict mode on the tool definitions. Adding "strict": true forces 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. 1. Anthropic — Tool use with Claude overview (client tools, stop_reason behaviour, required-parameter clarification pattern, claude-opus-4-7 Python snippet) (accessed )
  2. 2. Google — Calendar API Python quickstart (pip install set, InstalledAppFlow + credentials.json + token.json flow) (accessed )
  3. 3. python-dotenv on PyPI (loads .env into os.environ at startup) (accessed )
  4. 4. Google — Calendar API create-events guide (calendar vs calendar.readonly scope requirement for inserts) (accessed )
  5. 5. Google — Calendar API v3 reference (service.events, service.freebusy, service.calendars method chains) (accessed )
  6. 6. Google — events.list reference (singleEvents + orderBy=startTime requirement, per-user quota) (accessed )
  7. 7. Google — events.insert reference (body schema, sendUpdates parameter) (accessed )
  8. 8. Google — freebusy.query reference (timeMin / timeMax / timeZone / items body, per-calendar busy ranges) (accessed )
  9. 9. Anthropic — How to implement tool use (name / description / input_schema shape; strict tool use) (accessed )
  10. 10. Anthropic — How tool use works (agentic loop, tool_use to tool_result handoff) (accessed )
  11. 11. Anthropic — Tool use pricing table (346 / 313 token system-prompt overhead by tool_choice) (accessed )

Further Reading

Anonymous · no cookies set

Report a problem with this article

Articles are produced by an autonomous AI pipeline; mistakes do happen. Tell us what's wrong and the editorial review will revisit the claim.

Category

Found this useful? Share it.