Startseite / Artikel

Python für JS-Entwickler: Ein Gmail- und Kalender-Agent mit FastAPI und LangGraph

Lernen Sie Python, indem Sie einen persönlichen Assistenten nur für Entwürfe entwickeln: FastAPI, Google OAuth für Gmail und Kalender, LangGraph-Tools, lokale Ollama-Modelle sowie SSE-Streaming.

Verlag: REACTAPP.TOP10 Min. Lesezeit2243 Wörter

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

Für Entwickler, deren tägliche Werkzeuge Node.js, React, Express und Next sind, kann Python wie fremdes Gebiet erscheinen – mit anderen Konventionen und einem Ökosystem, das von Grund auf erlernt werden muss. Ein guter Weg, damit zurechtzukommen, besteht darin, etwas Reales und Nützliches zu entwickeln, anstatt nur eine To-Do-App oder einen Rechner. Dieses Projekt ist ein persönlicher Assistenten-Agent: eine KI, die mit Gmail und Google Calendar verbunden ist, Fragen in einfachem Englisch versteht und selbst entscheidet, was zu tun ist.

Fragen Sie ihn, welche E-Mails diese Woche eingegangen sind, und er liest den Posteingang durch. Befehlen Sie ihm, für morgen Morgen ein Standup-Treffen einzutragen, und ein Kalendereintrag erscheint. Bitten Sie um einen Entwurf für eine Antwort auf eine Ablehnung bei einer Stellensuche – er erstellt einen Entwurf, den er jedoch niemals direkt sendet, sodass immer eine Person ihn zuerst überprüft.

Von RAG zu Agenten

Retrieval-augmented Generation ermöglicht es einem Chatbot, Fragen aus Ihren eigenen Dokumenten zu beantworten, anstatt Dinge zu erfinden. Die Entwicklung eines solchen Systems lehrt, wie LLMs sich verhalten, wie Embeddings und Vektordatenbanken wie ChromaDB funktionieren sowie wie Antworten über Server-Sent Events übertragen werden. Dennoch ist RAG passiv: Es nimmt eine Frage entgegen, durchsucht Dokumente und antwortet. In der Realität passiert nichts weiter.

Ein Agent beschränkt sich nicht auf das Beantworten. Er handelt. Der Unterschied ist am leichtesten nebeneinander zu erkennen:

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

Ein Agent handelt in Schleifen und verfügt über eine Reihe von Werkzeugen, also Funktionen, die er aufrufen kann, und entscheidet selbst, welche für eine Anfrage geeignet ist. Eine Frage zu Terminen leitet ihn zum Kalenderwerkzeug. Eine Anfrage, einen Entwurf für einen Manager zu erstellen, leitet ihn zum Gmail-Entwurfswerkzeug. Eine Anfrage, die beides erfordert, lässt ihn beide nacheinander aufrufen. Beim ersten Mal, wenn das funktioniert, wirkt es fast wie Magie.

Während der Entwicklung läuft der gesamte Stack kostenlos und offline unter Verwendung eines lokalen Modells, und eine einzige Umgebungsvariable ersetzt Claude in der Produktionsumgebung.

Umgebungs einrichten

Aktivieren Sie immer die virtuelle Umgebung, bevor Sie etwas installieren oder ausführen, denn Vergessen ist die häufigste Quelle für Verwirrung.

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

Der kleinste FastAPI-Server

FastAPI übernimmt die Rolle, die Express in der JavaScript-Welt einnimmt. Ein Hello World sieht so aus:

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}"}

Starten Sie es mit uvicorn:

uvicorn main:app --reload

Die Flagge --reload ist das Gegenstück zu nodemon und startet den Server erneut, sobald sich eine Datei ändert. Eine schnelle Anfrage prüft den Endpunkt:

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

Gmail verbinden

Nun wird es ernst, denn der Agent kommuniziert mit einem echten Gmail-Konto. Zuerst geht es um die Google Cloud-Seite:

  1. Öffnen Sie die Google Cloud Konsole und erstellen Sie ein neues Projekt.
  2. Aktivieren Sie sowohl die Gmail API als auch die Google Calendar API.
  3. Erstellen Sie OAuth-Zertifikate vom Typ Desktop App.
  4. Herunterladen Sie die JSON-Datei und speichern Sie sie als credentials.json.
  5. Unter dem OAuth-Zustimmungsbildschirm fügen Sie in der Abschnitt „Audience“ Ihre eigene Gmail-Adresse als Testnutzer hinzu.

Dann installieren Sie die Google-Client-Bibliotheken:

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

Das Gmail-Tool erstellt nur Entwürfe und sendet niemals. Ein Agent sollte nicht die Befugnis haben, im Namen des Benutzers E-Mails zu senden, ohne dass dies zuvor überprüft wird.

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}'"

Beim ersten Start bittet ein Browserfenster Sie, sich mit Google anzumelden, und warnt, dass die App noch nicht überprüft wurde. Wählen Sie „Erweitert“ und wechseln Sie anschließend zur App. Das ist bei Software, die sich noch in der Entwicklung befindet, normal. Die Anmeldedaten werden danach in token.json gespeichert, sodass sich der Benutzer nur einmal anmelden muss.

Eine Sicherheitsregel sollte unbedingt klar formuliert werden: Übergeben Sie token.json oder credentials.json auf keinen Fall an git. GitHubs Geheimnis-Scanning blockiert solche Uploads oft, aber es ist besser, sich nicht darauf zu verlassen. Fügen Sie die Dateien umgehend zu .gitignore hinzu:

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

Verbindung mit dem Google-Kalender

Das Muster wiederholt sich wie bei Gmail: Authentifizierung und anschließende Aufrufung der 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}"

Der Agent selbst

LangGraph verbindet die Komponenten miteinander. Installieren Sie es zusammen mit den Modell-Integrationen:

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

Der Agent verfügt über vier Werkzeuge, die gewöhnliche Python-Funktionen sind, die von LangGraph automatisch aufgerufen werden können, sobald sie den Werkzeug-Decorator tragen:

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

Warum llama3.1 statt llama3.2 gewählt wird

Die Aufruffunktion erfordert, dass das Modell strukturierte JSON-Daten generiert, die LangGraph parsen kann – kleinere Modelle haben damit Schwierigkeiten. Das 3B-Modell llama3.2 gab stets rohes JSON zurück, anstatt tatsächlich das Tool aufzurufen, während das 8B-Modell llama3.1 diese Aufgabe zuverlässig bewältigt. Bei Agenten spielt die Modellgröße eine wichtigere Rolle als im normalen Chat.

Streamen der Überlegungen des Agenten

Eine der besten Funktionen von LangGraph ist die Möglichkeit, zu beobachten, wie ein Agent Schritt für Schritt eine Aufgabe bearbeitet: welches Tool er gewählt hat, welches Ergebnis zurückkam und wie er zu einer Antwort gelangte. Server-sent Events übertragen diesen Stream in Echtzeit an die Frontend-Seite. Der entsprechende Code befindet sich 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 der Benutzeroberfläche sieht der Benutzer eine solche Abfolge:

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

Es ist eine überzeugende Demonstration, zu sehen, wie der Agent laut nachdenkt. Ein Testskript, das gegen das lokale Modell ausgeführt wird, gibt den vollständigen Ablauf aus:

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

Das Ergebnis verwendet echte E-Mails und echte Kalendereinträge, wobei der Agent ohne Hilfe das richtige Werkzeug auswählt – und das alles auf einem Laptop ohne Internetverbindung.

Lektionen, die man beibehalten sollte

  • Die Größe des Modells ist für Agenten wichtiger, als man erwarten würde. RAG kommt mit kleinen Modellen zurecht, da es nur den Kontext liest und zusammenfasst. Der Aufruf von Werkzeugen erfordert gültiges, strukturiertes JSON, und kleine Modelle neigen dazu, das Format falsch darzustellen. Der Unterschied zwischen einem 8B- und einem 3B-Modell ist in diesem Punkt enorm.
  • Halten Sie die Anweisungen kurz und präzise. Ein langer Text mit ausführlichen Anweisungen wird von kleinen lokalen Modellen meist ignoriert, während etwa sechs Zeilen mit einer Aufzählung von Regeln deutlich besser funktionieren.
  • Der Standard sollte ausschließlich Entwürfe sein. Wenn create_draft die einzige E-Mail-Aktion bleibt, behalten die Menschen die Kontrolle. Ein Agent, dem die Möglichkeit gegeben wird, selbstständig E-Mails zu senden, stellt ein Risiko dar – daher sollten Menschen alles vor dem Versand überprüfen.
  • Virtualisiertere Umgebungen bestrafen Vergesslichkeit. Immer wenn ein ModuleNotFoundError auftritt, besteht die Lösung fast immer darin, die venv erneut zu aktivieren.
  • Python ist für JavaScript-Entwickler einfacher, als es scheint. Es gibt keine Semikolons, keine geschweiften Klammern, und die Struktur ergibt sich aus der Einrückung. Nach etwa einer Woche täglicher Nutzung rückt die Sprache in den Hintergrund, während das Problem in den Vordergrund tritt.

Ausführung des fertigen Projekts

Die untenstehenden Befehle klonen das fertige Projekt und starten es:

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

Was kommt als Nächstes?

  • Ein Suchwerkzeug im Web, damit der Agent Dinge online suchen und zusammenfassen kann.
  • Ein Gesprächsverlauf, da jede Frage derzeit zustandslos ist und eine Sitzungserinnerung natürliche Fortsetzungen ermöglichen würde.
  • Weitere Tools wie Notizen, Erinnerungen, Slack-Nachrichten oder GitHub-Issues. Das Rezept bleibt immer dasselbe: Schreiben Sie eine Funktion, dekorieren Sie sie mit @tool und fügen Sie sie dem Agenten hinzu.
  • Eine Produktivumgebung mit Docker, Claude anstelle des lokalen Modells sowie einem Host wie Railway oder Render.

Agenten sind kein Zauber. Es handelt sich dabei um LLMs mit Funktionen, die sie aufrufen dürfen, und sobald das klappt, öffnet sich das gesamte Feld. Das Erlernen findet beim Aufbau von etwas statt.