Dans ce tutoriel, on construit une app grand public où chaque utilisateur a son propre agent IA, avec son propre ordinateur dans le cloud. L'utilisateur écrit sa demande sur une page de chat pensée pour le téléphone, ferme l'app, et reçoit un message Telegram quand son agent a fini. Le tout tient dans un server.js d'environ 200 lignes et une page HTML, sur l'API Baguette Bot.
Ce qu'on construit
Le 8 septembre 2026, Meta a lancé Muse, un agent IA personnel qui tourne sur un ordinateur cloud dédié à chaque utilisateur et continue de travailler quand on ferme l'app. Il est disponible sur iOS, Android, le web, Mac et WhatsApp, mais seulement aux États-Unis au lancement. L'idée est simple et forte : un agent par personne, qui garde sa mémoire et ses fichiers d'une tâche à l'autre.
On ne copie ni le nom ni la marque : on construit ta propre app sur la même idée, pour tes utilisateurs. Elle fait quatre choses :
- Un agent par utilisateur, créé à l'inscription et relié à ton compte utilisateur par
metadata. - Une page de chat mobile qui affiche les demandes et les réponses, même après une fermeture de l'app.
- Du travail en arrière-plan : la tâche part avec
wait: false, ton serveur suit l'agent, enregistre le résultat et prévient l'utilisateur sur le canal de ton choix (ici Telegram). - Un contrôle des coûts par utilisateur : ordinateur qui s'éteint vite, quota quotidien, bouton d'arrêt, vérification du solde.
L'architecture : le navigateur parle à ton serveur, ton serveur parle à l'API. La clé API ne quitte jamais le serveur.
Ce qu'il te faut
- Node 18 ou plus récent (
fetchest intégré). - Une clé API, créée dans la console. Elle commence par
bb_live_. Une fois ton e-mail vérifié, 1 € est offert pour essayer, ce qui suffit pour plusieurs tâches de test. - Optionnel : un bot Telegram, créé en deux minutes avec @BotFather, pour les notifications.
mkdir mon-agent && cd mon-agent
npm init -y && npm pkg set type=module
npm i express@5
mkdir public
export BAGUETTE_API_KEY=bb_live_...
export TELEGRAM_BOT_TOKEN=123456:ABC... # optionnel
export TELEGRAM_BOT_NAME=MonAgentBot # optionnel, sans le @On utilise Express 5 parce qu'il transmet les erreurs des routes async au gestionnaire d'erreurs, sans try/catch partout. Colle les blocs suivants dans l'ordre dans server.js.
Étape 1 : le client API et une petite base
Le helper api() est celui de la documentation : il lève une erreur avec le message de l'API quand la requête échoue. Pour la base, un fichier JSON suffit pour le tutoriel ; dans ton app, ce sera ta vraie base de données.
// server.js (Node 18+, npm i express@5)
import express from "express";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
const BASE = "https://api.baguettebot.com";
const KEY = process.env.BAGUETTE_API_KEY;
const TG = process.env.TELEGRAM_BOT_TOKEN;
const TG_BOT = process.env.TELEGRAM_BOT_NAME;
const DAILY_TASKS = 20; // tâches par utilisateur et par jour
const MIN_BALANCE = 2; // en euros : en dessous, on refuse les nouvelles tâches
const MAX_TASK_MIN = 20; // au-delà, on arrête la tâche
const api = (path, body, method = body ? "POST" : "GET") =>
fetch(BASE + path, {
method,
headers: { Authorization: `Bearer ${KEY}`, "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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const DB = "db.json";
const db = existsSync(DB) ? JSON.parse(readFileSync(DB, "utf8")) : { users: {} };
const save = () => writeFileSync(DB, JSON.stringify(db, null, 2));
const app = express();
app.use(express.json());
app.use(express.static("public"));
// Ta propre authentification remplace ce jeton de session
const me = (req, res, next) => {
req.user = db.users[req.get("x-session")];
req.user ? next() : res.status(401).json({ error: "Non connecté" });
};Étape 2 : un agent par utilisateur, créé à l'inscription
À l'inscription, on crée l'agent avec des instructions personnalisées. Deux paramètres comptent ici :
metadata: tes propres données, renvoyées telles quelles par l'API. On y met l'identifiant de l'utilisateur dans ton app. C'est le lien entre tes comptes et les agents.idle_minutes: le nombre de minutes sans activité avant que l'ordinateur s'éteigne. Pour une app grand public, reste bas.
const instructions = (name) => `Tu es l'agent personnel de ${name}.
Tu travailles sur ton propre ordinateur (Chrome, terminal, fichiers) et tu te souviens des demandes précédentes.
Quand tu as fini, réponds en quelques lignes lisibles sur un téléphone, avec les liens utiles.
Si un site demande une connexion ou un captcha, arrête-toi et explique ce qu'il faut faire.
Tu n'achètes rien et tu ne crées aucun compte.`;
app.post("/api/signup", async (req, res) => {
const name = String(req.body.name ?? "").trim().slice(0, 40) || "toi";
const userId = randomUUID();
const agent = await api("/v1/agents", {
name: `Agent de ${name}`,
instructions: instructions(name),
idle_minutes: 5,
metadata: { app_user_id: userId },
});
const session = randomUUID();
db.users[session] = {
userId, name, agentId: agent.id, tasks: [],
linkCode: randomUUID().replaceAll("-", ""), telegramChatId: null,
today: { day: "", count: 0 },
};
save();
res.json({ session });
});Le modèle par défaut est anthropic/claude-haiku-5.5. Tu peux en passer un autre avec model (la liste est dans GET /v1/models).
Grâce à metadata, tu peux toujours retrouver l'agent d'un utilisateur avec l'API seule, par exemple pour réparer ta base :
const { data } = await api("/v1/agents?limit=500");
const agent = data.find((a) => a.metadata?.app_user_id === userId);Étape 3 : suivre l'agent quand l'app est fermée
C'est le cœur du clone. Avec wait: true, l'API attend au plus 120 secondes. Une vraie tâche (comparer des offres, remplir un dossier, chercher un logement) dure souvent plus. On envoie donc la tâche avec wait: false : l'API répond 202 tout de suite avec un message_id, et ton serveur suit l'agent, que le téléphone de l'utilisateur soit ouvert ou non.
followRun() lit le flux d'événements de l'agent. Quand il cesse de travailler (événement status différent de working), on vérifie son état avec GET /v1/agents/{id} et on lit les messages arrivés après la demande avec after. Par sécurité, on revérifie aussi une fois par minute (les : ping arrivent toutes les 15 s) et on se reconnecte si le flux coupe.
async function messagesAfter(agentId, after) {
const all = [];
for (;;) {
const { data } = await api(`/v1/agents/${agentId}/messages?after=${after}&limit=200`);
all.push(...data);
if (data.length < 200) return all;
after = data.at(-1).id;
}
}
// Suit l'agent jusqu'à la fin de la tâche lancée par messageId
async function followRun(agentId, messageId, maxMs, onStatus = () => {}) {
const started = Date.now();
const deadline = started + maxMs;
const check = async () => {
const agent = await api(`/v1/agents/${agentId}`);
if (agent.status === "working") return null;
const msgs = await messagesAfter(agentId, messageId);
const answer = msgs.filter((m) => m.role === "assistant" && m.content).at(-1);
if (!answer && agent.status === "idle" && Date.now() - started < 30_000) return null; // pas encore commencé
return { status: agent.status, output: answer?.content ?? null };
};
while (Date.now() < deadline) {
try {
const res = await fetch(`${BASE}/v1/agents/${agentId}/events`, { headers: { Authorization: `Bearer ${KEY}` } });
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "", lastCheck = 0;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let i;
while ((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 = type && JSON.parse(/^data: (.+)$/m.exec(chunk)?.[1] ?? "{}");
if (type === "status") onStatus(data.text);
const stopped = type === "status" && data.status !== "working";
if (type === "ready" || stopped || Date.now() - lastCheck > 60_000) {
lastCheck = Date.now();
const result = await check();
if (result) { reader.cancel(); return result; }
}
if (Date.now() > deadline) { reader.cancel(); return null; }
}
}
} catch (e) {
console.error("events:", e.message);
}
await sleep(3000); // flux coupé : on se reconnecte
}
return null;
}Étape 4 : envoyer une tâche, avec les garde-fous
Avant d'envoyer, on applique trois règles : une seule tâche à la fois par utilisateur, un quota quotidien, et un solde minimum vérifié avec GET /v1/account. Sans cette dernière vérification, ton solde épuisé ferait échouer les tâches de tous tes utilisateurs avec une erreur 402.
app.post("/api/tasks", me, async (req, res) => {
const u = req.user;
const content = String(req.body.content ?? "").trim();
if (!content) return res.status(400).json({ error: "Demande vide" });
const day = new Date().toISOString().slice(0, 10);
if (u.today.day !== day) u.today = { day, count: 0 };
if (u.today.count >= DAILY_TASKS) return res.status(429).json({ error: "Limite du jour atteinte" });
if (u.tasks.some((t) => t.status === "working")) return res.status(409).json({ error: "Ton agent travaille déjà" });
const { balance_eur } = await api("/v1/account");
if (balance_eur < MIN_BALANCE) return res.status(503).json({ error: "Service en pause, réessaie plus tard" });
const run = await api(`/v1/agents/${u.agentId}/messages`, { content, wait: false });
const task = { id: run.message_id, content, status: "working", output: null, statusText: null };
u.tasks.push(task); u.today.count++; save();
track(u, task); // sans await : la réponse part tout de suite
res.status(202).json(task);
});
async function track(u, task) {
const result = await followRun(u.agentId, task.id, MAX_TASK_MIN * 60_000, (text) => (task.statusText = text))
.catch((e) => ({ status: "error", output: e.message }));
if (task.status === "stopped") return save();
if (!result) await api(`/v1/agents/${u.agentId}/stop`, {}).catch(() => {});
task.status = !result ? "timeout" : ["blocked", "error"].includes(result.status) ? result.status : "done";
task.output = result?.output ?? null;
save();
// Tâche finie : on éteint l'ordinateur sans attendre idle_minutes (ses fichiers restent)
if (task.status !== "blocked") await api(`/v1/agents/${u.agentId}/computer/stop`, {}).catch(() => {});
await notify(u, task).catch((e) => console.error("notify:", e.message));
}Étape 5 : prévenir l'utilisateur
L'API n'envoie pas de notifications : c'est ton serveur qui choisit le canal. On prend Telegram parce que c'est gratuit et rapide à brancher, mais notify() est le seul endroit à changer pour passer à ton service de push (FCM, APNs, Web Push) ou à un webhook Slack.
Pour relier un utilisateur à Telegram, ta page lui donne un lien t.me/TonBot?start=CODE. Quand il appuie sur « Démarrer », Telegram envoie /start CODE à ton bot, et on le lit avec getUpdates.
async function notify(u, task) {
if (!TG || !u.telegramChatId) return;
const text = task.status === "blocked"
? "Ton agent a besoin de toi (connexion ou captcha). Ouvre l'app et touche « Écran »."
: `C'est fait : « ${task.content.slice(0, 60)} »\n\n${(task.output ?? "Pas de réponse.").slice(0, 3500)}`;
const r = await fetch(`https://api.telegram.org/bot${TG}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: u.telegramChatId, text }),
});
if (!r.ok) throw new Error(await r.text());
}
async function pollTelegram() {
let offset = 0;
while (TG) {
try {
const r = await fetch(`https://api.telegram.org/bot${TG}/getUpdates?timeout=30&offset=${offset}`).then((r) => r.json());
for (const up of r.result ?? []) {
offset = up.update_id + 1;
const code = up.message?.text?.match(/^\/start (\w+)/)?.[1];
const u = code && Object.values(db.users).find((x) => x.linkCode === code);
if (u) { u.telegramChatId = up.message.chat.id; save(); }
}
} catch { await sleep(5000); }
}
}Étape 6 : les dernières routes
Il reste à lire l'état pour la page, à arrêter l'agent, à prendre la main quand il est bloqué, et à supprimer le compte. Au démarrage, on reprend le suivi des tâches restées en cours : un redémarrage du serveur ne perd aucun résultat.
app.get("/api/me", me, (req, res) => {
const u = req.user;
res.json({
name: u.name,
tasks: u.tasks.slice(-20),
telegram: TG_BOT ? { linked: !!u.telegramChatId, link: `https://t.me/${TG_BOT}?start=${u.linkCode}` } : null,
});
});
app.post("/api/stop", me, async (req, res) => {
for (const t of req.user.tasks) if (t.status === "working") t.status = "stopped";
save();
await api(`/v1/agents/${req.user.agentId}/stop`, {});
await api(`/v1/agents/${req.user.agentId}/computer/stop`, {});
res.json({ ok: true });
});
// L'écran de l'agent, que l'utilisateur peut piloter (connexion, captcha)
app.post("/api/live", me, async (req, res) => {
const id = req.user.agentId;
for (let i = 0; i < 30 && (await api(`/v1/agents/${id}/computer`)).status !== "running"; i++) {
if (i === 0) await api(`/v1/agents/${id}/computer/start`, {});
await sleep(2000);
}
const live = await api(`/v1/agents/${id}/computer/live`, { interactive: true, ttl_seconds: 900 });
res.json({ url: live.url });
});
app.delete("/api/account", me, async (req, res) => {
await api(`/v1/agents/${req.user.agentId}`, undefined, "DELETE");
delete db.users[req.get("x-session")];
save();
res.json({ ok: true });
});
app.use((err, req, res, next) => res.status(500).json({ error: err.message }));
for (const u of Object.values(db.users)) for (const t of u.tasks) if (t.status === "working") track(u, t);
pollTelegram();
app.listen(3000, () => console.log("http://localhost:3000"));La page de chat mobile
public/index.html : une colonne de messages, un champ en bas, et la page relit l'état toutes les 4 secondes quand elle est visible. Les réponses sont affichées avec textContent, jamais en HTML brut : c'est du texte produit par un agent qui a lu des pages web.
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Mon agent</title>
<style>
body { margin: 0; height: 100dvh; display: flex; flex-direction: column; font: 16px/1.45 system-ui, sans-serif; background: #f6f3ee; color: #222; }
header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; font-weight: 600; }
header a, header button { font: inherit; font-size: 14px; color: #b3261e; background: none; border: 0; padding: 0; margin-left: 12px; }
#list { flex: 1; overflow-y: auto; padding: 0 16px; }
.msg { background: #fff; border-radius: 14px; padding: 10px 12px; margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.me { background: #2b2b2b; color: #fff; margin-left: 15%; }
.wait { color: #777; font-style: italic; }
form { display: flex; gap: 8px; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); }
input { flex: 1; min-width: 0; font-size: 16px; padding: 12px; border: 1px solid #ccc; border-radius: 12px; }
form button { font-size: 16px; padding: 0 16px; border: 0; border-radius: 12px; background: #b3261e; color: #fff; }
</style>
</head>
<body>
<header><span>Mon agent</span><span><a id="tg" target="_blank" hidden>Telegram</a><button id="screenBtn">Écran</button><button id="haltBtn">Arrêter</button></span></header>
<div id="list"></div>
<form id="f"><input id="q" placeholder="Demande quelque chose…" autocomplete="off"><button>Envoyer</button></form>
<script>
let session = localStorage.getItem("session"), last = "";
const call = (path, body) => fetch(path, {
method: body ? "POST" : "GET",
headers: { "Content-Type": "application/json", "x-session": session },
body: body && JSON.stringify(body),
}).then(async (r) => { const j = await r.json(); if (!r.ok) throw new Error(j.error); return j; });
const bubble = (text, cls = "") => {
const d = document.createElement("div");
d.className = "msg " + cls; d.textContent = text;
return list.appendChild(d);
};
const LABELS = { working: "Je m'en occupe. Tu peux fermer l'app, je te préviens.", stopped: "Arrêté.", timeout: "Trop long, tâche arrêtée.", error: "Erreur.", blocked: "J'ai besoin de toi : touche « Écran »." };
async function refresh() {
if (document.hidden) return;
const me = await call("/api/me");
if (me.telegram) { tg.hidden = false; tg.href = me.telegram.link; tg.textContent = me.telegram.linked ? "Telegram relié" : "Relier Telegram"; }
const key = JSON.stringify(me.tasks);
if (key === last) return;
last = key; list.textContent = "";
for (const t of me.tasks) {
bubble(t.content, "me");
bubble(t.status === "working" ? (t.statusText || LABELS.working) : (t.output || LABELS[t.status] || ""), t.status === "working" ? "wait" : "");
}
list.scrollTop = list.scrollHeight;
}
f.onsubmit = async (e) => {
e.preventDefault();
const content = q.value.trim(); if (!content) return;
q.value = "";
try { await call("/api/tasks", { content }); } catch (err) { alert(err.message); }
refresh();
};
haltBtn.onclick = () => call("/api/stop", {}).then(refresh);
screenBtn.onclick = async () => {
const w = window.open("", "_blank"); // ouvert tout de suite, sinon Safari bloque la fenêtre
w.location = (await call("/api/live", {})).url;
};
(async () => {
if (!session) {
({ session } = await call("/api/signup", { name: prompt("Ton prénom ?") || "" }));
localStorage.setItem("session", session);
}
refresh(); setInterval(refresh, 4000);
document.addEventListener("visibilitychange", refresh);
})();
</script>
</body>
</html>Lance node server.js, ouvre http://localhost:3000 sur ton ordinateur ou ton téléphone (même réseau, avec l'adresse IP de ta machine), et demande par exemple « Trouve-moi trois restaurants italiens ouverts dimanche soir près de la gare de Lyon, avec les avis ». Ferme l'onglet : le résultat arrive sur Telegram.
Montrer l'écran en direct
Quand l'agent tombe sur une page de connexion ou un captcha, ses instructions lui demandent de s'arrêter et d'expliquer ce qu'il faut faire (si son statut passe à blocked, le serveur garde l'ordinateur allumé et la notification le dit). L'utilisateur touche alors « Écran » : ton serveur rallume l'ordinateur si besoin, crée un lien avec POST /v1/agents/{id}/computer/live et interactive: true, valable 15 minutes, et l'utilisateur pilote lui-même le navigateur de l'agent pour se connecter. Le lien signé ne contient pas ta clé. L'utilisateur écrit ensuite « c'est bon, continue » dans le chat, et l'agent reprend là où il en était. Ses sessions de navigateur sont conservées pour les tâches suivantes.
Pour une simple vue en lecture seule (regarder l'agent travailler), passe interactive: false et affiche l'URL dans une iframe.
Coûts : contrôler la dépense par utilisateur
Tu paies avec un solde prépayé en euros, sans abonnement : les tokens du modèle au prix de chaque modèle, et l'ordinateur à 2 € de l'heure, facturé à la seconde. Une tâche de 3 minutes avec l'ordinateur coûte donc 0,10 € d'ordinateur, plus les tokens du modèle. Regarde GET /v1/models pour le prix par million de tokens de celui que tu utilises.
Dans une app grand public, c'est toi qui paies pour tous tes utilisateurs. Les leviers du code :
idle_minutes: 5, et surtoutcomputer/stopdès qu'une tâche est finie : l'ordinateur ne tourne que pendant le travail. Ses fichiers restent, et il se rallume en quelques secondes à la tâche suivante.- Un quota quotidien (
DAILY_TASKS) et une seule tâche à la fois par utilisateur. - Une durée maximale (
MAX_TASK_MIN) au-delà de laquellePOST /v1/agents/{id}/stopcoupe la tâche. - Un bouton Arrêter pour l'utilisateur, qui arrête la tâche et l'ordinateur.
- Un solde minimum vérifié avant chaque envoi.
Pour fixer le prix de ton offre, mesure : la console détaille ce que coûte chaque agent.
Aller plus loin
- Tâches planifiées. L'API n'a pas de planificateur : ajoute un cron de ton côté (
node-cron, un timer systemd, GitHub Actions) qui envoie « fais-moi le point sur mes colis » chaque matin avec la même route. - Fichiers.
POST /v1/agents/{id}/filesdépose un document de l'utilisateur (environ 30 Mo au plus) sur l'ordinateur de son agent ;GET /v1/agents/{id}/files?path=…récupère ce que l'agent a produit. - Le rendu Markdown.
outputest du Markdown : passe-le dans une bibliothèque de rendu qui nettoie le HTML. - La limite de 200 agents par compte. Supprime les agents des comptes inactifs (après avoir récupéré leurs fichiers utiles) et recrée-les à leur retour.
FAQ
Est-ce que l'agent peut envoyer des e-mails ou appeler pour l'utilisateur ?
Non. Les agents de l'API n'envoient pas d'e-mails et ne passent pas d'appels. Ils naviguent, remplissent des formulaires web, lisent, comparent et produisent des fichiers. Les notifications passent par ton propre canal, comme ici Telegram.
Que se passe-t-il si mon solde est épuisé pendant une tâche ?
Les tâches s'arrêtent et l'API répond 402 insufficient_balance aux nouvelles demandes. C'est pour ça que le serveur vérifie GET /v1/account avant chaque envoi : il vaut mieux refuser poliment une tâche que la laisser échouer.
Où sont les données de mes utilisateurs ?
Les ordinateurs des agents sont hébergés à Paris. Les messages partent chez l'éditeur du modèle que tu choisis. Ta propre base (ici db.json) reste chez toi. Respecte nos conditions : pas de spam, de fraude ni de collecte abusive de données personnelles.
Pourquoi ne pas ouvrir le flux d'événements depuis le navigateur ?
EventSource ne peut pas envoyer l'en-tête Authorization, et ta clé ne doit jamais aller dans le navigateur. Le serveur suit donc les événements lui-même, ce qui est de toute façon nécessaire pour que le travail continue app fermée.
Pour la suite : la documentation de l'API, la console, la présentation de l'API, le tutoriel principal pour créer une app d'agent IA et notre comparatif Baguette Bot ou Meta Muse.