Le point de départ : une boîte que personne ne trie plus
Une boîte Gmail ouverte il y a des années finit en sédiment : notifications d’applications, newsletters jamais lues, promotions, et au milieu, les quelques mails qui comptent. Les filtres Gmail ne résolvent qu’une partie du problème, car ils supposent qu’on connaît l’expéditeur à l’avance.
Le 7 octobre 2026, je pose la première version d’un agent qui tourne dans le cluster. Il parcourt la boîte du plus récent au plus ancien (le rattrapage), puis reste en veille sur les nouveaux mails. Pour chaque mail, il doit répondre à une seule question : garder, archiver ou mettre à la corbeille ?
La tentation évidente, c’est de donner le mail à un LLM et de lui demander quoi faire. C’est exactement ce que je n’ai pas fait.
Le parti pris : le modèle classe, le code décide
Un modèle qui décide seul de jeter un mail, c’est une action irréversible prise par un composant probabiliste, sur des données personnelles, des milliers de fois. Même à 99 % de justesse, 1 % de 26 000, c’est 260 mails perdus, et on ne sait pas lesquels.
Le partage retenu :
- le LLM classe : il reçoit un lot de mails et rend, pour chacun, une catégorie (personnel, important, transactionnel, notification, newsletter, promotion, indésirable) et une confiance entre 0 et 1. Rien d’autre ;
- une politique déterministe (
policy.json, versionnée dans le dépôt) traduit la catégorie en action. Sous 0,7 de confiance, le mail est gardé et marqué « à vérifier ». La corbeille exige 0,85 ou plus, sinon c’est l’archive.
La politique est un fichier que je relis, que je modifie par un commit, et dont chaque version est datée. Le 8 octobre, par exemple, j’ai décidé que les reçus et confirmations de commande restaient dans la boîte au lieu d’être archivés : une ligne changée, transactionnel → keep, sans toucher au prompt.
policy.json, relu et versionné.
The model owns one gate out of four, and it leads to no action: it returns a label.
What happens to the mail is decided in policy.json, reviewed and versioned.
Les descriptions des catégories dans la politique servent aussi de prompt au modèle. Une seule source pour les deux : ce que le modèle lit et ce que le code applique ne peuvent pas diverger.
Ce qui n’atteint jamais le modèle
Le nœud triage passe avant tout appel au LLM. Sont écartés, gardés tels quels et jamais envoyés au fournisseur :
- les mails étoilés ;
- les mails qui portent un de mes libellés ;
- tous les fils où j’ai écrit (les fils du dossier « Envoyés » sont mémorisés au démarrage, puis suivis) ;
- les expéditeurs d’une liste blanche.
Pour les autres, le modèle ne voit que des métadonnées bornées : expéditeur, objet tronqué à 160 caractères, extrait fourni par Gmail tronqué à 240, âge, onglet Gmail et trois indicateurs (« liste », « automatique », « destinataire direct »). Jamais le corps, jamais une pièce jointe. Le client Gmail ne demande d’ailleurs que le format metadata pour classer : le corps n’est même pas téléchargé.
Deuxième économie : la mémoire des expéditeurs. Un expéditeur classé trois fois de suite dans la même catégorie (pour les catégories sans enjeu : notification, newsletter, promotion, transactionnel) ne repasse plus par le modèle.
Le graphe : une boucle qui survit à tout
L’agent est un graphe LangGraph, mais un graphe modeste :
START → init ─┬─▶ fetch_backfill ─┐ (rattrapage : 50 mails par tour)
├─▶ fetch_new ──────┤ (veille : historique Gmail)
│ ▼
│ triage ── protégé / déjà trié / expéditeur connu ──┐
│ ▼ │
│ classify (LLM, 25 mails par appel) │
│ ▼ ▼
│ archive_docs ─────────────────────────────────▶ apply ──▶ route
├─▶ retention ─▶ route
└─▶ wait_budget ─▶ route (budget du jour atteint : pause jusqu'à minuit)
L’intérêt de LangGraph ici n’est pas l’« agentivité » : c’est d’avoir un aiguillage explicite (route) et des nœuds testables un par un. Tout l’état durable vit dans SQLite sur un volume : curseurs, journal de chaque décision, mémoire des expéditeurs, coûts.
Deux règles rendent la boucle robuste. Gmail d’abord, journal ensuite, et les curseurs ne sont écrits qu’à la fin du lot : un plantage au milieu fait simplement relire le lot. Et un mail déjà journalisé ou portant un libellé Tri/… n’est jamais reclassé, donc jamais payé deux fois.
Le dry-run : quatre jours de libellés avant la première corbeille
L’agent démarre en dry-run. Dans ce mode, il ne fait qu’ajouter des libellés : Tri/<catégorie>, plus Tri/a-jeter ou Tri/a-archiver pour ce qu’il ferait. Rien n’est déplacé.
Il a tourné ainsi du 9 au 13 octobre, le temps du rattrapage complet. La relecture se fait dans Gmail, avec une recherche label:tri-a-jeter. Un mail qui ne doit pas partir ? On lui retire le libellé, ou on l’étoile. Ce geste vaut veto : au passage en apply, la commande qui applique les décisions accumulées relit chaque mail et respecte les vetos.
Le 13 octobre, la relecture terminée, je passe en apply. Le journal de décisions garde, pour chacune, si le mail était dans la boîte de réception et s’il était non lu : la commande undo sait tout remettre en place. Et la portée OAuth gmail.modify ne permet de toute façon aucune suppression définitive. Le pire cas, c’est la corbeille Gmail, récupérable 30 jours.
Le premier piège du réel : 26 000 décisions et un quota par minute
apply-pending doit appliquer d’un coup les 26 000 décisions prises pendant le rattrapage. Au deuxième lot, la commande s’arrête : Gmail répond « Quota exceeded » sur sa limite d’unités par minute et par utilisateur, le client a épuisé ses 31 secondes de réessais, et l’exception remonte.
La correction tient en deux lignes de principe :
- sur ce quota-là, attendre 65 secondes et reprendre le même lot. Rien n’est marqué comme appliqué avant la fin du lot, et les écritures Gmail sont idempotentes (poser un libellé déjà posé ne change rien). Reprendre un lot à moitié fait ne coûte donc rien ;
- une pause de 3 secondes entre deux lots, pour ne pas foncer droit dans le mur suivant.
Dans la boucle normale, le même quota ne compte plus comme une erreur et ne déclenche plus d’alerte : la boucle patiente deux minutes et repart.
Le deuxième piège : 150 PDF à minuit sur un NAS à 6 W
Le 9 octobre, une fonction s’ajoute : les PDF joints aux mails personnels, importants ou transactionnels sont déposés dans Paperless-ngx, sur le NAS, qui fait l’OCR, les titres et le rangement. Chaque pièce n’est déposée qu’une fois (empreinte SHA-256, plus la détection de doublons de Paperless). Un mail dont une pièce n’est pas encore archivée n’est jamais mis à la corbeille : il est archivé à la place.
Le plafond était de 150 dépôts par jour. Le compteur se remettait à zéro à minuit, et toutes les pièces reportées repartaient en même temps. Résultat, vu dans Grafana : une rafale de 150 dépôts à 00 h 06, Paperless entre 100 et 380 % de CPU, et un processeur N100 saturé jusqu’à 1 h 30.
Le correctif : un second plafond, glissant, de 12 dépôts par heure. Même débit quotidien, mais étalé. Au 14 octobre, le compteur affiche 371 documents archivés, 28 doublons et 1 956 pièces exclues : billets de train, suivis de colis, conditions générales et autres pièces standard sans valeur, filtrées par domaine ou par nom de fichier.
Le choix du modèle : pas par la couche « compatible OpenAI »
Le code parle d’abord à n’importe quelle API compatible OpenAI. Le 8 octobre, je bascule sur Claude Haiku 5.5. La couche de compatibilité OpenAI d’Anthropic ne convient pas à ce modèle : temperature différente de 1 refusée, réflexion comptée dans max_tokens, response_format ignoré. Le client gagne donc un second fournisseur, l’API Messages native, toujours en bibliothèque standard Python. Un refus du classifieur de sécurité (stop_reason: refusal) laisse simplement le lot « à vérifier ».
Le coût mesuré à la fin du rattrapage : 604 appels, 1,41 million de jetons en entrée, 0,31 million en sortie, 0,295 $ au total. Un appel classe au plus 25 mails, donc au plus 15 100 mails sont passés par le modèle. Plus de 10 000 décisions ont donc été prises sans lui, par la mémoire des expéditeurs ou les règles de protection. La veille coûte aujourd’hui quelques dixièmes de centime par jour. Un budget journalier (1 $) met l’agent en pause jusqu’à minuit s’il est atteint, avec un message Telegram.
Un déploiement sans image
Dernier choix, plus infra : il n’y a pas d’image à construire. Le pod part de python:3.12-slim (multi-arch), le code et la politique arrivent par une ConfigMap générée par un script, et un initContainer installe les dépendances dans le volume, une fois par version du verrou :
pip install --no-cache-dir --require-hashes --only-binary=:all: \
--target "/state/deps/<hash-du-verrou>" -r requirements.txt
Les hashes sont vérifiés, seules les roues binaires sont acceptées. La somme de contrôle de la ConfigMap est inscrite dans le Deployment, donc modifier le code redémarre le pod. Un test échoue tant que la ConfigMap n’a pas été régénérée. Pas de registre, pas de secret de pull, pas de CI de build : pour un agent d’environ 2 200 lignes de Python, c’est autant de pièces en moins.
Le pod lui-même ne tourne pas en root, sa racine est en lecture seule, il n’a aucune capacité ni jeton de compte de service, et une NetworkPolicy refuse toute connexion entrante sauf le scrape des métriques. La suite compte 132 tests, avec un faux Gmail et un faux LLM, jusqu’au graphe de bout en bout.
Ce que je retiens
- Séparer classer et décider. Le modèle rend une étiquette et une confiance. Une politique lisible, versionnée, décide de l’action. On corrige une décision par un commit, pas par un prompt.
- Filtrer avant le modèle, pas après. Ce qui est sensible n’est jamais envoyé. C’est plus sûr que de demander au modèle d’être prudent.
- Un dry-run qui écrit dans l’outil de l’utilisateur. Des libellés dans Gmail se relisent là où on lit déjà ses mails, et le veto se fait d’un geste.
- Tout journaliser pour pouvoir tout annuler. Avec
undoet l’absence de suppression définitive, l’erreur est acceptable. - Les quotas par minute ne sont pas des erreurs. Il faut les attendre, avec des lots idempotents qu’on peut reprendre à mi-chemin.
- Lisser les plafonds. Un plafond quotidien qui se remet à zéro à minuit crée une rafale à minuit.