Grok Bot, Meta Muse, OpenAI's dots, Manus: behind very different names and interfaces, these products rest on the same building blocks. In this tutorial we take them apart, then build a small chat app that puts them together with the Baguette Bot API: you type a task, the agent's answer appears word by word, and its computer's screen shows up next to it, live. Two files, about a hundred lines, and a base that the other tutorials in this series build on to make their own "app like X".
The building blocks behind all these products
Look at what these products promise. Grok Bot, from xAI and Cursor, is a team of always-on agents that share a cloud computer, sign into websites like a person and hand work over to each other. Meta Muse gives every user an agent on its own cloud virtual machine that keeps working when the app is closed. An OpenAI dot is a personal agent with its own computer and browser that works in the background. Manus popularised the agent you hand a task to, which opens its own computer to browse, write code and produce files.
Strip the interfaces away and six building blocks remain:
| Building block | What it gives you | In the Baguette Bot API |
|---|---|---|
| An agent | A role, a memory, a model | POST /v1/agents |
| Its own computer | Chrome, terminal, files, persistent sessions | Boots on the first task, /computer/start and /stop |
| The live screen | Watch the agent work, take over | POST /computer/live (signed iframe) |
| Files | Hand over a document, fetch a result | POST and GET /files |
| Events | Word-by-word answer, status, computer state | GET /events (SSE) |
| Scheduling, notifications, teams | Routines, alerts, several agents working together | Your side: your cron, your channel, your server |
The first five rows are API routes. The last one is your code: the API has no scheduler, no webhooks and no notification delivery, and its agents don't send emails or make phone calls. That is not a hidden flaw, it is the split of responsibilities. Your server decides when an agent works (node-cron, a systemd timer, a GitHub Action), where the result goes (Telegram, Slack, your own push service) and who talks to whom when you have several agents. For your users, you map each one to their agent with the metadata field and your own database.
On the infrastructure side, every agent has an isolated Linux computer (1280×800 desktop, Chrome, terminal, sudo), hosted in Paris, that boots on its own and shuts down after idle_minutes without activity. Its files stay while it is off.
What you need
- Node 18 or later (
fetchis built in). - An API key, created in the console; it starts with
bb_live_. You get €1 free once your email is verified, more than enough for this tutorial. - Five minutes.
mkdir my-agent-app && cd my-agent-app
npm init -y
npm pkg set type=module
npm i express
mkdir public
export BAGUETTE_API_KEY=bb_live_…The key stays on your server. The web page never sees it: it talks to your server, which talks to the API.
The server: relaying the agent to the browser
The server does four things: create the agent (once), pass messages to it, relay its event stream to the browser, and create the link to its screen. The relay is needed because the browser's EventSource cannot send an Authorization header.
// server.js
import express from "express";
import { Readable } from "node:stream";
const KEY = process.env.BAGUETTE_API_KEY;
const BASE = "https://api.baguettebot.com";
// The small helper from the docs, which also accepts an empty response
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) => {
const text = await r.text();
const data = text ? JSON.parse(text) : {};
if (!r.ok) throw new Error(data.error?.message ?? r.statusText);
return data;
});
// A single agent for the demo. Put its id in AGENT_ID to reuse it.
let agentId = process.env.AGENT_ID;
async function getAgent() {
if (!agentId) {
const agent = await api("/v1/agents", {
name: "Assistant",
instructions: "You work on your computer. Answer in English, concisely.",
idle_minutes: 2,
metadata: { app: "my-agent-app" },
});
agentId = agent.id;
console.log("Agent created:", agentId);
}
return agentId;
}
// Errors in an async route end up as JSON, not as a crash
const route = (fn) => (req, res) =>
fn(req, res).catch((e) => (res.headersSent ? res.end() : res.status(500).json({ error: e.message })));
const app = express();
app.use(express.json());
app.use(express.static("public"));
// 1. Send a task without waiting: the answer arrives through the events
app.post("/api/chat", route(async (req, res) => {
const id = await getAgent();
await api(`/v1/agents/${id}/messages`, { content: String(req.body.text ?? "") });
res.json({ ok: true });
}));
// 2. Relay the agent's SSE stream as is
app.get("/api/events", route(async (req, res) => {
const id = await getAgent();
const ctrl = new AbortController();
req.on("close", () => ctrl.abort());
const upstream = await fetch(`${BASE}/v1/agents/${id}/events`, {
headers: { Authorization: `Bearer ${KEY}` },
signal: ctrl.signal,
});
if (!upstream.ok) return res.status(upstream.status).end();
res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
res.flushHeaders();
Readable.fromWeb(upstream.body).on("error", () => res.end()).pipe(res);
}));
// 3. A link to the screen, only when the computer is on
app.post("/api/live", route(async (req, res) => {
const id = await getAgent();
const computer = await api(`/v1/agents/${id}/computer`);
if (computer.status !== "running") return res.json({ url: null });
const live = await api(`/v1/agents/${id}/computer/live`, { interactive: false, ttl_seconds: 3600 });
res.json({ url: live.url });
}));
app.listen(3000, () => console.log("http://localhost:3000"));Three notes. We send the task without wait: the API answers right away (202) and the agent gets to work. That handles tasks longer than 120 s for free; with wait: true they would come back as status: "working", but here the browser simply follows the stream to the end. Next, the stream is relayed byte for byte, without parsing it: it is already in SSE format, and the : ping sent every 15 s keeps the connection open. Finally, the screen link is created on the server; only its signed, expiring URL goes to the page.
The page: the live answer and the screen
<!-- public/index.html -->
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My agent</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; display: grid; gap: 16px; }
@media (min-width: 900px) { body { grid-template-columns: 1fr 1.4fr; } }
#log div { white-space: pre-wrap; padding: 8px 12px; border-radius: 10px; margin: 6px 0; }
.user { background: #e8f0fe; } .assistant { background: #f3f3f3; }
#status { color: #777; min-height: 1.5em; }
form { display: flex; gap: 8px; } input { flex: 1; padding: 8px; }
iframe { width: 100%; aspect-ratio: 16 / 10; border: 0; border-radius: 12px; background: #111; }
</style>
<body>
<section>
<div id="log"></div>
<p id="status"></p>
<form id="form"><input id="q" autocomplete="off" placeholder="A task for your agent…"><button>Send</button></form>
</section>
<iframe id="screen" title="Agent's screen"></iframe>
<script>
const log = document.getElementById("log");
const statusEl = document.getElementById("status");
const screen = document.getElementById("screen");
const bubbles = new Map();
function bubble(id, role) {
let el = bubbles.get(id);
if (!el) { el = document.createElement("div"); el.className = role; log.append(el); bubbles.set(id, el); }
return el;
}
const events = new EventSource("/api/events");
events.addEventListener("status", (e) => {
const d = JSON.parse(e.data);
statusEl.textContent = d.status === "working" ? (d.text || "Working…") : "";
});
events.addEventListener("delta", (e) => {
const d = JSON.parse(e.data);
bubble(d.message_id, "assistant").textContent += d.text;
});
const onMessage = (e) => {
const m = JSON.parse(e.data);
if (m.role === "assistant" && m.content) bubble(m.id, "assistant").textContent = m.content;
};
events.addEventListener("message", onMessage);
events.addEventListener("message.updated", onMessage);
events.addEventListener("computer", (e) => {
if (JSON.parse(e.data).status === "running") showScreen();
});
async function showScreen() {
if (screen.getAttribute("src")) return;
const { url } = await fetch("/api/live", { method: "POST" }).then((r) => r.json());
if (url) screen.src = url;
}
showScreen();
document.getElementById("form").addEventListener("submit", async (e) => {
e.preventDefault();
const q = document.getElementById("q");
if (!q.value.trim()) return;
const el = document.createElement("div"); el.className = "user"; el.textContent = q.value; log.append(el);
await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: q.value }) });
q.value = "";
});
</script>
</body>
</html>Run node server.js, open http://localhost:3000 and ask, for instance, "Go to the Eurostar website and find the first London to Paris train tomorrow morning". The computer event switches to running after a few seconds, the iframe shows the agent's desktop and you watch it open Chrome. The delta events fill the answer bubble as it is written, and the final message replaces it with the full text (Markdown: plug in a library such as marked if you want to render it).
Showing the live screen, and handing over control
The /computer/live link is view-only by default. With interactive: true, your user takes the mouse and keyboard inside the iframe: that is how they sign into a site or solve a captcha in the agent's place, then let it carry on. Browser sessions persist between tasks, so a login done once serves the next tasks.
Two precautions. An interactive link gives control of the computer to whoever holds it: only serve it to a signed-in user of your app, with a short ttl_seconds (60 to 86,400 s, 3,600 by default). And when the link expires, ask for a new one.
What it costs
No subscription: a prepaid balance in euros, topped up in the console. Two items:
- The computer: €2 per hour while it is on, billed per second. A 3-minute task therefore costs €0.10 of computer time, plus the idle wait before shutdown: with
idle_minutes: 2, count 5 minutes, about €0.17. CallPOST /v1/agents/{id}/computer/stopas soon as you know the agent is done to stop billing right away. - The model: the tokens used, at the model's price per million tokens.
GET /v1/modelslistseur_per_million_input_tokensandeur_per_million_output_tokensfor more than 300 models; the default model isanthropic/claude-haiku-5.5.
GET /v1/account returns your balance_eur. When it reaches zero, tasks stop and the API answers 402 with the code insufficient_balance: handle that case in your interface.
Going further: one app per product
The app above is the foundation. Each tutorial in the series reuses the same routes and adds what makes the idea of the product it takes inspiration from. These are your own apps, not copies: no brand or interface is reused.
- A Grok Bot clone: a team of always-on agents (a researcher, a writer, a reviewer), your server passing messages and files between them, a cron for recurring work and a takeover to sign into a site.
- A Meta Muse clone: one personal agent per user, each with its own computer, which keeps working when the app is closed.
- An OpenAI dots clone: a primary agent the user names and customises, working in the background.
- A Manus clone: you hand over a task, the agent opens its computer and comes back with a result and files to download.
- An OpenClaw clone: your agent in a messaging app such as Telegram, with no agent server to install or secure.
To go from demo to product: a user → agent table in your database (your user's id in metadata too), one event stream per agent, GET /v1/agents/{id}/messages to redisplay the history on reload (with after to read only what came next), and POST /v1/agents/{id}/stop for a "Stop" button. Keep the limits in mind: 600 requests per minute per IP (including 60 agent creations and 120 tasks) and 200 agents per account. And use must follow the terms: no spam, fraud, abusive collection of personal data or attacks on websites.
FAQ
Is there an SDK to install?
No. It is a JSON REST API: fetch in Node, requests in Python or curl is enough. The api() helper in this tutorial is about ten lines.
Why go through my server for the events?
Because the stream needs the Authorization header with your key, which EventSource cannot send, and because the key must never sit in code sent to the browser. Your server acts as the relay, and it is also where you check that the user is allowed to see that agent.
What happens if a task takes several minutes?
Nothing special in this app: we don't wait for the HTTP response, we follow the events. If you use wait: true (handy in a script), the response comes back after 120 s at most; if status is working, carry on with GET /v1/agents/{id}/events or read the conversation again with after.
Where do the agents' computers run?
In Paris. Each agent has its own, isolated from the others, with its files and browser sessions kept until you delete it. Your prompts, on the other hand, go to the provider of the model you choose.
Next steps: the API documentation, the console to create your key, the API overview, and our comparison Baguette Bot vs Grok Bot.