Dans ce tutoriel, on construit un agent personnel dans l'esprit d'OpenClaw : tu lui écris sur Telegram, il fait le travail sur un ordinateur (Chrome, terminal, fichiers) et te répond dans la conversation. Pendant qu'il travaille, un message de progression se met à jour ; /screen t'envoie une capture de son écran, /stop l'arrête. La différence avec un OpenClaw auto-hébergé : l'agent et son ordinateur tournent chez Baguette Bot, et ton petit programme n'ouvre aucun port. Le tout tient dans un seul fichier bot.js, sans dépendance.
Ce qu'on construit
OpenClaw est un agent IA personnel open source, sous licence MIT : tu l'installes sur ta machine ou un serveur, tu branches ta propre clé d'API de modèle, et tu lui parles depuis WhatsApp, Telegram, Signal ou Discord. Il lit, écrit, navigue et lance des commandes pour toi. Sa contrepartie, détaillée dans notre article alternative à OpenClaw : c'est toi qui héberges, mets à jour et sécurises l'ensemble.
On ne reprend ni son code ni sa marque. On construit ton propre agent Telegram sur la même idée, avec l'API Baguette Bot pour l'agent et son ordinateur :
- un agent par conversation Telegram, retrouvé grâce à
metadata.telegram_chat_idet noté dans un fichieragents.json; - la progression en direct : un message « En cours » modifié au fil des événements de l'agent, puis la réponse finale ;
/screen: une capture d'écran de son ordinateur, envoyée comme photo ;/stop: arrête la tâche en cours ;- une liste d'accès : seuls les identifiants Telegram que tu autorises peuvent lui parler.
Ce qu'il te faut
- Node 18 ou plus (
fetch,FormDataetBlobsont intégrés). - Une clé API Baguette Bot, créée dans la console. Elle commence par
bb_live_. - Un bot Telegram : écris à @BotFather dans Telegram, commande
/newbot, et garde le jeton qu'il te donne. - Environ 1 € de budget de test : le solde prépayé commence avec 1 € offert une fois ton e-mail vérifié.
mkdir mon-claw && cd mon-claw
npm init -y && npm pkg set type=moduleAucun npm i : on parle à l'API Telegram avec fetch, en long polling (getUpdates). On aurait pu utiliser grammy ou telegraf, mais trois méthodes suffisent : getUpdates, sendMessage et sendPhoto (plus editMessageText pour la progression).
Le bot complet
// bot.js (Node 18+, aucune dépendance)
import { readFile, writeFile } from "node:fs/promises";
const KEY = process.env.BAGUETTE_API_KEY;
const BASE = "https://api.baguettebot.com";
const TG = `https://api.telegram.org/bot${process.env.TELEGRAM_TOKEN}`;
const ALLOWED = new Set((process.env.ALLOWED_USERS ?? "").split(",").map((s) => s.trim()).filter(Boolean));
if (!KEY || !process.env.TELEGRAM_TOKEN || !ALLOWED.size) {
throw new Error("BAGUETTE_API_KEY, TELEGRAM_TOKEN et ALLOWED_USERS sont obligatoires");
}
const auth = { Authorization: `Bearer ${KEY}` };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const api = (path, body, method = body ? "POST" : "GET") =>
fetch(BASE + path, {
method,
headers: { ...auth, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
}).then(async (r) => {
if (!r.ok) throw new Error((await r.json()).error?.message ?? r.statusText);
return r.json();
});
const tg = (method, body) =>
fetch(`${TG}/${method}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
.then((r) => r.json())
.then((j) => { if (!j.ok) throw new Error(j.description); return j.result; });
// Telegram limite un message à 4096 caractères : on découpe
async function say(chat_id, text) {
text ||= "(réponse vide)";
for (let i = 0; i < text.length; i += 4000) await tg("sendMessage", { chat_id, text: text.slice(i, i + 4000) });
}
/* ---------- un agent par conversation ---------- */
const INSTRUCTIONS = `Tu es l'assistant personnel de ton utilisateur, qui te parle depuis Telegram.
Tu travailles sur ton propre ordinateur (Chrome, terminal, fichiers), qui garde ses fichiers entre deux demandes.
Réponds en français, en texte simple et court : c'est lu sur un téléphone.`;
const DB = new URL("./agents.json", import.meta.url);
let agents = {};
try { agents = JSON.parse(await readFile(DB, "utf8")); } catch { /* premier lancement */ }
async function agentFor(chatId) {
if (agents[chatId]) return agents[chatId];
// agents.json perdu ? On retrouve l'agent grâce à ses métadonnées.
const { data } = await api("/v1/agents?limit=500");
const agent = data.find((a) => a.metadata?.telegram_chat_id === String(chatId)) ??
await api("/v1/agents", {
name: `Telegram ${chatId}`,
instructions: INSTRUCTIONS,
idle_minutes: 5,
metadata: { telegram_chat_id: String(chatId) },
});
agents[chatId] = agent.id;
await writeFile(DB, JSON.stringify(agents, null, 2));
return agent.id;
}
async function messagesAfter(id, after) {
const all = [];
for (;;) {
const { data } = await api(`/v1/agents/${id}/messages?after=${after}&limit=200`);
all.push(...data);
if (data.length < 200) return all;
after = data.at(-1).id;
}
}
/* ---------- une tâche, suivie en direct ---------- */
async function runTask(agentId, content, onProgress) {
const ctrl = new AbortController();
const res = await fetch(`${BASE}/v1/agents/${agentId}/events`, { headers: auth, signal: ctrl.signal });
if (!res.ok) throw new Error(`Flux d'événements : HTTP ${res.status}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "", run = null, working = false, end = null;
try {
while (!end) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let i;
while (!end && (i = buffer.indexOf("\n\n")) !== -1) {
const chunk = buffer.slice(0, i); buffer = buffer.slice(i + 2);
const type = /^event: (.+)$/m.exec(chunk)?.[1];
const data = /^data: (.+)$/m.exec(chunk)?.[1];
if (!type || !data) continue;
const ev = JSON.parse(data);
// On n'envoie la tâche qu'une fois le flux ouvert, pour ne rien rater
if (type === "ready" && !run) run = await api(`/v1/agents/${agentId}/messages`, { content, wait: false });
else if (type === "status" && ev.status === "working") { working = true; if (ev.text) onProgress(ev.text); }
else if (type === "status" && working) end = ev.status; // idle ou blocked
else if (type === "message" && ev.role === "tool" && ev.tool?.summary) onProgress(ev.tool.summary);
}
}
} finally { ctrl.abort(); }
if (!run) throw new Error("Le flux s'est fermé avant le lancement de la tâche");
// Le flux s'est coupé en route : on attend la fin en interrogeant l'agent
while (!end) {
await sleep(5000);
const a = await api(`/v1/agents/${agentId}`);
if (a.status !== "working") end = a.status;
}
const messages = await messagesAfter(agentId, run.message_id);
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.content)?.content;
return { status: end, answer };
}
// Un seul message « En cours », modifié au plus toutes les 3 s
function progress(chat_id) {
let id = null, last = "", next = "", timer = null, closed = false;
const flush = async () => {
timer = null;
if (closed || next === last) return;
last = next;
const text = `En cours : ${next}`.slice(0, 4000);
try {
if (!id) id = (await tg("sendMessage", { chat_id, text })).message_id;
else await tg("editMessageText", { chat_id, message_id: id, text });
} catch { /* message identique ou trop rapide : sans gravité */ }
};
return {
update: (text) => { next = text; timer ??= setTimeout(flush, 3000); },
done: () => { closed = true; clearTimeout(timer); },
};
}
/* ---------- les commandes ---------- */
async function sendScreen(chat_id, agentId) {
const r = await fetch(`${BASE}/v1/agents/${agentId}/computer/screenshot`, { headers: auth });
if (r.status === 409) return say(chat_id, "Mon ordinateur est éteint. Il se rallume à la prochaine tâche.");
if (!r.ok) return say(chat_id, `Capture impossible (HTTP ${r.status}).`);
const form = new FormData();
form.append("chat_id", String(chat_id));
form.append("photo", new Blob([await r.arrayBuffer()], { type: "image/jpeg" }), "ecran.jpg");
const j = await fetch(`${TG}/sendPhoto`, { method: "POST", body: form }).then((x) => x.json());
if (!j.ok) throw new Error(j.description);
}
const busy = new Set();
async function handle(msg) {
const chat_id = msg.chat.id, text = msg.text?.trim();
if (!text) return;
if (!ALLOWED.has(String(msg.from?.id))) {
console.log(`Refusé : utilisateur ${msg.from?.id} (@${msg.from?.username ?? "?"}), chat ${chat_id}`);
return; // pas de réponse : un inconnu n'apprend rien
}
const command = text.startsWith("/") ? text.split(/[\s@]/)[0] : null;
if (command === "/start") return say(chat_id, "Donne-moi une tâche. /screen pour voir mon écran, /stop pour m'arrêter.");
const agentId = await agentFor(chat_id);
if (command === "/stop") { await api(`/v1/agents/${agentId}/stop`, {}); return say(chat_id, "Tâche arrêtée."); }
if (command === "/screen") return sendScreen(chat_id, agentId);
if (busy.has(chat_id)) return say(chat_id, "Je suis encore sur la tâche précédente. /screen pour voir où j'en suis, /stop pour l'arrêter.");
busy.add(chat_id);
const p = progress(chat_id);
try {
await tg("sendChatAction", { chat_id, action: "typing" });
const { status, answer } = await runTask(agentId, text, p.update);
p.done();
await say(chat_id, answer ?? "Terminé.");
if (status === "blocked") {
const live = await api(`/v1/agents/${agentId}/computer/live`, { interactive: true, ttl_seconds: 900 });
await say(chat_id, `J'ai besoin de toi (connexion, captcha…). Prends la main ici, le lien expire dans 15 minutes :\n${live.url}\nÉcris-moi quand c'est fait.`);
}
} catch (e) {
p.done();
await say(chat_id, `Erreur : ${e.message}`);
} finally {
busy.delete(chat_id);
}
}
/* ---------- long polling : aucune connexion entrante ---------- */
let offset = 0;
console.log("Bot prêt.");
for (;;) {
try {
const updates = await tg("getUpdates", { offset, timeout: 50, allowed_updates: ["message"] });
for (const u of updates) {
offset = u.update_id + 1;
if (u.message) handle(u.message).catch((e) => console.error(e));
}
} catch (e) {
console.error(e.message);
await sleep(5000);
}
}Le lancer et trouver ton identifiant Telegram
La liste d'accès est obligatoire : sans elle, le bot refuse de démarrer. Pour connaître ton identifiant, lance-le d'abord avec une valeur bidon, écris-lui un message, et lis la console :
BAGUETTE_API_KEY=bb_live_… TELEGRAM_TOKEN=123456:ABC… ALLOWED_USERS=0 node bot.js
# Refusé : utilisateur 812345678 (@toi), chat 812345678Relance avec ton identifiant (plusieurs séparés par des virgules pour un usage en famille ou en équipe) :
BAGUETTE_API_KEY=bb_live_… TELEGRAM_TOKEN=123456:ABC… ALLOWED_USERS=812345678 node bot.jsÉcris-lui « Trouve-moi trois trains Paris-Lyon samedi matin et dis-moi le moins cher » : le message « En cours » change au fil des étapes, puis la réponse arrive. Pour qu'il tourne en permanence, n'importe quelle machine allumée fait l'affaire (un petit VPS, un Raspberry Pi, un service systemd ou pm2) : il n'a besoin que de connexions sortantes.
Comment ça marche
Un agent par conversation. À la première tâche d'un chat, agentFor crée un agent avec metadata: { telegram_chat_id } et note son identifiant dans agents.json. Si le fichier disparaît, l'agent n'est pas perdu : on le retrouve dans GET /v1/agents grâce à ses métadonnées. Cet agent garde sa mémoire, ses fichiers et ses sessions de navigateur d'une tâche à l'autre, ce qui donne l'impression d'un assistant qui te connaît.
La progression. runTask ouvre le flux GET /v1/agents/{id}/events avant d'envoyer la tâche avec wait: false. Chaque événement status porte un texte (« Recherche des horaires… ») et chaque message tool un tool.summary : on les transmet à un seul message Telegram, modifié au plus toutes les 3 secondes pour ne pas inonder la conversation. Quand le statut repasse de working à idle, on relit la conversation après le message_id de la tâche et on envoie la dernière réponse de l'agent.
Les tâches longues. Le flux n'a pas de limite de durée : une tâche de vingt minutes se suit comme une tâche de vingt secondes. Le serveur envoie un commentaire : ping toutes les 15 s, ce qui garde la connexion ouverte. Si elle se coupe quand même, runTask passe à une interrogation de GET /v1/agents/{id} toutes les 5 secondes jusqu'à la fin. C'est pour ça qu'on n'utilise pas wait: true, qui rend la main au bout de 120 s avec status: "working".
/screen. GET /v1/agents/{id}/computer/screenshot renvoie un JPEG, ou 409 si l'ordinateur est éteint. On le transmet à sendPhoto en multipart/form-data.
/stop. POST /v1/agents/{id}/stop arrête la tâche. Le statut repasse à idle, runTask se termine et le bot envoie ce que l'agent avait écrit jusque-là.
Quand l'agent est bloqué. Si l'agent passe en blocked (il doit se connecter à un compte, un captcha l'arrête), le bot crée un lien d'écran avec POST /v1/agents/{id}/computer/live et interactive: true. Tu l'ouvres sur ton téléphone, tu fais la manipulation, puis tu lui écris « c'est fait ». Ce lien donne la main sur l'ordinateur à quiconque l'a pendant 15 minutes : il n'est envoyé que dans ta conversation privée.
Sécurité : ce qui change par rapport à l'auto-hébergement
Les problèmes rencontrés par les utilisateurs d'OpenClaw en 2026, rappelés dans notre article alternative à OpenClaw, viennent pour beaucoup du fait que l'agent, ses clés et sa passerelle tournent sur une machine que tu dois toi-même protéger : la faille CVE-2026-25253 (vol du jeton de la passerelle), des dizaines de milliers de panneaux de contrôle exposés sur Internet, des logiciels espions visant les fichiers de configuration qui contiennent les clés d'API, et un bac à sable désactivé par défaut selon The Register. Voici ce que cette architecture change :
| OpenClaw auto-hébergé | Ce bot + l'API Baguette Bot | |
|---|---|---|
| Où l'agent agit | Ta machine ou ton serveur | Son propre ordinateur Linux, hébergé à Paris, isolé de celui des autres agents |
| Ports ouverts | Une passerelle et un panneau de contrôle à protéger | Aucun : bot.js ne fait que des requêtes sortantes (long polling) |
| Clés | Dans des fichiers de configuration sur la machine de l'agent | La clé API reste dans l'environnement de bot.js, jamais sur l'ordinateur de l'agent ni dans Telegram |
| Qui peut parler à l'agent | Selon ta configuration | Une liste d'identifiants Telegram vérifiée dans ton code, et le bot refuse de démarrer sans elle |
| Dépenses | Facture au token chez ton fournisseur de modèle | Solde prépayé : quand il est vide, l'API répond 402 et les tâches s'arrêtent |
Ce qui reste à ta charge, honnêtement :
- Tes deux secrets. Le jeton Telegram et la clé API vivent dans les variables d'environnement de la machine qui fait tourner
bot.js. Ne les commite pas, et révoque la clé dans la console au moindre doute : elle cesse de fonctionner immédiatement. - Telegram voit les messages. Les conversations avec un bot ne sont pas chiffrées de bout en bout. N'y envoie pas ce que tu ne confierais pas à Telegram.
- L'agent agit sur le web. Il a un navigateur et un terminal sur son ordinateur. Si tu lui confies des comptes, limite-les à ce dont il a besoin, et garde la liste d'accès courte.
Coûts
Pas d'abonnement : un solde prépayé en euros.
- L'ordinateur coûte 2 € de l'heure tant qu'il est allumé, facturé à la seconde. Il s'allume à la première tâche et s'éteint après
idle_minutessans activité (5 ici). Une tâche de 2 minutes suivie de 5 minutes d'inactivité, c'est 7 minutes, soit environ 0,23 € d'ordinateur. Baisseidle_minutessi tu envoies peu de demandes, ou garde-le plus haut si tu enchaînes les messages. - Le modèle est facturé selon les tokens consommés, au prix par million de tokens de
GET /v1/models. La console détaille ce que coûte chaque agent, donc chaque conversation Telegram.
Une commande /solde est facile à ajouter : GET /v1/account renvoie balance_eur.
Aller plus loin
- Des tâches programmées. L'API n'a pas de planificateur : utilise ton propre cron. Avec
npm i node-cron,cron.schedule("0 8 * * *", () => handle({ chat: { id: MON_CHAT }, from: { id: MON_ID }, text: "Résume l’actualité tech de ce matin" }))t'envoie un résumé tous les matins à 8 h. - Des consignes modifiables. Une commande
/consignequi appellePATCH /v1/agents/{id}avec de nouvellesinstructionsjoue le rôle des compétences d'OpenClaw : le style de réponse, les sites à privilégier, ce qu'il ne doit jamais faire. - Choisir le modèle. Par défaut
anthropic/claude-haiku-5.5;GET /v1/modelsen liste plus de 300, avec leur prix et l'indicateurvision. Une commande/modelepeut faire unPATCHdu champmodel. - Des fichiers. Un document envoyé dans Telegram peut être téléchargé avec
getFilepuis déposé sur l'ordinateur de l'agent avecPOST /v1/agents/{id}/files; dans l'autre sens,GET /v1/agents/{id}/files?path=…récupère ce qu'il a produit, à renvoyer avecsendDocument. - D'autres messageries. La même logique (un agent par conversation,
metadatapour faire le lien) s'applique à Slack, Discord ou ton propre chat. Les agents, eux, n'envoient pas d'e-mails et ne passent pas d'appels.
FAQ
Est-ce que l'agent tourne quand mon ordinateur est éteint ?
L'agent et son ordinateur tournent chez Baguette Bot, pas chez toi. Seul bot.js doit rester allumé pour relayer les messages Telegram : un petit VPS ou n'importe quelle machine toujours allumée suffit, sans port ouvert.
Plusieurs personnes peuvent-elles l'utiliser ?
Oui : ajoute leurs identifiants à ALLOWED_USERS. Chacune a sa conversation privée avec le bot, donc son propre agent et son propre ordinateur. Dans un groupe, tout le groupe partage un agent, et seuls les membres autorisés peuvent lui donner des tâches. Un compte peut avoir jusqu'à 200 agents.
Pourquoi pas un webhook Telegram ?
Un webhook demande une URL publique en HTTPS, donc un port ouvert vers ton programme. Le long polling (getUpdates) fait l'inverse : c'est ton programme qui va chercher les messages, et rien ne l'atteint de l'extérieur. Pour un agent personnel, c'est plus simple et plus sûr.
Que se passe-t-il si agents.json est supprimé ?
Rien de grave : au message suivant, agentFor retrouve l'agent de la conversation dans GET /v1/agents grâce à metadata.telegram_chat_id, avec sa mémoire et ses fichiers.
Pour la suite : la documentation de l'API, la console pour créer ta clé, la présentation de l'API, le tutoriel général créer une app d'agents IA avec l'API, et notre comparatif alternative à OpenClaw.