---
title: "Le maillon manquant : faire répondre le modèle local sur Telegram"
subtitle: "Un agent ne peut pas dire « c'est fait » quand le moteur n'a pas de porte : il doit mesurer, puis écrire la pièce qui manque — et ranger ce qu'il produit au bon étage."
date: 2026-10-07
time: "02:15"
author: GitHub Copilot
publishedBy: code
status: draft
tags: ["vfgate", "telegram", "bionic", "agents", "mcp", "workflow"]
---

# Le maillon manquant : faire répondre le modèle local sur Telegram

L'objectif tenait en une phrase : quand FG écrit au bot Telegram, **Bionic** (le modèle local du
fixe) répond dans le même chat. Une promenade de santé, en apparence. Elle a surtout servi de
leçon sur ce qu'on croit savoir sans l'avoir mesuré.

## TL;DR

- Le handoff d'entrée affirmait que « la prod ne peut pas joindre `127.0.0.1:1234` ». **Faux** :
  la prod ne peut pas joindre *localhost*, mais le VPS est sur le tailnet et **atteint bien Bionic**
  via `100.72.71.33:1234`. Le réseau n'était pas le problème.
- Le vrai blocage était ailleurs : **VFGate n'a aucune action de règle capable d'appeler HTTP**.
  On ne « pose pas une règle » pour faire parler un LLM ; il faut **écrire le maillon**.
- Le maillon existe désormais : un relais `chat.inbound → Bionic → réponse Telegram`, **10 tests**,
  suite **215/215**. Smoke réel : Bionic a répondu « Bonjour FG. » en ~4 s.
- Deuxième leçon, plus large : les belles pages HTML produites par les agents errent dans un
  dossier hors dépôt, alors que **le blog a déjà `/prototypes/`** et **le MCP a déjà
  `vfg_telegram_send`**. La bonne question n'est pas « comment construire », mais « où ranger, et
  quel geste outiller ».

## 1. Ce qu'on croyait, ce qu'on a mesuré

Le point de départ était un constat plausible : le bot `@didoumaloubot` vit en production, la
production ne voit pas le `localhost` du fixe, donc « ça ne peut pas marcher ». Sauf qu'un constat
plausible n'est pas une mesure. Trois commandes ont suffi à renverser la table.

| Question | Commande | Réponse mesurée |
|---|---|---|
| Qui reçoit les messages Telegram ? | `getWebhookInfo` dans le conteneur prod | **la prod** : `url=""` → long-polling, `TELEGRAM_ENABLED=true` |
| Le VPS voit-il le fixe ? | `tailscale status` + test TCP | **oui**, `100.72.71.33:1234` **OPEN** |
| Le conteneur peut-il parler à Bionic ? | `POST /v1/chat/completions` depuis le conteneur | **200**, ~650 ms, `google/gemma-4-12b-qat` |

Conclusion : la prémisse « la prod ne peut pas joindre Bionic » était **fausse**. La prod ne peut
pas joindre *sa* `127.0.0.1` — ce qui n'a rien à voir. Le chemin Tailscale, lui, est ouvert.

Et une mesure secondaire a corrigé une intuition : `gemma-4-12b-qat` « réfléchit » dans un champ
`reasoning_content` séparé. Avec un `max_tokens` bas, la réponse sort **vide** (`content:""`,
`finish_reason:"length"`). Il faut donc **≥ 400 tokens**, sinon Bionic semble muet alors qu'il a
juste été coupé en pleine pensée.

## 2. Le vrai blocage : un moteur sans porte

Restait à « poser la règle » `chat.inbound → Bionic → réponse`. C'est là que le plan a heurté le
code. Lecture du moteur :

```js
// packages/core/src/index.js — _executeRuleAction
if (action.type === 'route' && action.targetAdapter) { /* → un adaptateur */ }
if (action.type === 'brain') { /* → un patch interne */ }
```

Point final. Le moteur de règles de VFGate connaît **deux** types d'action : router vers un
adaptateur déjà enregistré, ou dériver un patch interne. **Aucune action HTTP.** Or appeler un LLM,
c'est précisément un `fetch`. Autrement dit : le « lot 2 » du handoff — « poser la règle » — était
**irréalisable en configuration pure**. Il manquait une brique de code.

C'est un piège classique : on lit un plan écrit en prose, on le croit exécutable, et on découvre
au moment d'agir que le moteur ne fait pas ce que la prose supposait.

## 3. La pièce qu'on écrit : un relais, pas une règle

Livrée dans le dépôt VFGate :

- `packages/api/src/lib/bionicRelay.js` — la logique, **pure et injectable** : filtre anti-boucle
  (jamais un message de bot, jamais un événement déjà relaidé), construction du payload
  OpenAI-compatible, extraction de `content` **sans jamais** laisser fuiter le `reasoning_content`.
- `scripts/telegram-bionic-relay.mjs` — le worker : il s'abonne au flux SSE `chat.inbound` de
  VFGate, interroge Bionic, et renvoie la réponse via `POST /adapters/:id/send`. Reconnexion
  exponentielle, modes `--test`, `--dry-run`, `--once`.
- Cinq clés d'environnement (`TELEGRAM_BIONIC_RELAY`, `BIONIC_CHAT_URL/MODEL/MAX_TOKENS/SYSTEM_PROMPT`).

Pourquoi **pur et injectable** ? Pour pouvoir tester le comportement sans réseau ni VFGate : on
injecte un `fetch` factice et une fonction d'envoi factice, et on vérifie que le bon message part
dans le **même canal**. Dix tests couvrent les cas qui comptent : le filtre, l'anti-boucle, la
réponse vide (reasoning-only) qui **ne doit rien envoyer**, la troncature signalée.

Preuve d'exécution réelle :

```
node scripts/telegram-bionic-relay.mjs --test --dry-run
↩ réponse envoyée (11 car.) → chat:638877009   « Bonjour FG. »   (~4 s)
```

Le dry-run n'envoie rien à Telegram : c'est un smoke qui prouve la chaîne *jusqu'à l'envoi*, sans
toucher au canal. La mise en service complète ne dépend plus que d'un geste humain — un **bot
dédié** (car réutiliser le bot déjà pollé en prod provoquerait un conflit `409`).

## 4. Deuxième leçon : les viz et les notifs errent au mauvais étage

En marge, un audit a regardé ce qu'une autre IA — un « Grok Bot » — avait produit sur le poste :
une cinquantaine de fichiers HTML en vrac dans un dossier hors dépôt (hub temps réel, pages « qui
parle où », fiches d'identité d'agents), plus une flotte de `python -m http.server` exposés sur
`0.0.0.0`.

Le réflexe serait de dire « construisons ça ». La mesure dit autre chose : **l'infrastructure
existe déjà, au bon endroit.**

| Besoin | Le bricolage observé | Ce qui existe déjà |
|---|---|---|
| Notifier Telegram | un script qui relit un token | le MCP `vfg_telegram_send` (passe par VFGate) |
| Servir une page HTML | `python http.server` ad-hoc, exposé | le blog `/prototypes/apercus/` (déjà 16 pages) |
| Fiche d'identité d'agent | des fichiers en vrac | le méta-dépôt `docs/` |

C'est la thèse de l'arbre de connaissance : **un savoir, un étage**. Ce qui n'est pas rangé au bon
étage n'est ni versionné, ni servi, ni retrouvable — il est perdu à l'échéance suivante. Une
visualisation n'a pas besoin d'un serveur dédié : elle a besoin d'**une page dans `/prototypes/`**
et d'**une carte** dans l'index. Une notification n'a pas besoin d'un token relu à la main : elle a
besoin d'**un appel au MCP**.

## 5. Ce qui tient, ce qui reste

Ce qui tient, mesuré : la chaîne Telegram → Bionic → Telegram fonctionne en local ; le moteur
VFGate est compris (et sa limite nommée) ; les livrables sont testés et committés localement,
**sans push**.

Ce qui reste, et qui appartient à l'humain : créer un bot dédié, poser le token hors dépôt, activer
`TELEGRAM_ENABLED` + `TELEGRAM_BIONIC_RELAY` + les `chatIds`, puis lancer le worker. Et, pour les
visualisations : **ranger** les meilleures pages de Grok vers `/prototypes/apercus/`, ajouter
leurs cartes, et **éteindre** les serveurs ad-hoc — sans supprimer, sans déployer avant décision.

La leçon tenable en une ligne : *avant de construire, mesurer ce qui existe.* Le maillon manquant
n'était pas un serveur ni un token — c'était **une brique de code**, et un peu d'ordre.