Build a Meta Muse clone with the Baguette Bot API: a personal agent for every user

Node.js tutorial: a personal AI agent for every user of your app, which keeps working when the app is closed and notifies them on Telegram. Full code.

By the Baguette Bot team 16 min read
Muse clone
Contents
  1. What we're building
  2. What you need
  3. Step 1: the API client and a tiny database
  4. Step 2: one agent per user, created at sign-up
  5. Step 3: follow the agent while the app is closed
  6. Step 4: send a task, with guard rails
  7. Step 5: notify the user
  8. Step 6: the last routes
  9. The mobile chat page
  10. Show the screen live
  11. Costs: control spending per user
  12. Going further
  13. FAQ

In this tutorial we build a consumer app where every user gets their own AI agent, with its own computer in the cloud. The user types a request on a phone-friendly chat page, closes the app, and gets a Telegram message when their agent is done. It all fits in a server.js of about 200 lines and one HTML page, on the Baguette Bot API.

What we're building

On 8 September 2026, Meta launched Muse, a personal AI agent that runs on a dedicated cloud computer for each user and keeps working when you close the app. It is available on iOS, Android, the web, Mac and WhatsApp, but only in the United States at launch. The idea is simple and strong: one agent per person, which keeps its memory and files from one task to the next.

We don't copy the name or the brand: we build your own app on the same idea, for your users. It does four things:

  • One agent per user, created at sign-up and linked to your user account with metadata.
  • A mobile chat page showing requests and answers, even after the app was closed.
  • Background work: the task is sent with wait: false, your server follows the agent, stores the result and notifies the user on the channel you choose (Telegram here).
  • Per-user cost control: a computer that shuts down quickly, a daily quota, a stop button, a balance check.

The architecture: the browser talks to your server, your server talks to the API. The API key never leaves the server.

What you need

  • Node 18 or later (fetch is built in).
  • An API key, created in the console. It starts with bb_live_. Once your email is verified you get €1 free to try, enough for several test tasks.
  • Optional: a Telegram bot, created in two minutes with @BotFather, for notifications.
bash
mkdir my-agent && cd my-agent
npm init -y && npm pkg set type=module
npm i express@5
mkdir public
export BAGUETTE_API_KEY=bb_live_...
export TELEGRAM_BOT_TOKEN=123456:ABC...   # optional
export TELEGRAM_BOT_NAME=MyAgentBot       # optional, without the @

We use Express 5 because it forwards errors from async routes to the error handler, with no try/catch everywhere. Paste the following blocks in order into server.js.

Step 1: the API client and a tiny database

The api() helper is the one from the documentation: it throws with the API's message when a request fails. For storage, a JSON file is enough for the tutorial; in your app, it will be your real database.

javascript
// server.js (Node 18+, npm i express@5)
import express from "express";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";

const BASE = "https://api.baguettebot.com";
const KEY = process.env.BAGUETTE_API_KEY;
const TG = process.env.TELEGRAM_BOT_TOKEN;
const TG_BOT = process.env.TELEGRAM_BOT_NAME;

const DAILY_TASKS = 20;  // tasks per user per day
const MIN_BALANCE = 2;   // in euros: below this, new tasks are refused
const MAX_TASK_MIN = 20; // past this, the task is stopped

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 DB = "db.json";
const db = existsSync(DB) ? JSON.parse(readFileSync(DB, "utf8")) : { users: {} };
const save = () => writeFileSync(DB, JSON.stringify(db, null, 2));

const app = express();
app.use(express.json());
app.use(express.static("public"));

// Your own authentication replaces this session token
const me = (req, res, next) => {
  req.user = db.users[req.get("x-session")];
  req.user ? next() : res.status(401).json({ error: "Not signed in" });
};

Step 2: one agent per user, created at sign-up

At sign-up, we create the agent with personalised instructions. Two parameters matter here:

  • metadata: your own data, returned as is by the API. We put the user's id in your app there. It is the link between your accounts and the agents.
  • idle_minutes: how many idle minutes before the computer shuts down. For a consumer app, keep it low.
javascript
const instructions = (name) => `You are ${name}'s personal agent.
You work on your own computer (Chrome, terminal, files) and you remember earlier requests.
When you are done, answer in a few lines that read well on a phone, with the useful links.
If a site asks for a login or a captcha, stop and explain what needs to be done.
You never buy anything and never create accounts.`;

app.post("/api/signup", async (req, res) => {
  const name = String(req.body.name ?? "").trim().slice(0, 40) || "you";
  const userId = randomUUID();
  const agent = await api("/v1/agents", {
    name: `${name}'s agent`,
    instructions: instructions(name),
    idle_minutes: 5,
    metadata: { app_user_id: userId },
  });
  const session = randomUUID();
  db.users[session] = {
    userId, name, agentId: agent.id, tasks: [],
    linkCode: randomUUID().replaceAll("-", ""), telegramChatId: null,
    today: { day: "", count: 0 },
  };
  save();
  res.json({ session });
});

The default model is anthropic/claude-haiku-5.5. You can pass another one with model (the list is in GET /v1/models).

Thanks to metadata, you can always find a user's agent with the API alone, for example to repair your database:

javascript
const { data } = await api("/v1/agents?limit=500");
const agent = data.find((a) => a.metadata?.app_user_id === userId);

Step 3: follow the agent while the app is closed

This is the heart of the clone. With wait: true, the API waits 120 seconds at most. A real task (comparing offers, filling in a form, hunting for a flat) often takes longer. So we send the task with wait: false: the API answers 202 right away with a message_id, and your server follows the agent, whether the user's phone is open or not.

followRun() reads the agent's event stream. When it stops working (a status event other than working), we check its state with GET /v1/agents/{id} and read the messages that came after the request with after. As a safety net, we also check once a minute (the : ping comments arrive every 15 s) and reconnect if the stream drops.

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

Step 4: send a task, with guard rails

Before sending, we apply three rules: one task at a time per user, a daily quota, and a minimum balance checked with GET /v1/account. Without that last check, an empty balance would make every user's task fail with a 402 error.

javascript
app.post("/api/tasks", me, async (req, res) => {
  const u = req.user;
  const content = String(req.body.content ?? "").trim();
  if (!content) return res.status(400).json({ error: "Empty request" });
  const day = new Date().toISOString().slice(0, 10);
  if (u.today.day !== day) u.today = { day, count: 0 };
  if (u.today.count >= DAILY_TASKS) return res.status(429).json({ error: "Daily limit reached" });
  if (u.tasks.some((t) => t.status === "working")) return res.status(409).json({ error: "Your agent is already working" });
  const { balance_eur } = await api("/v1/account");
  if (balance_eur < MIN_BALANCE) return res.status(503).json({ error: "Service paused, try again later" });

  const run = await api(`/v1/agents/${u.agentId}/messages`, { content, wait: false });
  const task = { id: run.message_id, content, status: "working", output: null, statusText: null };
  u.tasks.push(task); u.today.count++; save();
  track(u, task); // no await: the response goes out right away
  res.status(202).json(task);
});

async function track(u, task) {
  const result = await followRun(u.agentId, task.id, MAX_TASK_MIN * 60_000, (text) => (task.statusText = text))
    .catch((e) => ({ status: "error", output: e.message }));
  if (task.status === "stopped") return save();
  if (!result) await api(`/v1/agents/${u.agentId}/stop`, {}).catch(() => {});
  task.status = !result ? "timeout" : ["blocked", "error"].includes(result.status) ? result.status : "done";
  task.output = result?.output ?? null;
  save();
  // Task over: shut the computer down without waiting for idle_minutes (its files stay)
  if (task.status !== "blocked") await api(`/v1/agents/${u.agentId}/computer/stop`, {}).catch(() => {});
  await notify(u, task).catch((e) => console.error("notify:", e.message));
}

Step 5: notify the user

The API doesn't send notifications: your server picks the channel. We use Telegram because it is free and quick to set up, but notify() is the only place to change to move to your push service (FCM, APNs, Web Push) or a Slack webhook.

To link a user to Telegram, your page gives them a t.me/YourBot?start=CODE link. When they tap "Start", Telegram sends /start CODE to your bot, and we read it with getUpdates.

javascript
async function notify(u, task) {
  if (!TG || !u.telegramChatId) return;
  const text = task.status === "blocked"
    ? "Your agent needs you (login or captcha). Open the app and tap \"Screen\"."
    : `Done: "${task.content.slice(0, 60)}"\n\n${(task.output ?? "No answer.").slice(0, 3500)}`;
  const r = await fetch(`https://api.telegram.org/bot${TG}/sendMessage`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ chat_id: u.telegramChatId, text }),
  });
  if (!r.ok) throw new Error(await r.text());
}

async function pollTelegram() {
  let offset = 0;
  while (TG) {
    try {
      const r = await fetch(`https://api.telegram.org/bot${TG}/getUpdates?timeout=30&offset=${offset}`).then((r) => r.json());
      for (const up of r.result ?? []) {
        offset = up.update_id + 1;
        const code = up.message?.text?.match(/^\/start (\w+)/)?.[1];
        const u = code && Object.values(db.users).find((x) => x.linkCode === code);
        if (u) { u.telegramChatId = up.message.chat.id; save(); }
      }
    } catch { await sleep(5000); }
  }
}

Step 6: the last routes

What's left: reading the state for the page, stopping the agent, taking over when it is stuck, and deleting the account. At start-up, we resume following the tasks still in progress: a server restart loses no result.

javascript
app.get("/api/me", me, (req, res) => {
  const u = req.user;
  res.json({
    name: u.name,
    tasks: u.tasks.slice(-20),
    telegram: TG_BOT ? { linked: !!u.telegramChatId, link: `https://t.me/${TG_BOT}?start=${u.linkCode}` } : null,
  });
});

app.post("/api/stop", me, async (req, res) => {
  for (const t of req.user.tasks) if (t.status === "working") t.status = "stopped";
  save();
  await api(`/v1/agents/${req.user.agentId}/stop`, {});
  await api(`/v1/agents/${req.user.agentId}/computer/stop`, {});
  res.json({ ok: true });
});

// The agent's screen, which the user can drive (login, captcha)
app.post("/api/live", me, async (req, res) => {
  const id = req.user.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: true, ttl_seconds: 900 });
  res.json({ url: live.url });
});

app.delete("/api/account", me, async (req, res) => {
  await api(`/v1/agents/${req.user.agentId}`, undefined, "DELETE");
  delete db.users[req.get("x-session")];
  save();
  res.json({ ok: true });
});

app.use((err, req, res, next) => res.status(500).json({ error: err.message }));

for (const u of Object.values(db.users)) for (const t of u.tasks) if (t.status === "working") track(u, t);
pollTelegram();
app.listen(3000, () => console.log("http://localhost:3000"));

The mobile chat page

public/index.html: a column of messages, a field at the bottom, and the page re-reads the state every 4 seconds while it is visible. Answers are shown with textContent, never as raw HTML: it is text written by an agent that has read web pages.

html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>My agent</title>
<style>
  body { margin: 0; height: 100dvh; display: flex; flex-direction: column; font: 16px/1.45 system-ui, sans-serif; background: #f6f3ee; color: #222; }
  header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; font-weight: 600; }
  header a, header button { font: inherit; font-size: 14px; color: #b3261e; background: none; border: 0; padding: 0; margin-left: 12px; }
  #list { flex: 1; overflow-y: auto; padding: 0 16px; }
  .msg { background: #fff; border-radius: 14px; padding: 10px 12px; margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; }
  .me { background: #2b2b2b; color: #fff; margin-left: 15%; }
  .wait { color: #777; font-style: italic; }
  form { display: flex; gap: 8px; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); }
  input { flex: 1; min-width: 0; font-size: 16px; padding: 12px; border: 1px solid #ccc; border-radius: 12px; }
  form button { font-size: 16px; padding: 0 16px; border: 0; border-radius: 12px; background: #b3261e; color: #fff; }
</style>
</head>
<body>
<header><span>My agent</span><span><a id="tg" target="_blank" hidden>Telegram</a><button id="screenBtn">Screen</button><button id="haltBtn">Stop</button></span></header>
<div id="list"></div>
<form id="f"><input id="q" placeholder="Ask for something…" autocomplete="off"><button>Send</button></form>
<script>
let session = localStorage.getItem("session"), last = "";
const call = (path, body) => fetch(path, {
  method: body ? "POST" : "GET",
  headers: { "Content-Type": "application/json", "x-session": session },
  body: body && JSON.stringify(body),
}).then(async (r) => { const j = await r.json(); if (!r.ok) throw new Error(j.error); return j; });

const bubble = (text, cls = "") => {
  const d = document.createElement("div");
  d.className = "msg " + cls; d.textContent = text;
  return list.appendChild(d);
};
const LABELS = { working: "On it. You can close the app, I'll let you know.", stopped: "Stopped.", timeout: "Took too long, task stopped.", error: "Error.", blocked: "I need you: tap \"Screen\"." };

async function refresh() {
  if (document.hidden) return;
  const me = await call("/api/me");
  if (me.telegram) { tg.hidden = false; tg.href = me.telegram.link; tg.textContent = me.telegram.linked ? "Telegram linked" : "Link Telegram"; }
  const key = JSON.stringify(me.tasks);
  if (key === last) return;
  last = key; list.textContent = "";
  for (const t of me.tasks) {
    bubble(t.content, "me");
    bubble(t.status === "working" ? (t.statusText || LABELS.working) : (t.output || LABELS[t.status] || ""), t.status === "working" ? "wait" : "");
  }
  list.scrollTop = list.scrollHeight;
}

f.onsubmit = async (e) => {
  e.preventDefault();
  const content = q.value.trim(); if (!content) return;
  q.value = "";
  try { await call("/api/tasks", { content }); } catch (err) { alert(err.message); }
  refresh();
};
haltBtn.onclick = () => call("/api/stop", {}).then(refresh);
screenBtn.onclick = async () => {
  const w = window.open("", "_blank"); // opened right away, or Safari blocks the window
  w.location = (await call("/api/live", {})).url;
};

(async () => {
  if (!session) {
    ({ session } = await call("/api/signup", { name: prompt("Your first name?") || "" }));
    localStorage.setItem("session", session);
  }
  refresh(); setInterval(refresh, 4000);
  document.addEventListener("visibilitychange", refresh);
})();
</script>
</body>
</html>

Run node server.js, open http://localhost:3000 on your computer or phone (same network, with your machine's IP address), and ask for example "Find me three Italian restaurants open on Sunday evening near King's Cross, with their reviews". Close the tab: the result arrives on Telegram.

Show the screen live

When the agent hits a login page or a captcha, its instructions tell it to stop and explain what needs doing (if its status becomes blocked, the server keeps the computer on and the notification says so). The user then taps "Screen": your server boots the computer if needed, creates a link with POST /v1/agents/{id}/computer/live and interactive: true, valid for 15 minutes, and the user drives the agent's browser themselves to sign in. The signed link doesn't contain your key. The user then writes "done, carry on" in the chat, and the agent picks up where it left off. Its browser sessions are kept for later tasks.

For a simple watch-only view (seeing the agent work), pass interactive: false and show the URL in an iframe.

Costs: control spending per user

You pay from a prepaid balance in euros, with no subscription: model tokens at each model's price, and the computer at €2 per hour, billed per second. A 3-minute task with the computer therefore costs €0.10 of computer time, plus the model's tokens. Check GET /v1/models for the price per million tokens of the one you use.

In a consumer app, you pay for all your users. The levers in the code:

  • idle_minutes: 5, and above all computer/stop as soon as a task is over: the computer only runs while it works. Its files stay, and it boots again in a few seconds for the next task.
  • A daily quota (DAILY_TASKS) and one task at a time per user.
  • A maximum duration (MAX_TASK_MIN) after which POST /v1/agents/{id}/stop ends the task.
  • A Stop button for the user, which stops the task and the computer.
  • A minimum balance checked before every send.

To price your own plan, measure: the console shows what each agent costs.

Going further

  • Scheduled tasks. The API has no scheduler: add your own cron (node-cron, a systemd timer, GitHub Actions) that sends "give me an update on my parcels" every morning through the same route.
  • Files. POST /v1/agents/{id}/files puts a user's document (about 30 MB at most) on their agent's computer; GET /v1/agents/{id}/files?path=… fetches what the agent produced.
  • Markdown rendering. output is Markdown: run it through a rendering library that sanitises the HTML.
  • The 200 agents per account limit. Delete the agents of inactive accounts (after fetching their useful files) and recreate them when the users come back.

FAQ

Can the agent send emails or make calls for the user?

No. API agents don't send emails and don't make calls. They browse, fill in web forms, read, compare and produce files. Notifications go through your own channel, like Telegram here.

What happens if my balance runs out during a task?

Tasks stop and the API answers 402 insufficient_balance to new requests. That's why the server checks GET /v1/account before every send: it's better to politely refuse a task than to let it fail.

Where is my users' data?

The agents' computers are hosted in Paris. Messages go to the provider of the model you choose. Your own database (db.json here) stays with you. Follow our terms: no spam, no fraud, no abusive collection of personal data.

Why not open the event stream from the browser?

EventSource can't send the Authorization header, and your key must never go to the browser. So the server follows the events itself, which is needed anyway for work to continue while the app is closed.

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

ShareXLinkedInReddit