Skip to content
Scalekit Docs

Build an agent that books meetings and drafts emails

Connect a Python agent to Google Calendar and Gmail via Scalekit to find free slots, book meetings, and draft follow-up emails.

Scheduling a meeting sounds simple: find a free slot, create an event, send a confirmation. But in an agent, each of those steps crosses a tool boundary — and each tool requires its own OAuth token. Without a managed auth layer, you end up writing token-fetching, refresh logic, and error handling three times over before you write a single line of scheduling logic. This cookbook solves that by using Scalekit to own the OAuth lifecycle for each connector, so your agent can focus on the workflow itself.

This is a Python recipe for agents that call two or more external APIs on behalf of a user. The code on this page is the complete script: save the steps below in one file, meeting_scheduler_agent.py.

The core problems this solves:

  • One token per connector — Google Calendar and Gmail use separate OAuth scopes and separate access tokens. Your agent must manage both independently.
  • First-run authorization is blocking — If the user has not yet authorized a connector, your agent cannot proceed until they complete the browser OAuth flow.
  • Token expiry is silent — A token that worked yesterday fails today, and the failure looks identical to a permissions error.
  • Chaining tool outputs is fragile — The event link from the Calendar API needs to appear in the Gmail draft. If the Calendar call fails mid-workflow, the draft gets a broken link or never gets created.

Scalekit exposes a connected_accounts abstraction that maps a user ID to an authorized OAuth session per connector. When your agent calls get_or_create_connected_account, Scalekit returns the user’s account for that connector. If it isn’t ACTIVE yet, get_authorization_link gives you a URL for the user to authorize. From then on, Scalekit stores the tokens and refreshes them automatically.

Your agent never handles a token. It calls actions.execute_tool with a tool name, such as googlecalendar_query_freebusy, and Scalekit makes the Google API call with the right user’s credentials. The authorization step is one function for every connector, and each API call is one execute_tool call.

  1. Set up the environment

    Create a .env file at the project root with your Scalekit credentials:

    Terminal window
    SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com
    SCALEKIT_CLIENT_ID=your-client-id
    SCALEKIT_CLIENT_SECRET=your-client-secret

    Install dependencies:

    Terminal window
    pip install scalekit-sdk-python python-dotenv

    In the Scalekit Dashboard, create two connections for your environment:

    • googlecalendar — Google Calendar OAuth connection
    • gmail — Gmail OAuth connection

    The script references these names literally. The names must match exactly.

  2. Initialize the Scalekit client

    meeting_scheduler_agent.py
    import os
    from datetime import datetime, timezone, timedelta
    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(
    env_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.

  3. Authorize each connector

    The authorize function makes sure the user has an active connected account for a connector. On first run, it prints an authorization link and waits for the user to finish the browser OAuth flow:

    def authorize(connector: str) -> None:
    """Ensure the user has an ACTIVE connected account for this connector."""
    response = actions.get_or_create_connected_account(
    connection_name=connector,
    identifier=USER_ID,
    )
    if response.connected_account.status != "ACTIVE":
    link_response = actions.get_authorization_link(
    connection_name=connector,
    identifier=USER_ID,
    )
    print(f"\nOpen this link to authorize {connector}:\n{link_response.link}\n")
    input("Press Enter after completing authorization in your browser…")
    response = actions.get_connected_account(
    connection_name=connector,
    identifier=USER_ID,
    )
    if response.connected_account.status != "ACTIVE":
    raise RuntimeError(f"{connector} is still not authorized")

    Call it once per connector before any tool calls:

    authorize("googlecalendar")
    authorize("gmail")

    After the first successful authorization, the account is already ACTIVE on later runs and the if block is skipped. Scalekit refreshes expired tokens automatically.

  4. Query calendar availability

    Call googlecalendar_query_freebusy to get the user’s busy intervals:

    def get_busy_slots() -> list[dict]:
    """Fetch busy intervals for the user's primary calendar."""
    now = datetime.now(timezone.utc)
    window_end = now + timedelta(days=SEARCH_DAYS)
    result = actions.execute_tool(
    tool_name="googlecalendar_query_freebusy",
    connection_name="googlecalendar",
    identifier=USER_ID,
    tool_input={
    "calendar_ids": ["primary"],
    "time_min": now.isoformat(),
    "time_max": window_end.isoformat(),
    },
    )
    return result.data["calendars"]["primary"]["busy"]

    The tool returns Google’s free/busy response as result.data. If the call fails, execute_tool raises an exception with the error from Google, so the caller never gets a silently wrong result. The busy list contains {"start": "...", "end": "..."} dicts with ISO 8601 timestamps.

  5. 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 parse_time(value: str) -> datetime:
    # Google returns UTC times with a trailing Z, which fromisoformat
    # accepts only from Python 3.11
    return datetime.fromisoformat(value.replace("Z", "+00:00"))
    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 < parse_time(b["end"]) and slot_end > parse_time(b["start"])
    for b in busy_slots
    )
    if not overlap:
    return candidate, slot_end
    candidate += timedelta(hours=1)
    return None

    This is a useful first-draft strategy: simple, readable, easy to debug. Its limits are real (one-hour granularity, UTC-only, primary calendar only) and addressed in Production notes below.

  6. Create the calendar event

    Call googlecalendar_create_event and return the event’s link, which you’ll include in the email draft:

    def create_event(start: datetime) -> str:
    """Create a calendar event and return its link."""
    result = actions.execute_tool(
    tool_name="googlecalendar_create_event",
    connection_name="googlecalendar",
    identifier=USER_ID,
    tool_input={
    "summary": MEETING_TITLE,
    "description": "Scheduled by agent",
    "start_datetime": start.isoformat(),
    "event_duration_minutes": DURATION_MINUTES,
    "timezone": "UTC",
    "attendees_emails": [ATTENDEE_EMAIL],
    },
    )
    return result.data["event"]["htmlLink"]

    The tool returns the created event under event, and its htmlLink is the calendar event URL. Google also sends an invitation email to each attendee automatically when the event is created; the draft you create in the next step is a separate follow-up, not the invitation itself.

  7. Draft the confirmation email

    Call gmail_create_draft with the recipient, subject and body. The tool builds the email message for you:

    def create_draft(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!"
    )
    actions.execute_tool(
    tool_name="gmail_create_draft",
    connection_name="gmail",
    identifier=USER_ID,
    tool_input={
    "to": ATTENDEE_EMAIL,
    "subject": f"Invitation: {MEETING_TITLE}",
    "body": body,
    },
    )
    print("Draft created in Gmail.")

    The script creates a draft, not a sent message. The user reviews it before sending. This is the right default for an agent — it takes the action but keeps a human in the loop for outbound communication.

  8. Wire it together

    def main() -> None:
    print("Authorizing Google Calendar…")
    authorize("googlecalendar")
    print("Authorizing Gmail…")
    authorize("gmail")
    print("Checking calendar availability…")
    busy_slots = get_busy_slots()
    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(start)
    print(f"Event created: {event_link}")
    print("Creating Gmail draft…")
    create_draft(event_link, start)
    if __name__ == "__main__":
    main()

Run the agent from the command line:

Terminal window
python meeting_scheduler_agent.py

On first run, you should see two authorization prompts in sequence:

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, the authorization prompts are skipped and the agent goes straight to availability checking.

Verify the results:

  1. Open Google Calendar — you should see the event on the chosen date
  2. Open Gmail — you should see a draft in the Drafts folder with the event link
  • Connection name mismatch — If you name the Scalekit connection google-calendar instead of googlecalendar, get_or_create_connected_account returns an error. The name in the Dashboard must match the connection_name in the script exactly.

  • Missing OAuth scopes — If a tool call fails with 403 Forbidden, the connection is missing a required scope. Calendar needs https://www.googleapis.com/auth/calendar and Gmail needs https://www.googleapis.com/auth/gmail.compose. Add them to the connection in the Scalekit Dashboard, and to your OAuth app if you use your own credentials, then authorize again.

  • UTC times without timezone info — Passing a naive datetime (without timezone.utc) to isoformat() produces a string without a UTC offset, and Google Calendar rejects it. Always construct datetimes with timezone.utc.

  • USER_ID not 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 tool calls act on the wrong user’s accounts.

Timezone handling — The working-hours check (WORK_START_HOUR, WORK_END_HOUR) is UTC-only. In production, convert the user’s local timezone and the attendee’s timezone before searching. The zoneinfo module (Python 3.9+) handles this without third-party dependencies.

Slot granularity — The one-hour increment misses 30- and 15-minute openings. For real scheduling, use the busy intervals directly to calculate the gaps between events, then filter by minimum duration.

Multiple calendars — The free/busy query checks only primary. Users who manage work and personal calendars separately will show false availability. Add their other calendar IDs to calendar_ids; googlecalendar_list_calendars returns them.

Draft vs send — Creating a draft is safer for a first deployment. When you’re confident in the agent’s output quality, switch from gmail_create_draft to gmail_send_message to make the agent fully autonomous. Add a confirmation step before making this change.

Error recovery — If create_event succeeds but create_draft fails, you have an orphaned event with no follow-up email. In production, wrap the two calls in a compensation pattern: keep the event’s id from result.data["event"] and delete it with googlecalendar_delete_event if the draft creation fails.

Rate limits — Google Calendar and Gmail both have per-user quotas. If your agent runs frequently for the same user, add exponential backoff around the execute_tool calls.

  • Add user input — Replace the hardcoded ATTENDEE_EMAIL, MEETING_TITLE, and DURATION_MINUTES with parameters parsed from natural language using an LLM tool call.
  • Build the JavaScript equivalent — The Node.js SDK has the same calls: getOrCreateConnectedAccount, getAuthorizationLink and executeTool.
  • Handle re-authorization — If a user revokes access, the account’s status is no longer ACTIVE and tool calls fail. Catch that, send the user a new authorization link, and retry instead of crashing.
  • Explore other connectors — The same authorize() pattern works for any Scalekit-supported connector: Slack, Notion, Jira. Swap the connection name and call that connector’s tools. Each connector page lists its tools.
  • Review the Scalekit agent auth quickstart — For a broader overview of the connected-accounts model, see the agent auth quickstart.