Build a Manus clone with the Baguette Bot API: one task, one computer, one deliverable

Node.js tutorial: a web app where you describe a task, an agent opens its own computer, works while you watch, and hands back a file to download.

By the Baguette Bot team 14 min read
Manus clone
Contents
  1. What we're building
  2. What you need
  3. The server
  4. The page
  5. What the page receives during a task
  6. Show the live screen
  7. Tasks longer than 120 seconds
  8. Costs
  9. Going further
  10. FAQ

In this tutorial we build a small web app in the spirit of Manus: you type a task, attach a file if needed, and an AI agent takes care of it on its own cloud computer. While it works, the page shows what it is doing step by step, along with its live screen. When it's done, you read its summary and download the deliverable it produced (a Markdown report, a CSV, an archive of code). The whole thing fits in a server.js of about 120 lines and one HTML page.

What we're building

Manus popularised a simple idea: you give the agent a task, it opens its own cloud computer, browses, writes code, builds files and comes back with a result. It runs on credits and background tasks, and it is mostly used for research and deliverables such as reports or small web apps. We are not copying its interface or its brand here: we build your own app on the same idea, using the Baguette Bot API for the agent and its computer.

The flow:

  1. The page sends the task (and an optional file) to your server.
  2. The server creates an agent, uploads the file to its computer, then relays its live events to the page.
  3. The page shows the status, each of the agent's actions and its screen in an iframe.
  4. At the end, the server reads the last answer, fetches the file produced in ~/Deliverables/ and shuts the computer down.

Each task gets its own agent, so its own Linux computer (1280×800 desktop, Chrome, terminal), isolated from the others and hosted in Paris.

What you need

  • Node 18 or later (for built-in fetch).
  • An API key created in the console. It starts with bb_live_ and stays on your server, never in the page.
  • About €1 of test budget. The prepaid balance starts with €1 free once your email is verified, which covers several short tasks.
bash
mkdir my-agent && cd my-agent
npm init -y && npm pkg set type=module
npm i express
mkdir public

The server

The server does five things: create the task, relay the event stream, start the task, create the screen link, and bring back the result. The agent's instructions ask it to save its deliverable in ~/Deliverables/ and to give the path on a recognisable last line, so the server knows which file to fetch.

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 = `You are an autonomous agent working on your own computer (Chrome, terminal, files).
Carry the task through to the end without asking questions, unless you are really stuck.
Save the final deliverable in the ~/Deliverables/ folder (Markdown, CSV, HTML, code or a .zip archive).
End your answer with a short summary, then a last line exactly in this form:
DELIVERABLE: ~/Deliverables/file-name.ext`;

// agentId -> { content, started, messageId, result, file }. In memory: use a database in 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: "Unknown task" });
  return t;
};

// The whole conversation after a message, page by page (200 messages max per call)
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. New task: a fresh agent, and the input file uploaded to its computer
app.post("/api/tasks", route(async (req, res) => {
  const { prompt, file } = req.body;
  if (!prompt?.trim()) return res.status(400).json({ error: "Describe the task" });
  const agent = await api("/v1/agents", {
    name: prompt.slice(0, 80),
    instructions: INSTRUCTIONS,
    idle_minutes: 3,
    metadata: { app: "my-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\nInput file: ${path}`;
  }
  tasks.set(agent.id, { content });
  res.json({ id: agent.id });
}));

// 2. The agent's events, relayed as is to the page (the key stays here)
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 closed */ }
  res.end();
}));

// 3. Start the task once the page is listening (so no event is missed)
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 });
}));

// Reply to the agent (for example after taking over for a login)
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. The live screen: only the signed URL goes to the 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. The result: last answer, deliverable brought back, then the computer shut down
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 = /DELIVERABLE:\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"));

Two details matter. First, the task is only sent (/run) once the page is subscribed to the events: otherwise the first statuses would go by before anyone listens. Second, the deliverable is copied to your server before the computer is shut down: GET /v1/agents/{id}/files boots the computer again if it's off, so you might as well fetch everything in one go.

The page

A single page, no framework. The browser can't send an Authorization header with EventSource, which is why it listens to /api/tasks/:id/events on your server rather than to the API directly.

html
<!-- public/index.html -->
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>My 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>My agent</h1>
<textarea id="prompt" placeholder="E.g. list the 15 main European tech conferences of 2027 with dates, city and price, as a CSV"></textarea>
<p>
  <input type="file" id="file">
  <button id="go">Run</button>
  <button id="stop" hidden>Stop</button>
  <button id="resume" hidden>Done, carry on</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 = "Preparing the 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 ? "control" : "watch";
    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: "Done, you can carry on." });
  };

  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    " : "failed"} ${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 = "The agent needs you: take over in the screen, then click “Done, carry on”.";
      $("#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 = "Done, fetching the deliverable…";
  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 = `Download ${file}`; a.hidden = false;
  }
  $("#status").textContent = "Done.";
  $("#go").disabled = false;
}
</script>

Run it:

bash
BAGUETTE_API_KEY=bb_live_… node server.js

Open http://localhost:3000, type a task that asks for a file ("make it a CSV", "write a report in Markdown"), and watch the agent open Chrome and get to work.

What the page receives during a task

The GET /v1/agents/{id}/events stream sends named events, which EventSource splits out for you:

  • status: working, idle or blocked, with a text saying what the agent is doing. This is the page's status line, and the switch from working to idle triggers the end.
  • message: every new message. Those with the tool role carry tool.name, tool.ok and tool.summary, which make up the action log (browser, terminal, files).
  • computer: the computer changes state. As soon as it is running, we ask for the screen link.
  • delta: the answer being written, piece by piece. We don't use it here, but it's what you need to stream the final summary.

A : ping comment arrives every 15 s: the relay passes it through, which keeps the connection open across most proxies.

Show the live screen

POST /v1/agents/{id}/computer/live returns a signed, expiring URL (30 minutes here) that goes into an iframe. It doesn't contain your key: it's the only thing from the API that reaches the browser. The screen is view-only by default.

When the agent switches to blocked (signing in to an account, a captcha), the page asks for a new link with interactive: true: your user takes the mouse and keyboard in the iframe, does what's needed, then clicks "Done, carry on", which sends a new message to the agent. Since the agent's browser sessions are kept, a login done once stays valid for the rest of the task.

Tasks longer than 120 seconds

A real agent task often takes several minutes. With wait: true, POST /v1/agents/{id}/messages waits at most 120 s, then answers with status: "working" if the agent isn't done. The app above sidesteps the issue: it sends the task with wait: false (an immediate 202) and follows the events, which have no time limit. If the connection drops, EventSource reconnects on its own, and the started flag prevents the task from being sent twice.

For a script without a browser (a nightly job, a test), here is the variant that works whatever the duration: wait 120 s, then poll the agent until it's no longer working, and read the conversation from the returned message_id.

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;
  // Still running after 120 s: wait for the end
  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;
}

Polling every 5 s stays far below the 600 requests per minute allowed per IP address.

Costs

There is no subscription: you pay from a prepaid balance in euros.

  • The computer costs €2 per hour while it's on, billed per second. A 3-minute task is therefore €0.10 of computer time. Uploading the input file boots the computer slightly before the task, and the app shuts it down as soon as the deliverable is fetched; without that, it would stay on for idle_minutes (3 here) longer.
  • The model is billed by tokens used, at the price per million tokens shown by GET /v1/models. A task that reads many pages uses more than a short one: check the prices of the model you pick, and the console, which shows what each agent costs.

GET /v1/account returns your balance (balance_eur). When it's empty, the API answers 402 insufficient_balance: show a clear message rather than a raw error.

Going further

  • Pick the model. The default model is anthropic/claude-haiku-5.5. GET /v1/models lists 300+ models with their price and a vision flag (the model sees the screen). Pass the chosen id as model when creating the agent, or offer a menu to your users.
  • User accounts. The API doesn't manage your users: tie each task to one of them with metadata (for example { user_id: "u_42" }) and check on the server that the signed-in user owns the agent before every route.
  • Clean up. An account has at most 200 agents. Once the deliverable is fetched, delete the agent with await api(`/v1/agents/${id}`, null, "DELETE") (cannot be undone: files included), or keep one agent per user and send it the next tasks so it reuses its files.
  • Scheduled tasks. The API has no scheduler: call runTask from your own cron (node-cron, a systemd timer, GitHub Actions).
  • Notify when it's done. For a long task started in the background, send the notification through your own channel (Telegram Bot API, a Slack webhook, your push service). Agents don't send emails themselves.
  • Follow-ups. Agents pick up the conversation where it left off: a "Now…" field calling /reply lets the user ask for a PDF version or a fix without starting over.

FAQ

Can I use this app for clients?

Yes, that's the point: the app is yours, with your interface and your name. Keep the key on the server, put authentication in front of the /api routes, and follow the terms of use: no spam, fraud, abusive collection of personal data or attacks on websites.

What files can the agent produce?

Anything a Linux computer with a terminal can produce: Markdown, CSV, HTML, JSON, code, .zip archives. GET /v1/agents/{id}/files returns the file as binary, whatever its type. The other way round, POST /v1/agents/{id}/files accepts files of up to about 30 MB.

Why one agent per task?

Each task starts on a clean computer, with no files from another task, which is close to how Manus works. If you'd rather have a user find their files and logins from one task to the next, keep one agent per user: its computer and memory persist until you delete it.

Where do the agents' computers run?

They are hosted in Paris. Each agent has its own computer, isolated from other agents.

Next steps: the API documentation, the console to create your key, the API overview, the general tutorial build an AI agent app with the API, and our Manus alternative comparison.

ShareXLinkedInReddit