Home / Articles

Python for a JS Dev: A Gmail and Calendar Agent With FastAPI and LangGraph

Learn Python by building a drafts-only personal assistant: FastAPI, Google OAuth for Gmail and Calendar, LangGraph tools, local Ollama models and SSE streaming.

Publisher: REACTAPP.TOP10 min read2243 words

This article is published in English.

Sources: Learn Python by Building an AI Agent That Reads Emails and Calendar

For a developer whose daily tools are Node.js, React, Express and Next, Python can feel like foreign territory, with different conventions and an ecosystem to learn from scratch. A good way across is to build something real and useful, and not a todo app or a calculator. This project is a personal assistant agent: an AI that connects to Gmail and Google Calendar, understands plain-English questions, and works out on its own what to do.

Ask it what emails arrived this week and it reads the inbox. Tell it to put a standup in tomorrow morning and a calendar event appears. Ask for a reply to a job rejection and it writes a draft, which it never sends directly, so a person always reviews first.

From RAG to agents

Retrieval-augmented generation lets a chatbot answer questions from your own documents instead of inventing things. Building one teaches how LLMs behave, how embeddings and vector databases such as ChromaDB work, and how to stream responses over server-sent events. Still, RAG is passive: it takes a question, searches documents and answers. Nothing happens in the world.

An agent doesn't stop at answering. It acts. The difference is easiest to see side by side:

RAG: Question → Search docs → Answer
Agent: Question → Think → Pick a tool → Use it → Think again → Answer
 ↑___________________________________|
 (loops until the task is done)

An agent reasons in a loop and holds a set of tools, which are functions it may call, and it decides by itself which one fits the request. A question about meetings sends it to the calendar tool. A request to draft a note to a manager sends it to the Gmail draft tool. A request that needs both makes it call both in sequence. The first time this works it feels a bit like magic.

During development the entire stack runs free and offline, using a local model, and a single environment variable swaps in Claude for production.

Environment setup

Always activate the virtual environment before installing or running anything, because forgetting is the most common source of confusion.

python3 -m venv venv
source venv/bin/activate  # Mac/Linux
# you'll see (venv) in your terminal — that means it's active
pip install fastapi uvicorn python-dotenv

The smallest FastAPI server

FastAPI plays the role Express plays in the JavaScript world. A Hello World looks like this:

from fastapi import FastAPI
from dotenv import load_dotenv

load_dotenv()  # reads your .env file

app = FastAPI()

@app.get("/")
def root():
    return {"message": "Agent is running!"}

@app.post("/ask")
def ask(body: dict):
    question = body.get("question")
    return {"answer": f"You asked: {question}"}

Start it with uvicorn:

uvicorn main:app --reload

The --reload flag is the counterpart of nodemon, restarting the server whenever a file changes. A quick request tests the endpoint:

curl -X POST http://localhost:8000/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "hello"}'

Connecting Gmail

Now things get real, because the agent talks to an actual Gmail account. The Google Cloud side comes first:

  1. Open the Google Cloud console and create a new project.
  2. Enable both the Gmail API and the Google Calendar API.
  3. Create OAuth credentials of the Desktop App type.
  4. Download the JSON file and save it as credentials.json.
  5. Under the OAuth consent screen, in the Audience section, add your own Gmail address as a test user.

Then install the Google client libraries:

pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client

The Gmail tool creates drafts only and never sends. An agent shouldn't hold the power to send mail on your behalf without a review step.

import os
import base64
import re
from email.mime.text import MIMEText
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/gmail.readonly',
    'https://www.googleapis.com/auth/gmail.compose',
]

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


def read_emails(max_results=5):
    service = get_gmail_service()
    results = service.users().messages().list(
        userId='me', maxResults=max_results, labelIds=['INBOX']
    ).execute()

    emails = []
    for msg in results.get('messages', []):
        message = service.users().messages().get(
            userId='me', id=msg['id'], format='full'
        ).execute()
        headers = message['payload']['headers']
        emails.append({
            'subject': next((h['value'] for h in headers if h['name'] == 'Subject'), ''),
            'from':    next((h['value'] for h in headers if h['name'] == 'From'), ''),
            'date':    next((h['value'] for h in headers if h['name'] == 'Date'), ''),
        })
    return emails


def create_draft(to: str, subject: str, body: str):
    # Extract clean email address from "Name <email>" format
    match = re.search(r'[\w\.-]+@[\w\.-]+\.\w+', to)
    if match:
        to = match.group(0)

    service = get_gmail_service()
    message = MIMEText(body)
    message['to'] = to
    message['subject'] = subject
    raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
    draft = service.users().drafts().create(
        userId='me', body={'message': {'raw': raw}}
    ).execute()
    return f"Draft created for {to} — subject: '{subject}'"

On the first run, a browser window asks you to sign in with Google and warns that the app isn't verified. Choose Advanced, then go to the app. That is normal for software still in development. The credentials land in token.json afterwards, so login happens only once.

One security rule is worth stating firmly: never commit token.json or credentials.json to git. GitHub's secret scanning will often block such a push, but it is better not to depend on that. Add the files to .gitignore right away:

token.json
credentials.json
.env
venv/
__pycache__/

Connecting Google Calendar

The pattern repeats from Gmail: authenticate, then call the API.

from datetime import datetime, timedelta
from googleapiclient.discovery import build
# ... (same auth setup as gmail_tool.py)

def get_upcoming_events(days=7, max_results=10):
    service = get_calendar_service()
    now = datetime.utcnow()
    future = now + timedelta(days=days)

    events = service.events().list(
        calendarId='primary',
        timeMin=now.isoformat() + 'Z',
        timeMax=future.isoformat() + 'Z',
        maxResults=max_results,
        singleEvents=True,
        orderBy='startTime'
    ).execute().get('items', [])

    return [{
        'title': e.get('summary', 'No title'),
        'start': e['start'].get('dateTime', e['start'].get('date')),
        'end':   e['end'].get('dateTime', e['end'].get('date')),
        'location': e.get('location', ''),
        'attendees': [a['email'] for a in e.get('attendees', [])],
    } for e in events]


def create_event(title, date, start_time, end_time, description='', attendees=[]):
    service = get_calendar_service()
    event = {
        'summary': title,
        'description': description,
        'start': {'dateTime': f'{date}T{start_time}:00', 'timeZone': 'America/Los_Angeles'},
        'end':   {'dateTime': f'{date}T{end_time}:00',   'timeZone': 'America/Los_Angeles'},
    }
    if attendees:
        event['attendees'] = [{'email': e} for e in attendees]

    created = service.events().insert(
        calendarId='primary', body=event, sendUpdates='none'
    ).execute()
    return f"Event '{title}' created on {date} from {start_time} to {end_time}"

The agent itself

LangGraph wires the pieces together. Install it with the model integrations:

pip install langgraph langchain-anthropic langchain-core langchain-ollama

The agent has four tools, which are ordinary Python functions that LangGraph can call automatically once they carry the tool decorator:

import os
from dotenv import load_dotenv
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from langgraph.prebuilt import create_react_agent
from gmail_tool import read_emails, create_draft
from calendar_tool import get_upcoming_events, create_event

load_dotenv()

# ── Dev/prod switch ───────────────────────────────────────────────
ENV = os.getenv("APP_ENV", "development")

if ENV == "production":
    from langchain_anthropic import ChatAnthropic
    llm = ChatAnthropic(model="claude-sonnet-4-6", api_key=os.getenv("ANTHROPIC_API_KEY"))
    print("🤖 Using Claude (production)")
else:
    from langchain_ollama import ChatOllama
    llm = ChatOllama(model="llama3.1", temperature=0)
    print("🤖 Using Ollama llama3.1 (development)")

# ── Tools (functions the agent can call) ─────────────────────────
@tool
def tool_read_emails(max_results: int = 5) -> str:
    """Read latest emails from Gmail."""
    emails = read_emails(max_results)
    if not emails:
        return "No emails found."
    return "\n---\n".join(
        f"From: {e['from']}\nSubject: {e['subject']}\nDate: {e['date']}"
        for e in emails
    )

@tool
def tool_create_draft(to: str, subject: str, body: str) -> str:
    """Create a Gmail draft. Args: to=email address, subject=subject line, body=email text."""
    return create_draft(to, subject, body)

@tool
def tool_get_events(days: int = 7) -> str:
    """Get upcoming Google Calendar events."""
    events = get_upcoming_events(days)
    if not events:
        return f"No events in the next {days} days."
    return "\n---\n".join(
        f"📅 {e['title']}\n   {e['start']} → {e['end']}"
        + (f"\n   📍 {e['location']}" if e['location'] else "")
        for e in events
    )

@tool
def tool_create_event(title: str, date: str, start_time: str, end_time: str, description: str = "", attendees: str = "") -> str:
    """Create a Google Calendar event. Date: YYYY-MM-DD. Time: HH:MM."""
    attendee_list = [a.strip() for a in attendees.split(",")] if attendees else []
    return create_event(title, date, start_time, end_time, description, attendee_list)

# ── Agent ─────────────────────────────────────────────────────────
agent = create_react_agent(
    model=llm,
    tools=[tool_read_emails, tool_create_draft, tool_get_events, tool_create_event],
    prompt="""You are a personal assistant. Use tools to help with emails and calendar.

Rules:
- ALWAYS use a tool to answer. Never guess.
- For emails: use tool_read_emails
- For drafts: use tool_create_draft
- For calendar: use tool_get_events
- For new events: use tool_create_event"""
)

def ask_agent(question: str) -> str:
    result = agent.invoke({"messages": [HumanMessage(content=question)]})
    return result['messages'][-1].content

Picking llama3.1 over llama3.2

Tool calling requires the model to emit structured JSON that LangGraph can parse, and smaller models struggle with that. The 3B llama3.2 kept returning raw JSON in place of really invoking the tool, whereas the 8B llama3.1 handles it dependably. For agents, model size counts for more than it does in plain chat.

Streaming the agent's reasoning

One of LangGraph's best features is the ability to watch an agent work through a task step by step: which tool it picked, what result came back and how it settled on an answer. Server-sent events push that stream to the frontend in real time. The relevant code sits in main.py:

# main.py
import json
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage
from agent import agent

app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

@app.get("/api/ask")
async def ask(q: str):
    def stream():
        for chunk in agent.stream(
            {"messages": [HumanMessage(content=q)]},
            stream_mode="updates"
        ):
            # Agent deciding which tool to use
            if "agent" in chunk:
                for msg in chunk["agent"].get("messages", []):
                    if hasattr(msg, "tool_calls") and msg.tool_calls:
                        for tc in msg.tool_calls:
                            yield f"data: {json.dumps({'type': 'thinking', 'text': f'Using tool: {tc[\"name\"]}...'})}\n\n"
                    elif hasattr(msg, "content") and msg.content:
                        yield f"data: {json.dumps({'type': 'answer', 'text': msg.content})}\n\n"

            # Tool result coming back
            if "tools" in chunk:
                for msg in chunk["tools"].get("messages", []):
                    if hasattr(msg, "content") and msg.content:
                        yield f"data: {json.dumps({'type': 'tool_result', 'text': str(msg.content)[:300]})}\n\n"

        yield f"data: {json.dumps({'type': 'done'})}\n\n"

    return StreamingResponse(stream(), media_type="text/event-stream")

In the interface, the user sees a sequence like this:

🧠 Using tool: tool_read_emails...
📦 Tool result ▼  (click to expand raw data)
💬 Here are your 5 latest emails...

Seeing the agent think out loud is a persuasive demo. A test script run against the local model prints the full trace:

python3 test_agent.py

🤖 Using Ollama llama3.1 (development)
🤖 Testing agent...

Test 1: Read emails
Here are your latest 3 emails:
1. From Bilt <notifications@members.bilt.com> — Your ALL Accor status match unlocks August 1st
2. From Vanguard <vanguard@...> — Your Vanguard site is getting an upgrade
3. From Kaggle <no-reply@kaggle.com> — Thank You for Making the 5-Day AI Agents Intensive a Success!

Test 2: Check calendar
You have a meeting scheduled for July 31st, 1pm-2pm.

Test 3: Create draft email
✅ Draft created for test@example.com — subject: 'Hello'

Test 4: Create calendar event
✅ Event 'Agent Test Meeting' created on 2026-07-31 from 14:00 to 15:00

The result uses real emails and real calendar events, with the agent choosing the right tool without help, all on a laptop with no internet connection.

Lessons worth keeping

  • Model size matters more than expected for agents. RAG copes with small models because it only reads context and summarizes. Tool calling needs valid structured JSON, and small models tend to hallucinate the format. The gap between an 8B and a 3B model is huge here.
  • Keep prompts short and explicit. A long block of careful instructions is mostly ignored by small local models, while about six lines with a bullet list of rules works far better.
  • Drafts-only is the right default. Making create_draft the sole email action keeps people in charge. An agent allowed to send mail autonomously is a liability, so let humans review before anything leaves.
  • Virtual environments punish forgetfulness. Whenever a ModuleNotFoundError appears, the cure is nearly always to activate the venv again.
  • Python is easier than it looks to a JavaScript developer. No semicolons, no curly braces, and structure comes from indentation. After about a week of daily use the language fades into the background and the problem takes center stage.

Running the finished project

The commands below clone the finished project and start it:

git clone https://github.com/TpPrachi/personal_assistant_agent.git
cd personal_assistant_agent

python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

# Pull the model (8B, better tool calling)
ollama pull llama3.1
ollama serve

# Set up Google credentials (see README for full steps)
# Then authenticate
python3 test_gmail.py

# Run the agent
python3 test_agent.py

Where to go next

  • A web search tool, so the agent can look things up online and summarize them.
  • Conversation history, because each question is currently stateless and session memory would allow natural follow-ups.
  • More tools, such as notes, reminders, Slack messages or GitHub issues. The recipe never changes: write a function, decorate it with @tool and add it to the agent.
  • A production deployment, with Docker, Claude instead of the local model and a host like Railway or Render.

Agents aren't magic. They are LLMs with functions they're allowed to call, and once that clicks, the whole field opens up. Building something is where the learning happens.