Build an OpenAI dots clone with the Baguette Bot API: a primary agent with your name on it

Node.js tutorial: a primary agent you name and personalise, background tasks, its live screen, the files it produces, and your choice of model. Full code.

By the Baguette Bot team 15 min read
Dots clone
Contents
  1. What we're building
  2. What you need
  3. Step 1: the API client and the state
  4. Step 2: an agent you name and personalise
  5. Step 3: choosing the model
  6. Step 4: background tasks
  7. Step 5: fetching the files it produces
  8. Step 6: the live screen, and stopping
  9. The page
  10. Show the screen live
  11. Costs
  12. Going further
  13. FAQ

In this tutorial we build a small personal app around one primary agent that you name and personalise. You hand it tasks that run in the background, watch its screen in a live card, get the files it produces right in the page, and pick its AI model from a list. One server.js and one HTML page, on the Baguette Bot API.

What we're building

On 29 September 2026, at its DevDay, OpenAI introduced dots: personal agents built into ChatGPT. Each user gets a "primary dot" they name and customise, with its own cloud computer and browser, which works in the background. Dots run on OpenAI's model only, and they are not offered on personal plans in the European Economic Area, the UK and Switzerland.

We don't reuse the name or the look: we build your own app on the same idea. It has five parts:

  • A primary agent created on the first visit, which you rename and whose personality you change at any time (PATCH /v1/agents/{id}).
  • Background tasks, queued and sent with wait: false.
  • A "live screen" card, watch-only or in take-over mode.
  • The files the agent produces, downloaded from its computer with GET /v1/agents/{id}/files and shown in the page (images as previews).
  • Model choice, from the list in GET /v1/models, with a vision filter.

As always, the browser talks to your server, and only your server talks to the API with your key.

What you need

  • Node 18 or later.
  • An API key created in the console (it starts with bb_live_). You get €1 free to try once your email is verified.
bash
mkdir my-dot && cd my-dot
npm init -y && npm pkg set type=module
npm i express@5
mkdir public
export BAGUETTE_API_KEY=bb_live_...

Paste the following blocks in order into server.js. The app is built for one person, on your machine: add your own authentication before you put it online.

Step 1: the API client and the state

javascript
// 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 = "~/Deliverables"; // the folder where the agent puts what it produces

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"));

Step 2: an agent you name and personalise

The first time, POST /v1/agents creates the agent; after that, the same route sends a PATCH with name, instructions and model. The personality you write is followed by fixed working rules, including one that matters later: save every file it produces in ~/Deliverables/ and name its path. That is how your server finds them.

metadata: { role: "primary" } marks the primary agent: if you add secondary agents later, you find it again with GET /v1/agents.

javascript
const rules = (personality) => `${personality}

How you work:
- You work in the background on your own Linux computer (Chrome, terminal, files).
- Save every file you produce for the user (PDF, CSV, image, web page) in ${OUT}/, with a short name without spaces or accents.
- At the end, sum up what you did and give the full path of each file, for example ${OUT}/budget.csv.
- You never buy anything, never create accounts, and you ask before any irreversible action.`;

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) || "You are my personal agent. You are friendly and get straight to the point.";
  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 });
});

Step 3: choosing the model

GET /v1/models returns more than 300 models with their price in euros per million tokens and a vision flag. A model with vision: true sees its computer's screen; the others work from the pages' text, which is enough for most web tasks. For an agent that edits images or reads charts, pick a vision model. We keep the list in memory so we don't fetch it on every visit.

javascript
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);
});

If the id you send doesn't exist, the API answers 400 invalid_model, and the page shows the message.

Step 4: background tasks

An agent has a single conversation, so we give it tasks one at a time, through a queue. Each task goes out with wait: false (a 202 response with a message_id), then followRun() follows the event stream until the agent stops working. We then read the messages that came after the request, with after. The stream is read on the server, because EventSource can't send the Authorization header.

javascript
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;
  }
}

// Follows the agent until the task started by messageId is over
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; // not started yet
    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); // stream dropped: reconnect
  }
  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: "Create your agent first" });
  if (!content) return res.status(400).json({ error: "Empty task" });
  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(); // the next task
}

On restart, pump() resumes the running task where it was: its messageId is saved.

Step 5: fetching the files it produces

When a task is over, we look for ~/Deliverables/... paths in the answer and download each file with GET /v1/agents/{id}/files?path=…. We copy them to your server straight away: the computer is still on, and the page can serve them later without waking it up (that route boots the computer if it's off, which is billed).

Images are served as previews, everything else as downloads. Never serve an HTML file produced by the agent as a page of your site: it could contain script.

javascript
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; // the agent named a file that doesn't exist
    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);
});

The other way round, POST /v1/agents/{id}/files with path and content_base64 (about 30 MB at most) puts a file on the agent's computer; then name its path in the task.

Step 6: the live screen, and stopping

POST /v1/agents/{id}/computer/live creates a signed link, valid for 15 minutes here, to put in an iframe. With interactive: false you watch; with interactive: true you take the mouse and keyboard, for example to sign in to a site for the agent. The link doesn't contain your key. If the computer is off, we boot it first.

javascript
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"));

The page

public/index.html: the personalisation card, the task field, the live screen card and the task list with their files. All text coming from the agent goes through textContent.

html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My primary agent</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">Your agent</h1>

<div class="card">
  <label>Its name</label><input id="dotName" placeholder="E.g. Pixel">
  <label>Its personality and your instructions</label>
  <textarea id="personality" rows="4" placeholder="Be casual, be direct, cite your sources."></textarea>
  <label>Model (<input type="checkbox" id="visionOnly"> vision only)</label><select id="modelSelect"></select>
  <button id="saveBtn">Save</button>
</div>

<form class="card" id="taskForm">
  <label>A task for your agent</label>
  <textarea id="taskText" rows="3" placeholder="Compare London-Lisbon flights from 10 to 17 May and make me a CSV."></textarea>
  <button>Run in the background</button> <button type="button" id="haltBtn">Stop everything</button>
</form>

<div class="card">
  <div class="muted" id="computerState">Computer: -</div>
  <button id="watchBtn">Watch the screen</button> <button id="driveBtn">Take over</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: "Queued", working: "Working", done: "Done", timeout: "Took too long, stopped", stopped: "Stopped", error: "Error" };
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} per million tokens`,
  })));
}

async function refresh() {
  const { agent, tasks } = await call("/api/dot");
  if (!agent) return;
  pageTitle.textContent = agent.name;
  computerState.textContent = `Computer: ${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: "Download " + 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>

Run node server.js, open http://localhost:3000, give your agent a name and a personality, then save. Ask it for example "Make a one-page website presenting my book club, plus a PNG screenshot of it": the screenshot shows up as a preview in the task, the HTML page as a download link.

Show the screen live

The screen card is useful at two moments. During a task, "Watch the screen" shows the agent at work, watch-only: it's like looking over your agent's shoulder. When it hits a login or a captcha, "Take over" gives you the mouse and keyboard; you sign in, then write "done, carry on". Its browser sessions are kept for later tasks.

The link expires after ttl_seconds (between 60 and 86,400 seconds): ask for a new one if the card stays open for a long time.

Costs

No subscription: a prepaid balance in euros. You pay model tokens at the model's price (GET /v1/models, shown in the page's list) and the computer at €2 per hour, billed per second. A 3-minute task therefore costs €0.10 of computer time, plus tokens. With idle_minutes: 5, the computer stays on up to 5 minutes after the last activity, so at most €0.17 more: the price of an agent ready for a follow-up ("now export it as a PDF"). Lower idle_minutes with a PATCH if you want it to shut down sooner.

Watch out for two things that boot the computer: opening the screen card, and downloading a file from the agent. That is why we copy the files right after the task. If your balance runs out, the API answers 402 insufficient_balance; you can read your balance with GET /v1/account.

Going further

  • Several users. Create one primary agent per account and link it with metadata, as in the Meta Muse clone tutorial.
  • Recurring tasks. The API has no scheduler: a node-cron job on your server that adds a task to the queue every Monday is enough.
  • Notifications. The API sends none: at the end of pump(), call your own channel (the Telegram Bot API, a Slack webhook, your push service).
  • Secondary agents. For a team of agents, your server passes messages from one to the other. Keep an eye on the limit of 200 agents per account.

FAQ

Can I change models along the way?

Yes. A PATCH /v1/agents/{id} with model is enough, and the agent keeps its conversation, memory and computer. Pick a model with vision: true if the task needs to see the screen.

Where are the agent's files stored?

On its computer, hosted in Paris. They stay while the computer is off, until the agent is deleted. Your server keeps a copy in files/.

Can the agent email me when it's done?

No, agents don't send emails and don't make calls. Your server sends the notification, on the channel you choose.

What happens if I close the page during a task?

Nothing: the task is followed by your server, not by the browser. You find the result and the files on your next visit.

Next steps: the API documentation, the console, the API overview, the main tutorial on building an AI agent app, the Meta Muse clone and our comparison Baguette Bot vs OpenAI Dots.

ShareXLinkedInReddit