Python pour un développeur JS : un agent Gmail et Calendrier avec FastAPI et LangGraph
Apprenez Python en créant un assistant personnel fonctionnant uniquement en version bêta : FastAPI, Google OAuth pour Gmail et Calendar, des outils LangGraph, des modèles locaux Ollama et une diffusion en temps réel SSE.
Sources: Learn Python by Building an AI Agent That Reads Emails and Calendar
Pour un développeur dont les outils quotidiens sont Node.js, React, Express et Next, Python peut sembler être un territoire inconnu, avec des conventions différentes et un écosystème qu’il faut apprendre de zéro. Une bonne façon de s’y faire est de créer quelque chose de réel et d’utile, et non pas une application de liste de tâches ou une calculatrice. Ce projet consiste en un agent assistant personnel : une IA qui se connecte à Gmail et au Google Calendar, comprend les questions en langue simple et décide elle-même de ce qu’il faut faire.
Demandez-lui quels e-mails sont arrivés cette semaine et elle lit votre boîte de réception. Dites-lui d’organiser une réunion le lendemain matin et une entrée dans le calendrier apparaît. Demandez-lui de rédiger une réponse à un refus d’emploi et elle crée un brouillon, qu’elle n’envoie jamais directement, afin que quelqu’un puisse toujours le relire en premier.
De RAG aux agents
La génération augmentée par récupération permet à un chatbot de répondre aux questions à partir de vos propres documents plutôt que d’inventer des réponses. En en créant un, on apprend comment se comportent les LLM, comment fonctionnent les embeddings et les bases de données vectorielles comme ChromaDB, ainsi que comment diffuser des réponses via des événements envoyés par le serveur. Cependant, la RAG est passive : elle reçoit une question, cherche dans les documents et répond. Rien ne se passe dans le monde réel.
Un agent ne se contente pas de répondre. Il agit. La différence est la plus facile à voir côte à côte :
RAG: Question → Search docs → Answer
Agent: Question → Think → Pick a tool → Use it → Think again → Answer
↑___________________________________|
(loops until the task is done)
Un agent raisonne en boucle et dispose d’un ensemble d’outils, qui sont des fonctions qu’il peut appeler, et il décide lui-même laquelle convient à la demande. Une question concernant des réunions l’envoie vers l’outil de calendrier. Une demande de rédiger une note pour un manager l’envoie vers l’outil de brouillon de Gmail. Une demande nécessitant les deux le pousse à les appeler l’un après l’autre. La première fois que cela fonctionne, on a l’impression d’un peu de magie.
Pendant le développement, toute la stack fonctionne gratuitement et hors ligne, en utilisant un modèle local, et une seule variable d’environnement remplace Claude pour le mode production.
Préparation de l’environnement
Activez toujours l’environnement virtuel avant d’installer ou d’exécuter quoi que ce soit, car l’oubli est la cause la plus fréquente de 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
Le plus petit serveur FastAPI
FastAPI remplit le même rôle qu’Express dans le monde du JavaScript. Un exemple de « Hello World » se présente comme ceci :
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}"}
Démarrez-le avec uvicorn :
uvicorn main:app --reload
Le flag --reload est l’équivalent de nodemon : il redémarre le serveur chaque fois qu’un fichier est modifié. Une requête rapide permet de tester l’endpoint :
curl -X POST http://localhost:8000/ask \
-H "Content-Type: application/json" \
-d '{"question": "hello"}'
Connexion à Gmail
Voici la partie importante, car l’agent va interagir avec un compte Gmail réel. Commençons par la partie Google Cloud :
- Ouvrez la console Google Cloud et créez un nouveau projet.
- Activez à la fois l’API Gmail et l’API Google Calendar.
- Créez des identifiants OAuth de type Application de bureau.
- téléchargez le fichier JSON et enregistrez-le sous le nom
credentials.json. - Dans l’écran de consentement OAuth, dans la section Public cible, ajoutez votre propre adresse Gmail en tant qu’utilisateur de test.
Ensuite, installez les bibliothèques clients Google :
pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client
Outil Gmail : il ne crée que des brouillons et n’envoie jamais de courriels. Un agent ne devrait pas avoir le pouvoir d’envoyer des e-mails à votre place sans une étape de vérification.
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}'"
Lors de la première exécution, une fenêtre du navigateur vous demande de vous connecter avec Google et vous avertit que l’application n’a pas été vérifiée. Choisissez « Avancé », puis accédez à l’application. C’est normal pour un logiciel encore en développement. Les identifiants sont ensuite stockés dans token.json, de sorte que la connexion n’a lieu qu’une seule fois.
Il convient de rappeler fermement une règle de sécurité : ne commitez jamais token.json ou credentials.json sur git. Le système de détection des secrets de GitHub bloque souvent de telles mises à jour, mais il vaut mieux ne pas compter dessus. Ajoutez ces fichiers à .gitignore immédiatement :
token.json
credentials.json
.env
venv/
__pycache__/
Connexion au calendrier Google
Le processus est identique à celui de Gmail : s’authentifier, puis appeler l’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}"
L’agent lui-même
LangGraph relie les différentes composantes entre elles. Installez-le avec les intégrations de modèles :
pip install langgraph langchain-anthropic langchain-core langchain-ollama
L’agent dispose de quatre outils, qui sont des fonctions Python ordinaires que LangGraph peut appeler automatiquement une fois qu’elles portent le décorateur d’outil :
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
Préférer llama3.1 à llama3.2
Les appels d’outils exigent que le modèle génère du JSON structuré que LangGraph puisse parser, et les modèles plus petits ont du mal à le faire. Le modèle de 3 milliards de paramètres llama3.2 renvoyait constamment du JSON brut au lieu d’appeler réellement l’outil, tandis que le modèle de 8 milliards de paramètres llama3.1 gère cette tâche de manière fiable. Pour les agents, la taille du modèle est plus importante que dans une simple conversation.
Diffusion en continu du raisonnement de l’agent
L’une des meilleures fonctionnalités de LangGraph est la possibilité d’observer un agent accomplir une tâche étape par étape : quel outil il a choisi, quel résultat est revenu et comment il a abouti à une réponse. Les événements envoyés par le serveur diffusent ce flux en temps réel vers l’interface utilisateur. Le code correspondant se trouve dans 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")
Dans l’interface, l’utilisateur voit une séquence comme celle-ci :
🧠 Using tool: tool_read_emails...
📦 Tool result ▼ (click to expand raw data)
💬 Here are your 5 latest emails...
Voir l’agent réfléchir à voix haute constitue une démonstration convaincante. Un script de test exécuté sur le modèle local affiche le suivi complet :
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
Le résultat utilise des e-mails réels et des événements de calendrier réels, l’agent choisissant l’outil approprié sans aide, tout cela sur un ordinateur portable sans connexion Internet.
Leçons à retenir
- La taille du modèle est plus importante que prévu pour les agents. RAG fonctionne bien avec de petits modèles car il se contente de lire le contexte et de le résumer. Les appels d’outils nécessitent un JSON structuré valide, et les petits modèles ont tendance à générer des formats erronés. L’écart entre un modèle de 8 milliards de paramètres et un modèle de 3 milliards est considérable dans ce cas.
- Privilégiez des prompts courts et explicites. Un long bloc d’instructions détaillées est généralement ignoré par les petits modèles locaux, tandis qu’environ six lignes accompagnées d’une liste à puces de règles fonctionnent bien mieux.
- Le mode brouillon doit être la configuration par défaut. En faisant de
create_draftl’unique action permettant d’envoyer un e-mail, on garde le contrôle en main. Un agent autorisé à envoyer des e-mails de manière autonome représente un risque ; il faut donc laisser les humains examiner le contenu avant toute diffusion. - Les environnements virtuels pénalisent l’oubli. Chaque fois qu’apparaît une erreur
ModuleNotFoundError, la solution est presque toujours de réactiver l’envoi virtuel. - Python est plus simple qu’il n’y paraît pour un développeur JavaScript. Pas de points-virgules, pas de crochets, et la structure provient de l’indentation. Après environ une semaine d’utilisation quotidienne, le langage passe au second plan tandis que le problème devient central.
Exécution du projet finalisé
Les commandes ci-dessous clonent le projet finalisé et le lancent :
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
Où aller ensuite
- Un outil de recherche sur le Web, afin que l’agent puisse chercher des informations en ligne et les résumer.
- Un historique des conversations, car chaque question est actuellement sans état, et la mémoire de session permettrait des suites naturelles.
- D’autres outils, tels que des notes, des rappels, des messages Slack ou des issues GitHub. La recette reste toujours la même : écrire une fonction, la décorer avec
@toolet l’ajouter à l’agent. - Un déploiement en production, avec Docker, Claude à la place du modèle local, et un hébergeur comme Railway ou Render.
Les agents ne sont pas magiques. Ce sont des LLM disposant de fonctions qu’ils peuvent appeler, et une fois cela fonctionnel, tout le domaine s’ouvre. C’est en construisant quelque chose que l’apprentissage a lieu.