n4nAI

Building a personal AI assistant with calendar and email

Hands-on tutorial: build a personal AI assistant calendar email system with Google Calendar, Gmail, and OpenAI-compatible LLM function calling.

n4n Team2 min read541 words

Audio narration

Coming soon — every post will get a voice note here.

A personal AI assistant calendar email setup lets you query meetings and draft replies in natural language without context-switching to multiple apps. This tutorial builds a runnable Python assistant that connects to Google Calendar and Gmail, then delegates intent extraction to an LLM with function calling. You will end up with a CLI that understands “What’s next on my calendar?” and “Email Bob about the postponement.”

Prerequisites

  • Python 3.11 or newer
  • A Google Cloud project with the Calendar API and Gmail API enabled
  • OAuth 2.0 desktop credentials downloaded as credentials.json
  • An OpenAI-compatible API key (we point the client at n4n.ai’s endpoint later for model access and automatic fallback)
  • Install dependencies:
pip install google-api-python-client google-auth-httplib2 \
    google-auth-oauthlib openai python-dotenv

Keep your credentials.json out of version control. The assistant requests read-only calendar scope and compose-only Gmail scope—no delete, no broad access.

Authenticate with Google

The Google client libraries handle token storage if you persist them. The following returns authenticated service objects for both APIs.

import os
from datetime import datetime
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.readonly",
    "https://www.googleapis.com/auth/gmail.compose",
]

def get_services():
    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 f:
            f.write(creds.to_json())
    calendar = build("calendar", "v3", credentials=creds)
    gmail = build("gmail", "v1", credentials=creds)
    return calendar, gmail

Run this once. It opens a browser, you consent, and token.json is written. Subsequent calls reuse the refreshed token.

Calendar and email operations

Wrap the Google calls in plain functions. The LLM never sees the Google SDK—only the JSON shape you return.

def list_events(calendar, max_results=5):
    now = datetime.utcnow().isoformat() + "Z"
    res = calendar.events().list(
        calendarId="primary", timeMin=now,
        maxResults=max_results, singleEvents=True,
        orderBy="startTime").execute()
    return [{"summary": e["summary"], "start": e["start"].get("dateTime")}
            for e in res.get("items", [])]

def send_email(gmail, to, subject, body):
    import base64
    from email.mime.text import MIMEText
    msg = MIMEText(body)
    msg["to"] = to
    msg["subject"] = subject
    raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
    sent = gmail.users().messages().send(userId="me", body={"raw": raw}).execute()
    return {"id": sent["id"]}

These two functions are the entire surface area of the personal AI assistant calendar email integration. Keep them side-effect free except for the explicit send.

Define LLM tools

Function calling needs a schema. The model uses it to emit structured arguments.

[
  {
    "type": "function",
    "function": {
      "name": "list_events",
      "description": "List upcoming calendar events from primary calendar",
      "parameters": {
        "type": "object",
        "properties": {
          "max_results": {"type": "integer", "default": 5}
        }
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "send_email",
      "description": "Send an email through Gmail",
      "parameters": {
        "type": "object",
        "properties": {
          "to": {"type": "string"},
          "subject": {"type": "string"},
          "body": {"type": "string"}
        },
        "required": ["to", "subject", "body"]
      }
    }
  }
]

Save this as tools.json and load it in the runner.

Wire the LLM client

Use the OpenAI SDK with a custom base URL. Pointing at n4n.ai’s OpenAI-compatible endpoint gives you 240+ models and automatic fallback when a provider is rate-limited or degraded, without changing your code.

import json
from openai import OpenAI
import os

client = OpenAI(
    base_url="https://api.n4n.ai/v1",
    api_key=os.environ["N4N_API_KEY"],
)
MODEL = "gpt-4o-mini"  # any model id supported by the gateway
tools = json.load(open("tools.json"))

If you prefer vanilla OpenAI, swap the base URL and key. The rest is identical.

Conversation loop

The loop appends user input, calls the model, executes any tool calls, feeds results back, and prints the final natural-language reply.

def run_assistant(calendar, gmail):
    messages = [{"role": "system",
                 "content": "You are a personal assistant with calendar and email access."}]
    while True:
        user = input("You: ")
        if user.lower() in ("exit", "quit"):
            break
        messages.append({"role": "user", "content": user})
        resp = client.chat.completions.create(
            model=MODEL, messages=messages, tools=tools, tool_choice="auto")
        msg = resp.choices[0].message
        if msg.tool_calls:
            for call in msg.tool_calls:
                fn = call.function.name
                args = json.loads(call.function.arguments)
                if fn == "list_events":
                    out = list_events(calendar, **args)
                    content = json.dumps(out)
                elif fn == "send_email":
                    out = send_email(gmail, **args)
                    content = json.dumps(out)
                messages.append({"role": "tool",
                                 "tool_call_id": call.id, "content": content})
            resp2 = client.chat.completions.create(model=MODEL, messages=messages)
            print("Assistant:", resp2.choices[0].message.content)
        else:
            print("Assistant:", msg.content)

Call run_assistant(*get_services()) from __main__.

Expected output at checkpoints

After authenticating, start the loop and try a query:

You: What's on my calendar today?
Assistant: You have 2 events: "Standup" at 09:30, "Design review" at 14:00.

Now trigger a send:

You: Email jane@corp.com about moving the design review to 15:00.
Assistant: I sent an email to jane@corp.com (message id 18c2f3a1b2).

The first exchange shows the model calling list_events and formatting the returned JSON. The second shows send_email executing against Gmail and the id returned to the model for confirmation.

Scope and error handling

Request the minimum OAuth scopes. Read-only calendar and compose-only mail mean a compromised token cannot delete events or read your entire inbox. The google-auth library refreshes expired tokens automatically as long as you persist token.json.

Handle API errors at the function boundary:

from googleapiclient.errors import HttpError

def list_events(calendar, max_results=5):
    try:
        # ... existing code ...
    except HttpError as e:
        return {"error": str(e)}

The LLM will surface the error string to the user instead of crashing the process. For production, add retry with exponential backoff on 429s from Google.

Extending the assistant

The personal AI assistant calendar email pattern generalizes to any side-effecting API. Add a create_event tool with write scope, or a search_email tool using Gmail filters. Because the LLM only sees tool schemas, you can swap the underlying provider without touching the prompt.

Stream the final response by passing stream=True to the second completion call if you want token-by-token output. For multi-step plans, let the model chain tool calls by looping until no tool_calls remain rather than doing a single second call.

Keep the function implementations pure and logged. You want an audit trail of what the assistant sent on your behalf.

Tagscalendaremailpersonal-assistanttutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All personal ai assistants posts →