Dans ce tutoriel, on construit une petite app personnelle autour d'un agent principal que tu nommes et personnalises. Tu lui confies des tâches qui tournent en arrière-plan, tu regardes son écran dans une carte en direct, tu récupères les fichiers qu'il produit directement dans la page, et tu choisis son modèle d'IA dans une liste. Un server.js et une page HTML, sur l'API Baguette Bot.
Ce qu'on construit
Le 29 septembre 2026, à son DevDay, OpenAI a présenté les dots : des agents personnels intégrés à ChatGPT. Chaque utilisateur reçoit un « primary dot » qu'il nomme et personnalise, avec son propre ordinateur cloud et son navigateur, et qui travaille en arrière-plan. Les dots ne fonctionnent qu'avec le modèle d'OpenAI, et ils ne sont pas proposés sur les offres personnelles dans l'Espace économique européen, au Royaume-Uni et en Suisse.
On ne reprend ni le nom ni l'apparence : on construit ta propre app sur la même idée. Elle a cinq parties :
- Un agent principal créé à la première visite, que tu renommes et dont tu changes la personnalité à tout moment (
PATCH /v1/agents/{id}). - Des tâches en arrière-plan, mises en file d'attente et envoyées avec
wait: false. - Une carte « écran en direct », en lecture seule ou en mode prise en main.
- Les fichiers produits par l'agent, téléchargés depuis son ordinateur avec
GET /v1/agents/{id}/fileset affichés dans la page (les images en aperçu). - Le choix du modèle, parmi la liste de
GET /v1/models, avec le filtrevision.
Comme toujours, le navigateur parle à ton serveur, et seul ton serveur parle à l'API avec ta clé.
Ce qu'il te faut
- Node 18 ou plus récent.
- Une clé API créée dans la console (elle commence par
bb_live_). 1 € est offert pour essayer une fois ton e-mail vérifié.
mkdir mon-dot && cd mon-dot
npm init -y && npm pkg set type=module
npm i express@5
mkdir public
export BAGUETTE_API_KEY=bb_live_...Colle les blocs suivants dans l'ordre dans server.js. L'app est pensée pour une personne, sur ta machine : ajoute ta propre authentification avant de la mettre en ligne.
Étape 1 : le client API et l'état
// server.js (Node 18+, npm i express@5)
import express from "express";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
const BASE = "https://api.baguettebot.com";
const KEY = process.env.BAGUETTE_API_KEY;
const OUT = "~/Livrables"; // le dossier où l'agent range ce qu'il produit
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 STATE = "dot.json";
const state = existsSync(STATE) ? JSON.parse(readFileSync(STATE, "utf8")) : { agentId: null, personality: "", tasks: [] };
const save = () => writeFileSync(STATE, JSON.stringify(state, null, 2));
const app = express();
app.use(express.json());
app.use(express.static("public"));Étape 2 : un agent que tu nommes et personnalises
La première fois, POST /v1/agents crée l'agent ; ensuite, la même route fait un PATCH sur name, instructions et model. La personnalité que tu écris est complétée par des règles de travail fixes, dont une qui compte pour la suite : ranger chaque fichier produit dans ~/Livrables/ et citer son chemin. C'est ce qui permet à ton serveur de les retrouver.
metadata: { role: "primary" } marque l'agent principal : si tu ajoutes plus tard des agents secondaires, tu le retrouves avec GET /v1/agents.
const rules = (personality) => `${personality}
Façon de travailler :
- Tu travailles en arrière-plan sur ton propre ordinateur Linux (Chrome, terminal, fichiers).
- Enregistre chaque fichier produit pour l'utilisateur (PDF, CSV, image, page web) dans ${OUT}/, avec un nom court, sans espaces ni accents.
- À la fin, résume ce que tu as fait et cite le chemin complet de chaque fichier, par exemple ${OUT}/budget.csv.
- Tu n'achètes rien, tu ne crées pas de compte, et tu demandes avant toute action irréversible.`;
app.get("/api/dot", async (req, res) => {
if (!state.agentId) return res.json({ agent: null, tasks: [] });
const a = await api(`/v1/agents/${state.agentId}`);
res.json({
agent: { name: a.name, model: a.model, personality: state.personality, status: a.status, statusText: a.status_text, computer: a.computer.status },
tasks: state.tasks.slice(-20).reverse(),
});
});
app.post("/api/dot", async (req, res) => {
const personality = String(req.body.personality ?? "").slice(0, 5000) || "Tu es mon agent personnel. Tu me tutoies et tu vas droit au but.";
const body = { name: String(req.body.name ?? "").trim().slice(0, 80) || "Dot", instructions: rules(personality) };
if (req.body.model) body.model = req.body.model;
const a = state.agentId
? await api(`/v1/agents/${state.agentId}`, body, "PATCH")
: await api("/v1/agents", { ...body, idle_minutes: 5, metadata: { role: "primary" } });
Object.assign(state, { agentId: a.id, personality });
save();
res.json({ ok: true });
});Étape 3 : le choix du modèle
GET /v1/models renvoie plus de 300 modèles avec leur prix en euros par million de tokens et un drapeau vision. Un modèle avec vision: true voit l'écran de son ordinateur ; les autres travaillent à partir du texte des pages, ce qui suffit pour la plupart des tâches web. Pour un agent qui retouche des images ou lit des graphiques, prends un modèle vision. On garde la liste en mémoire pour ne pas la redemander à chaque visite.
let models;
app.get("/api/models", async (req, res) => {
models ??= (await api("/v1/models")).data
.map((m) => ({ id: m.id, vision: m.vision, input: m.eur_per_million_input_tokens, output: m.eur_per_million_output_tokens }))
.sort((a, b) => a.id.localeCompare(b.id));
res.json(models);
});Si l'identifiant envoyé n'existe pas, l'API répond 400 invalid_model, et la page affiche le message.
Étape 4 : des tâches en arrière-plan
Un agent a une seule conversation : on lui donne donc les tâches une par une, dans une file d'attente. Chaque tâche part avec wait: false (réponse 202 avec un message_id), puis followRun() suit le flux d'événements jusqu'à ce que l'agent ne travaille plus. On lit alors les messages arrivés après la demande, avec after. Le flux est relu côté serveur, car EventSource ne peut pas envoyer l'en-tête Authorization.
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;
}
app.post("/api/tasks", async (req, res) => {
const content = String(req.body.content ?? "").trim();
if (!state.agentId) return res.status(400).json({ error: "Crée d'abord ton agent" });
if (!content) return res.status(400).json({ error: "Tâche vide" });
const task = { id: Date.now().toString(36), content, status: "queued", statusText: null, output: null, files: [] };
state.tasks.push(task);
save();
pump();
res.status(202).json(task);
});
let busy = false;
async function pump() {
const task = state.tasks.find((t) => t.status === "queued" || t.status === "working");
if (busy || !task) return;
busy = true;
try {
if (task.status === "queued") {
const run = await api(`/v1/agents/${state.agentId}/messages`, { content: task.content, wait: false });
Object.assign(task, { status: "working", messageId: run.message_id });
save();
}
const result = await followRun(state.agentId, task.messageId, 30 * 60_000, (text) => (task.statusText = text));
if (!result) await api(`/v1/agents/${state.agentId}/stop`, {}).catch(() => {});
if (task.status === "working") Object.assign(task, { status: result ? "done" : "timeout", output: result?.output ?? null });
task.files = await keepFiles(task);
} catch (e) {
Object.assign(task, { status: "error", output: e.message });
}
save();
busy = false;
pump(); // la tâche suivante
}Au redémarrage, pump() reprend la tâche en cours là où elle en était : son messageId est enregistré.
Étape 5 : récupérer les fichiers produits
Quand une tâche est finie, on cherche dans la réponse les chemins ~/Livrables/... et on télécharge chaque fichier avec GET /v1/agents/{id}/files?path=…. On les copie tout de suite sur ton serveur : l'ordinateur est encore allumé, et la page peut les servir ensuite sans le réveiller (cette route allume l'ordinateur s'il est éteint, ce qui se facture).
Les images sont servies en aperçu, tout le reste en téléchargement. On ne sert jamais un fichier HTML produit par l'agent comme une page de ton site : il pourrait contenir du script.
const pathsIn = (text) =>
[...new Set((text ?? "").match(new RegExp(`${OUT}/[\\w-][\\w.-]*`, "g")) ?? [])].map((p) => p.replace(/\.+$/, ""));
async function keepFiles(task) {
const saved = [];
for (const path of pathsIn(task.output)) {
const r = await fetch(`${BASE}/v1/agents/${state.agentId}/files?path=${encodeURIComponent(path)}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!r.ok) continue; // l'agent a cité un fichier qui n'existe pas
const name = path.split("/").pop();
mkdirSync(`files/${task.id}`, { recursive: true });
writeFileSync(`files/${task.id}/${name}`, Buffer.from(await r.arrayBuffer()));
saved.push(name);
}
return saved;
}
app.get("/files/:task/:name", (req, res) => {
const { task, name } = req.params;
if (!/^[\w-]+$/.test(task) || !/^[\w-][\w.-]*$/.test(name)) return res.sendStatus(400);
res.set("X-Content-Type-Options", "nosniff");
const opts = { root: `files/${task}` };
/\.(png|jpe?g|webp|gif)$/i.test(name) ? res.sendFile(name, opts) : res.download(name, name, opts);
});Dans l'autre sens, POST /v1/agents/{id}/files avec path et content_base64 (environ 30 Mo au plus) dépose un fichier sur l'ordinateur de l'agent ; cite ensuite son chemin dans la tâche.
Étape 6 : l'écran en direct, et l'arrêt
POST /v1/agents/{id}/computer/live crée un lien signé, valable ici 15 minutes, à mettre dans une iframe. Avec interactive: false, tu regardes ; avec interactive: true, tu prends la souris et le clavier, par exemple pour te connecter à un site à la place de l'agent. Le lien ne contient pas ta clé. Si l'ordinateur est éteint, on l'allume d'abord.
app.post("/api/live", async (req, res) => {
const id = state.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: !!req.body.interactive, ttl_seconds: 900 });
res.json({ url: live.url });
});
app.post("/api/stop", async (req, res) => {
for (const t of state.tasks) if (t.status === "queued" || t.status === "working") t.status = "stopped";
save();
await api(`/v1/agents/${state.agentId}/stop`, {});
res.json({ ok: true });
});
app.use((err, req, res, next) => res.status(500).json({ error: err.message }));
pump();
app.listen(3000, () => console.log("http://localhost:3000"));La page
public/index.html : la carte de personnalisation, le champ de tâche, la carte d'écran en direct et la liste des tâches avec leurs fichiers. Tout le texte venant de l'agent passe par textContent.
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mon agent principal</title>
<style>
body { margin: 0 auto; max-width: 760px; padding: 16px; font: 16px/1.45 system-ui, sans-serif; background: #f4f4f6; color: #1d1d1f; }
h1 { font-size: 22px; margin: 4px 0 16px; }
.card { background: #fff; border-radius: 16px; padding: 16px; margin-bottom: 16px; }
label { display: block; font-size: 13px; color: #666; margin: 10px 0 4px; }
input, textarea, select { width: 100%; box-sizing: border-box; font: inherit; padding: 10px; border: 1px solid #ccc; border-radius: 10px; }
input[type=checkbox] { width: auto; }
button { font: inherit; padding: 9px 14px; border: 0; border-radius: 10px; background: #1d1d1f; color: #fff; margin-top: 10px; }
iframe { width: 100%; aspect-ratio: 16 / 10; border: 0; border-radius: 12px; margin-top: 10px; background: #000; }
pre { white-space: pre-wrap; font: inherit; margin: 8px 0 0; }
.muted { color: #777; font-size: 14px; }
.files img { display: block; max-width: 100%; border-radius: 10px; margin-top: 8px; }
</style>
</head>
<body>
<h1 id="pageTitle">Ton agent</h1>
<div class="card">
<label>Son nom</label><input id="dotName" placeholder="Ex. Pixel">
<label>Sa personnalité et tes consignes</label>
<textarea id="personality" rows="4" placeholder="Tu me tutoies, tu es direct, tu cites tes sources."></textarea>
<label>Modèle (<input type="checkbox" id="visionOnly"> vision seulement)</label><select id="modelSelect"></select>
<button id="saveBtn">Enregistrer</button>
</div>
<form class="card" id="taskForm">
<label>Une tâche pour ton agent</label>
<textarea id="taskText" rows="3" placeholder="Compare les vols Paris-Lisbonne du 10 au 17 mai et fais-moi un CSV."></textarea>
<button>Lancer en arrière-plan</button> <button type="button" id="haltBtn">Tout arrêter</button>
</form>
<div class="card">
<div class="muted" id="computerState">Ordinateur : -</div>
<button id="watchBtn">Voir l'écran</button> <button id="driveBtn">Prendre la main</button>
<div id="live"></div>
</div>
<div id="taskList"></div>
<script>
const call = (path, body) => fetch(path, { method: body ? "POST" : "GET", headers: { "Content-Type": "application/json" }, body: body && JSON.stringify(body) })
.then(async (r) => { const j = await r.json(); if (!r.ok) throw new Error(j.error); return j; });
const el = (tag, props, ...kids) => { const e = Object.assign(document.createElement(tag), props); e.append(...kids); return e; };
const LABELS = { queued: "En attente", working: "En cours", done: "Terminé", timeout: "Trop long, arrêté", stopped: "Arrêté", error: "Erreur" };
let models = [], current = "anthropic/claude-haiku-5.5", last = "";
function renderModels() {
modelSelect.replaceChildren(...models.filter((m) => !visionOnly.checked || m.vision).map((m) => el("option", {
value: m.id, selected: m.id === current,
textContent: `${m.id}${m.vision ? " · vision" : ""} · ${m.input} € / ${m.output} € par million de tokens`,
})));
}
async function refresh() {
const { agent, tasks } = await call("/api/dot");
if (!agent) return;
pageTitle.textContent = agent.name;
computerState.textContent = `Ordinateur : ${agent.computer}` + (agent.statusText ? ` · ${agent.statusText}` : "");
const key = JSON.stringify(tasks);
if (key === last) return;
last = key;
taskList.replaceChildren(...tasks.map((t) => {
const files = el("div", { className: "files" }, ...t.files.map((f) => {
const href = `/files/${t.id}/${encodeURIComponent(f)}`;
return /\.(png|jpe?g|webp|gif)$/i.test(f) ? el("img", { src: href, alt: f }) : el("div", {}, el("a", { href, textContent: "Télécharger " + f }));
}));
const state = LABELS[t.status] + (t.status === "working" && t.statusText ? " · " + t.statusText : "");
return el("div", { className: "card" }, el("strong", { textContent: t.content }), el("div", { className: "muted", textContent: state }), el("pre", { textContent: t.output ?? "" }), files);
}));
}
saveBtn.onclick = () => call("/api/dot", { name: dotName.value, personality: personality.value, model: modelSelect.value })
.then(() => { current = modelSelect.value; refresh(); }).catch((e) => alert(e.message));
taskForm.onsubmit = (e) => {
e.preventDefault();
call("/api/tasks", { content: taskText.value }).then(() => { taskText.value = ""; refresh(); }).catch((err) => alert(err.message));
};
haltBtn.onclick = () => call("/api/stop", {}).then(refresh);
const showLive = (interactive) => call("/api/live", { interactive })
.then(({ url }) => live.replaceChildren(el("iframe", { src: url }))).catch((e) => alert(e.message));
watchBtn.onclick = () => showLive(false);
driveBtn.onclick = () => showLive(true);
visionOnly.onchange = renderModels;
(async () => {
const [{ agent }, list] = await Promise.all([call("/api/dot"), call("/api/models")]);
models = list;
if (agent) { dotName.value = agent.name; personality.value = agent.personality; current = agent.model; }
renderModels();
refresh();
setInterval(refresh, 3000);
})();
</script>
</body>
</html>Lance node server.js, ouvre http://localhost:3000, donne un nom et une personnalité à ton agent, puis enregistre. Demande-lui par exemple « Fais une page web d'une page qui présente mon club de lecture, et une capture d'écran en PNG » : la capture s'affiche en aperçu dans la tâche, la page HTML en lien de téléchargement.
Montrer l'écran en direct
La carte d'écran sert à deux moments. Pendant une tâche, « Voir l'écran » te montre l'agent au travail, en lecture seule : c'est l'équivalent de regarder ton agent par-dessus son épaule. Quand il bute sur une connexion ou un captcha, « Prendre la main » te donne la souris et le clavier ; tu te connectes, puis tu lui écris « c'est bon, continue ». Ses sessions de navigateur sont conservées pour les tâches suivantes.
Le lien expire après ttl_seconds (entre 60 et 86 400 secondes) : redemande-en un si la carte reste ouverte longtemps.
Coûts
Pas d'abonnement : un solde prépayé en euros. Tu paies les tokens du modèle à son prix (GET /v1/models, affiché dans la liste de la page) et l'ordinateur à 2 € de l'heure, facturé à la seconde. Une tâche de 3 minutes coûte donc 0,10 € d'ordinateur, plus les tokens. Avec idle_minutes: 5, l'ordinateur reste allumé jusqu'à 5 minutes après la dernière activité, soit au plus 0,17 € de plus : c'est le prix d'un agent prêt à enchaîner (« et maintenant exporte-le en PDF »). Baisse idle_minutes avec un PATCH si tu préfères qu'il s'éteigne plus vite.
Attention à deux choses qui allument l'ordinateur : ouvrir la carte d'écran, et télécharger un fichier depuis l'agent. C'est pour ça qu'on copie les fichiers juste après la tâche. Si ton solde est épuisé, l'API répond 402 insufficient_balance ; tu peux lire ton solde avec GET /v1/account.
Aller plus loin
- Plusieurs utilisateurs. Crée un agent principal par compte et relie-le avec
metadata, comme dans le tutoriel du clone de Meta Muse. - Des tâches récurrentes. L'API n'a pas de planificateur : un
node-cronsur ton serveur qui ajoute une tâche à la file chaque lundi suffit. - Des notifications. L'API n'en envoie pas : à la fin de
pump(), appelle ton canal (l'API Bot de Telegram, un webhook Slack, ton service de push). - Des agents secondaires. Pour une équipe d'agents, c'est ton serveur qui passe les messages de l'un à l'autre. Garde un œil sur la limite de 200 agents par compte.
FAQ
Est-ce que je peux changer de modèle en cours de route ?
Oui. Un PATCH /v1/agents/{id} avec model suffit, et l'agent garde sa conversation, sa mémoire et son ordinateur. Choisis un modèle avec vision: true si la tâche demande de voir l'écran.
Où sont stockés les fichiers de l'agent ?
Sur son ordinateur, hébergé à Paris. Ils restent quand l'ordinateur est éteint, jusqu'à la suppression de l'agent. Ton serveur en garde une copie dans files/.
L'agent peut-il m'envoyer un e-mail quand il a fini ?
Non, les agents n'envoient pas d'e-mails et ne passent pas d'appels. C'est ton serveur qui prévient, par le canal de ton choix.
Que se passe-t-il si je ferme la page pendant une tâche ?
Rien : la tâche est suivie par ton serveur, pas par le navigateur. Tu retrouves le résultat et les fichiers à ta prochaine visite.
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, le clone de Meta Muse et notre comparatif Baguette Bot ou OpenAI Dots.