Créer un clone de Manus avec l'API Baguette Bot : une tâche, un ordinateur, un livrable

Tutoriel Node.js : une app web où l'on décrit une tâche, un agent ouvre son propre ordinateur, travaille sous tes yeux et rend un fichier à télécharger.

Par l'équipe Baguette Bot 14 min de lecture
Clone Manus
Sommaire
  1. Ce qu'on construit
  2. Ce qu'il te faut
  3. Le serveur
  4. La page
  5. Ce que la page reçoit pendant la tâche
  6. Montrer l'écran en direct
  7. Les tâches de plus de 120 secondes
  8. Coûts
  9. Aller plus loin
  10. FAQ

Dans ce tutoriel, on construit une petite app web dans l'esprit de Manus : tu écris une tâche, tu joins un fichier si besoin, et un agent IA s'en charge sur son propre ordinateur cloud. Pendant qu'il travaille, la page affiche ce qu'il fait étape par étape et son écran en direct. Quand il a fini, tu lis son résumé et tu télécharges le livrable qu'il a produit (un rapport Markdown, un CSV, une archive de code). Le tout tient dans un server.js d'environ 120 lignes et une page HTML.

Ce qu'on construit

Manus a popularisé une idée simple : tu confies une tâche à l'agent, il ouvre son propre ordinateur cloud, navigue, écrit du code, produit des fichiers et revient avec un résultat. Il fonctionne avec des crédits et des tâches en arrière-plan, et il est surtout utilisé pour des recherches et des livrables comme des rapports ou de petites applications web. Ici, on ne copie pas son interface ni sa marque : on construit ta propre app sur la même idée, avec l'API Baguette Bot pour la partie agent et ordinateur.

Le parcours :

  1. La page envoie la tâche (et un fichier éventuel) à ton serveur.
  2. Le serveur crée un agent, dépose le fichier sur son ordinateur, puis relaie ses événements en direct à la page.
  3. La page affiche le statut, chaque action de l'agent et son écran dans une iframe.
  4. À la fin, le serveur lit la dernière réponse, récupère le fichier produit dans ~/Deliverables/ et éteint l'ordinateur.

Chaque tâche a son propre agent, donc son propre ordinateur Linux (bureau 1280×800, Chrome, terminal), isolé des autres et hébergé à Paris.

Ce qu'il te faut

  • Node 18 ou plus (pour fetch intégré).
  • Une clé API créée dans la console. Elle commence par bb_live_ et reste sur ton serveur, jamais dans la page.
  • Environ 1 € de budget de test. Le solde prépayé commence avec 1 € offert une fois ton e-mail vérifié, ce qui suffit pour plusieurs tâches courtes.
bash
mkdir mon-agent && cd mon-agent
npm init -y && npm pkg set type=module
npm i express
mkdir public

Le serveur

Le serveur fait cinq choses : créer la tâche, relayer le flux d'événements, lancer la tâche, créer le lien vers l'écran, et rapatrier le résultat. Les consignes de l'agent lui demandent d'enregistrer son livrable dans ~/Deliverables/ et d'en donner le chemin sur une dernière ligne reconnaissable, ce qui permet au serveur de savoir quel fichier récupérer.

javascript
// server.js (Node 18+)
import express from "express";

const KEY = process.env.BAGUETTE_API_KEY;
const BASE = "https://api.baguettebot.com";
const auth = { Authorization: `Bearer ${KEY}` };

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 INSTRUCTIONS = `Tu es un agent autonome qui travaille sur ton propre ordinateur (Chrome, terminal, fichiers).
Mène la tâche jusqu'au bout sans poser de questions, sauf si tu es vraiment bloqué.
Enregistre le livrable final dans le dossier ~/Deliverables/ (Markdown, CSV, HTML, code ou archive .zip).
Termine ta réponse par un court résumé, puis une dernière ligne exactement de la forme :
LIVRABLE: ~/Deliverables/nom-du-fichier.ext`;

// agentId -> { content, started, messageId, result, file }. En mémoire : une base de données en production.
const tasks = new Map();

const app = express();
app.use(express.json({ limit: "40mb" }));
app.use(express.static("public"));
const route = (fn) => (req, res) => fn(req, res).catch((e) => res.status(500).json({ error: e.message }));
const find = (req, res) => {
  const t = tasks.get(req.params.id);
  if (!t) res.status(404).json({ error: "Tâche inconnue" });
  return t;
};

// Toute la conversation après un message, page par page (200 messages max par appel)
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;
  }
}

// 1. Nouvelle tâche : un agent neuf, et le fichier d'entrée déposé sur son ordinateur
app.post("/api/tasks", route(async (req, res) => {
  const { prompt, file } = req.body;
  if (!prompt?.trim()) return res.status(400).json({ error: "Décris la tâche" });
  const agent = await api("/v1/agents", {
    name: prompt.slice(0, 80),
    instructions: INSTRUCTIONS,
    idle_minutes: 3,
    metadata: { app: "mon-agent" },
  });
  let content = prompt;
  if (file) {
    const path = `~/Uploads/${file.name.replace(/[^\w.-]/g, "_")}`;
    await api(`/v1/agents/${agent.id}/files`, { path, content_base64: file.base64 });
    content += `\n\nFichier fourni : ${path}`;
  }
  tasks.set(agent.id, { content });
  res.json({ id: agent.id });
}));

// 2. Les événements de l'agent, relayés tels quels à la page (la clé reste ici)
app.get("/api/tasks/:id/events", route(async (req, res) => {
  if (!find(req, res)) return;
  const ctrl = new AbortController();
  res.on("close", () => ctrl.abort());
  const upstream = await fetch(`${BASE}/v1/agents/${req.params.id}/events`, { headers: auth, signal: ctrl.signal });
  if (!upstream.ok) return res.status(upstream.status).end();
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store" });
  try { for await (const chunk of upstream.body) res.write(chunk); } catch { /* page fermée */ }
  res.end();
}));

// 3. Lancer la tâche, une fois que la page écoute (pour ne rater aucun événement)
app.post("/api/tasks/:id/run", route(async (req, res) => {
  const t = find(req, res); if (!t) return;
  if (!t.started) {
    t.started = true;
    const run = await api(`/v1/agents/${req.params.id}/messages`, { content: t.content, wait: false });
    t.messageId = run.message_id;
  }
  res.json({ ok: true });
}));

// Répondre à l'agent (par exemple après avoir pris la main pour une connexion)
app.post("/api/tasks/:id/reply", route(async (req, res) => {
  if (!find(req, res)) return;
  await api(`/v1/agents/${req.params.id}/messages`, { content: req.body.text, wait: false });
  res.json({ ok: true });
}));

// 4. L'écran en direct : seule l'URL signée part vers la page
app.post("/api/tasks/:id/live", route(async (req, res) => {
  if (!find(req, res)) return;
  const live = await api(`/v1/agents/${req.params.id}/computer/live`, {
    interactive: req.query.interactive === "1",
    ttl_seconds: 1800,
  });
  res.json({ url: live.url });
}));

app.post("/api/tasks/:id/stop", route(async (req, res) => {
  if (!find(req, res)) return;
  await api(`/v1/agents/${req.params.id}/stop`, {});
  res.json({ ok: true });
}));

// 5. Le résultat : dernière réponse, livrable rapatrié, puis ordinateur éteint
app.get("/api/tasks/:id/result", route(async (req, res) => {
  const t = find(req, res); if (!t) return;
  const id = req.params.id;
  if (!t.result) {
    const messages = await messagesAfter(id, t.messageId);
    const output = [...messages].reverse().find((m) => m.role === "assistant" && m.content)?.content ?? "";
    const path = /LIVRABLE:\s*(~\/Deliverables\/[^\s`*]+)/.exec(output)?.[1];
    if (path) {
      const r = await fetch(`${BASE}/v1/agents/${id}/files?path=${encodeURIComponent(path)}`, { headers: auth });
      if (r.ok) t.file = { name: path.split("/").pop(), data: Buffer.from(await r.arrayBuffer()) };
    }
    await api(`/v1/agents/${id}/computer/stop`, {});
    t.result = { output, file: t.file?.name ?? null };
  }
  res.json(t.result);
}));

app.get("/api/tasks/:id/file", (req, res) => {
  const f = tasks.get(req.params.id)?.file;
  if (!f) return res.sendStatus(404);
  res.attachment(f.name).send(f.data);
});

app.listen(3000, () => console.log("http://localhost:3000"));

Deux détails comptent. D'abord, la tâche n'est envoyée (/run) qu'une fois la page abonnée aux événements : sinon les premiers statuts passeraient avant que quelqu'un écoute. Ensuite, le livrable est copié sur ton serveur avant d'éteindre l'ordinateur : la route GET /v1/agents/{id}/files rallume l'ordinateur s'il est éteint, autant tout récupérer d'un coup.

La page

Une seule page, sans framework. Le navigateur ne peut pas envoyer d'en-tête Authorization avec EventSource, c'est pourquoi il écoute /api/tasks/:id/events sur ton serveur et non l'API directement.

html
<!-- public/index.html -->
<!doctype html>
<html lang="fr">
<meta charset="utf-8">
<title>Mon agent</title>
<style>
  body { font: 15px/1.5 system-ui, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 16px; }
  textarea { width: 100%; height: 90px; }
  iframe { width: 100%; aspect-ratio: 16/10; border: 0; border-radius: 12px; background: #111; }
  #log { font: 13px ui-monospace, monospace; color: #555; max-height: 220px; overflow: auto; }
  pre { white-space: pre-wrap; background: #f4f4f4; padding: 12px; border-radius: 8px; }
</style>
<h1>Mon agent</h1>
<textarea id="prompt" placeholder="Ex. : liste les 15 principaux salons tech européens de 2027 avec dates, ville et prix, en CSV"></textarea>
<p>
  <input type="file" id="file">
  <button id="go">Lancer</button>
  <button id="stop" hidden>Arrêter</button>
  <button id="resume" hidden>C'est fait, continue</button>
</p>
<p id="status"></p>
<iframe id="screen" hidden></iframe>
<div id="log"></div>
<pre id="output" hidden></pre>
<p><a id="download" hidden></a></p>

<script type="module">
const $ = (s) => document.querySelector(s);
const post = (url, body) => fetch(url, {
  method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}),
}).then((r) => r.json());
const log = (text) => { const d = document.createElement("div"); d.textContent = text; $("#log").prepend(d); };
const toBase64 = (f) => new Promise((ok) => {
  const r = new FileReader(); r.onload = () => ok(r.result.split(",")[1]); r.readAsDataURL(f);
});

$("#go").onclick = async () => {
  const f = $("#file").files[0];
  $("#go").disabled = true;
  $("#status").textContent = "Préparation de l'agent…";
  const file = f && { name: f.name, base64: await toBase64(f) };
  const { id, error } = await post("/api/tasks", { prompt: $("#prompt").value, file });
  if (error) { $("#status").textContent = error; $("#go").disabled = false; return; }
  follow(id);
};

function follow(id) {
  const events = new EventSource(`/api/tasks/${id}/events`);
  let started = false, working = false, mode = "";
  const showScreen = async (interactive) => {
    const want = interactive ? "main" : "regard";
    if (mode === want) return;
    mode = want;
    const { url } = await post(`/api/tasks/${id}/live${interactive ? "?interactive=1" : ""}`);
    $("#screen").src = url; $("#screen").hidden = false;
  };
  $("#stop").hidden = false;
  $("#stop").onclick = () => post(`/api/tasks/${id}/stop`);
  $("#resume").onclick = () => {
    $("#resume").hidden = true;
    post(`/api/tasks/${id}/reply`, { text: "C'est fait, tu peux continuer." });
  };

  events.addEventListener("ready", () => {
    if (!started) { started = true; post(`/api/tasks/${id}/run`); }
  });
  events.addEventListener("computer", (e) => {
    if (JSON.parse(e.data).status === "running") showScreen(false);
  });
  events.addEventListener("message", (e) => {
    const m = JSON.parse(e.data);
    if (m.role === "tool") log(`${m.tool.ok ? "ok    " : "échec "} ${m.tool.name} · ${m.tool.summary ?? ""}`);
  });
  events.addEventListener("status", (e) => {
    const s = JSON.parse(e.data);
    $("#status").textContent = s.text ?? s.status;
    if (s.status === "working") { working = true; showScreen(false); }
    if (s.status === "blocked") {
      $("#status").textContent = "L'agent a besoin de toi : prends la main dans l'écran, puis clique sur « C'est fait ».";
      $("#resume").hidden = false;
      showScreen(true);
    }
    if (s.status === "idle" && working) finish(id, events);
  });
}

async function finish(id, events) {
  events.close();
  $("#stop").hidden = true; $("#resume").hidden = true;
  $("#status").textContent = "Terminé, récupération du livrable…";
  const { output, file, error } = await fetch(`/api/tasks/${id}/result`).then((r) => r.json());
  $("#output").textContent = error ?? output;
  $("#output").hidden = false;
  if (file) {
    const a = $("#download");
    a.href = `/api/tasks/${id}/file`; a.download = file; a.textContent = `Télécharger ${file}`; a.hidden = false;
  }
  $("#status").textContent = "Terminé.";
  $("#go").disabled = false;
}
</script>

Lance le tout :

bash
BAGUETTE_API_KEY=bb_live_… node server.js

Ouvre http://localhost:3000, écris une tâche qui demande un fichier (« fais-en un CSV », « rédige un rapport en Markdown »), et regarde l'agent ouvrir Chrome et travailler.

Ce que la page reçoit pendant la tâche

Le flux GET /v1/agents/{id}/events envoie des événements nommés, que EventSource sépare tout seul :

  • status : working, idle ou blocked, avec un texte qui dit ce que fait l'agent. C'est la ligne de statut de la page, et le passage de working à idle déclenche la fin.
  • message : chaque nouveau message. Ceux de rôle tool portent tool.name, tool.ok et tool.summary, ce qui donne le journal des actions (navigateur, terminal, fichiers).
  • computer : l'ordinateur change d'état. Dès qu'il est running, on demande le lien d'écran.
  • delta : la réponse en train de s'écrire, morceau par morceau. On ne l'utilise pas ici, mais c'est ce qu'il faut pour afficher le résumé final en streaming.

Un commentaire : ping arrive toutes les 15 s : le relais le transmet tel quel, ce qui garde la connexion ouverte à travers la plupart des proxys.

Montrer l'écran en direct

POST /v1/agents/{id}/computer/live renvoie une URL signée et temporaire (ici 30 minutes) qu'on met dans une iframe. Elle ne contient pas ta clé, c'est la seule chose de l'API qui arrive dans le navigateur. Par défaut l'écran est en lecture seule.

Quand l'agent passe en blocked (une connexion à un compte, un captcha), la page redemande un lien avec interactive: true : ton utilisateur prend la souris et le clavier dans l'iframe, fait ce qu'il faut, puis clique sur « C'est fait, continue », qui envoie un nouveau message à l'agent. Comme les sessions du navigateur de l'agent sont gardées, une connexion faite une fois reste valable pour la suite de la tâche.

Les tâches de plus de 120 secondes

Une vraie tâche d'agent dure souvent plusieurs minutes. Avec wait: true, POST /v1/agents/{id}/messages attend au maximum 120 s, puis répond avec status: "working" si l'agent n'a pas fini. L'app ci-dessus évite la question : elle envoie la tâche avec wait: false (réponse 202 immédiate) et suit les événements, qui n'ont pas de limite de durée. Si la connexion tombe, EventSource se reconnecte tout seul, et le drapeau started empêche de relancer la tâche.

Pour un script sans navigateur (un traitement de nuit, un test), voici la variante qui marche quelle que soit la durée : on attend 120 s, puis on interroge l'agent jusqu'à ce qu'il ne soit plus working, et on relit la conversation à partir du message_id renvoyé.

javascript
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function runTask(agentId, content) {
  const run = await api(`/v1/agents/${agentId}/messages`, { content, wait: true });
  if (run.status === "done") return run.output;
  // Toujours en cours après 120 s : on attend la fin
  while ((await api(`/v1/agents/${agentId}`)).status === "working") await sleep(5000);
  const messages = await messagesAfter(agentId, run.message_id);
  return [...messages].reverse().find((m) => m.role === "assistant" && m.content)?.content ?? null;
}

Une interrogation toutes les 5 s reste très loin des 600 requêtes par minute autorisées par adresse IP.

Coûts

Il n'y a pas d'abonnement : tu paies sur un solde prépayé en euros.

  • L'ordinateur coûte 2 € de l'heure tant qu'il est allumé, facturé à la seconde. Une tâche de 3 minutes, c'est donc 0,10 € d'ordinateur. Le dépôt du fichier d'entrée allume l'ordinateur un peu avant la tâche, et l'app l'éteint dès que le livrable est récupéré ; sans cet arrêt, il resterait allumé idle_minutes (3 ici) de plus.
  • Le modèle est facturé selon les tokens consommés, au prix par million de tokens indiqué par GET /v1/models. Une tâche qui lit beaucoup de pages consomme plus qu'une tâche courte : regarde les prix du modèle que tu choisis et la console, qui détaille ce que coûte chaque agent.

GET /v1/account renvoie ton solde (balance_eur). S'il est vide, l'API répond 402 insufficient_balance : affiche un message clair plutôt qu'une erreur brute.

Aller plus loin

  • Choisir le modèle. Le modèle par défaut est anthropic/claude-haiku-5.5. GET /v1/models liste plus de 300 modèles avec leur prix et un indicateur vision (le modèle voit l'écran). Passe l'identifiant choisi dans model à la création de l'agent, ou propose un menu à tes utilisateurs.
  • Des comptes utilisateurs. L'API ne gère pas tes utilisateurs : associe chaque tâche à l'un d'eux avec metadata (par exemple { user_id: "u_42" }) et vérifie côté serveur que l'utilisateur connecté est bien le propriétaire de l'agent avant chaque route.
  • Faire le ménage. Un compte a au plus 200 agents. Une fois le livrable récupéré, supprime l'agent avec await api(`/v1/agents/${id}`, null, "DELETE") (irréversible : fichiers compris), ou garde un agent par utilisateur et envoie-lui les tâches suivantes pour qu'il réutilise ses fichiers.
  • Les tâches planifiées. L'API n'a pas de planificateur : lance runTask depuis ton propre cron (node-cron, un timer systemd, GitHub Actions).
  • Prévenir quand c'est fini. Pour une tâche longue lancée en arrière-plan, envoie la notification par ton propre canal (Telegram Bot API, webhook Slack, ton service de push). Les agents n'envoient pas d'e-mails eux-mêmes.
  • Une suite de conversation. Les agents reprennent la conversation là où elle en est : un champ « Et maintenant… » qui appelle /reply permet de demander une version PDF ou une correction sans repartir de zéro.

FAQ

Est-ce que je peux utiliser cette app pour des clients ?

Oui, c'est fait pour ça : l'app est à toi, avec ton interface et ton nom. Garde la clé côté serveur, mets une authentification devant les routes /api, et respecte les conditions d'utilisation : pas de spam, de fraude, de collecte abusive de données personnelles ni d'attaque de sites.

Quels fichiers l'agent peut-il produire ?

Tout ce qu'un ordinateur Linux avec un terminal sait produire : Markdown, CSV, HTML, JSON, du code, des archives .zip. GET /v1/agents/{id}/files renvoie le fichier en binaire, quel que soit son type. Dans l'autre sens, POST /v1/agents/{id}/files accepte des fichiers d'environ 30 Mo au maximum.

Pourquoi un agent par tâche ?

Chaque tâche démarre sur un ordinateur propre, sans fichiers d'une autre tâche, ce qui ressemble au fonctionnement de Manus. Si tu préfères qu'un utilisateur retrouve ses fichiers et ses connexions d'une tâche à l'autre, garde un agent par utilisateur : son ordinateur et sa mémoire persistent jusqu'à sa suppression.

Où tournent les ordinateurs des agents ?

Ils sont hébergés à Paris. Chaque agent a son propre ordinateur, isolé de celui des autres agents.

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 à Manus.

PartagerXLinkedInReddit