Build a Grok Bot clone: a team of always-on AI agents with the Baguette Bot API

Build your own team of AI agents in Node: a researcher, a writer and a reviewer, each with its own computer, files passed between them, a cron and a live takeover.

By the Baguette Bot team 12 min read
Grok Bot clone
Contents
  1. The idea behind Grok Bot, and what we take from it
  2. What you need
  3. Step 1: create the team
  4. Step 2: talk to an agent, even when it takes a while
  5. Step 3: pass files around
  6. Step 4: the teamwork
  7. Step 5: recurring work with a cron
  8. Step 6: the live screen, and taking over
  9. What it costs
  10. Going further
  11. FAQ

In this tutorial we build a small team of three AI agents that works on its own every Monday morning: a researcher scans the web and saves its notes, a writer turns them into a newsletter, a reviewer corrects it, and the result lands in your Telegram. A web page shows the team's log and lets you take over any agent's computer, for example to sign into a site on its behalf. It all fits in one server.js and one index.html, on the Baguette Bot API.

The idea behind Grok Bot, and what we take from it

Grok Bot is the agent product xAI (now SpaceXAI) launched in beta in August 2026, built together with Cursor. It promises a team of always-on agents: bots that sign into websites like a person, message each other, hand work over (a "Chief of Staff" bot can coordinate specialists), and keep working when your laptop is closed. When a site asks for a password or a CAPTCHA, you take over the browser.

This tutorial doesn't copy Grok Bot: it is your own app, built on the same idea. And there is one design difference worth knowing from the start. On Grok Bot, a user's bots share one computer. With the Baguette Bot API, every agent has its own computer, isolated from the others. Agents therefore can't see their colleagues' files: your server moves the work around, messages and files alike. It plays the coordinator.

What you need

  • Node 18 or later.
  • An API key created in the console (€1 free once your email is verified, enough for a few team runs).
  • Optionally, a Telegram bot to receive the result: create it with @BotFather, which gives you a token, and get the id of your chat with it.
bash
mkdir my-team && cd my-team
npm init -y
npm pkg set type=module
npm i express node-cron
mkdir public
export BAGUETTE_API_KEY=bb_live_…
export TOPIC="AI in Europe"
# optional
export TELEGRAM_TOKEN=123456:ABC… TELEGRAM_CHAT_ID=123456789

Step 1: create the team

Three agents, three roles. Each one gets standing instructions and a metadata field saying which team it belongs to. We keep their ids in team.json so they aren't created again on every start (you can have 200 agents per account).

The top of server.js:

javascript
// server.js
import express from "express";
import cron from "node-cron";
import { readFile, writeFile } from "node:fs/promises";

const KEY = process.env.BAGUETTE_API_KEY;
const BASE = "https://api.baguettebot.com";
const TOPIC = process.env.TOPIC ?? "AI in Europe";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

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

const ROLES = {
  researcher: { name: "Researcher", idle_minutes: 2, instructions:
    "You research the web with Chrome. You check every fact at its source and always note its URL. " +
    "If a site asks you to sign in, don't make up credentials: say so in your answer." },
  writer: { name: "Writer", idle_minutes: 2, instructions:
    "You write clear, plain newsletters in English. You only use facts from the notes you are given, with their links." },
  reviewer: { name: "Reviewer", idle_minutes: 1, instructions:
    "You review texts. If it is ready to publish, answer only APPROVED. Otherwise, list the corrections needed, numbered, without rewriting the text." },
};

async function loadTeam() {
  let team = {};
  try { team = JSON.parse(await readFile("team.json", "utf8")); } catch {}
  for (const [role, def] of Object.entries(ROLES)) {
    if (!team[role]) team[role] = (await api("/v1/agents", { ...def, metadata: { team: "newsletter", role } })).id;
  }
  await writeFile("team.json", JSON.stringify(team, null, 2));
  return team;
}
const team = await loadTeam();

const journal = [];
const note = (text) => { journal.push(`${new Date().toLocaleTimeString("en-GB")}  ${text}`); console.log(text); };

All agents use the default model here, anthropic/claude-haiku-5.5. Nothing stops you from giving each role a different one (one more model field, picked from GET /v1/models): a stronger model for the researcher, a fast one for the reviewer.

Step 2: talk to an agent, even when it takes a while

A web search can take longer than the 120 s that wait: true is willing to wait. In that case the response comes back with status: "working". The ask() function handles it: it watches the agent's status, then reads the rest of the conversation from the message it sent, thanks to the after parameter.

javascript
// A task, and the agent's final answer, even past 120 s
async function ask(role, content) {
  const id = team[role];
  note(`${ROLES[role].name}: working`);
  const run = await api(`/v1/agents/${id}/messages`, { content, wait: true });
  if (run.status === "done") return run.output;

  for (let i = 0; i < 360; i++) { // 30 minutes at most
    await sleep(5000);
    const agent = await api(`/v1/agents/${id}`);
    if (agent.status === "working") continue;
    if (agent.status === "blocked") { note(`${ROLES[role].name} is blocked: ${agent.status_text ?? "open its screen"}`); continue; }
    const page = await api(`/v1/agents/${id}/messages?after=${run.message_id}&limit=200`);
    const messages = page.data ?? page;
    return messages.filter((m) => m.role === "assistant" && m.content).at(-1)?.content ?? "";
  }
  throw new Error(`${ROLES[role].name}: no answer after 30 minutes`);
}

If you prefer real time to polling, the GET /v1/agents/{id}/events stream gives the same information without the wait: the status event switches to idle when the agent is done. The base tutorial shows how to read and relay it.

Step 3: pass files around

The researcher saves its notes on its own computer. The writer can't read them directly: your server downloads them from one and uploads them to the other. The download returns the file as binary, hence a plain fetch rather than the JSON helper.

javascript
async function download(role, path) {
  const r = await fetch(`${BASE}/v1/agents/${team[role]}/files?path=${encodeURIComponent(path)}`, {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  if (!r.ok) throw new Error(`${path} not found on ${ROLES[role].name}`);
  return Buffer.from(await r.arrayBuffer());
}
const upload = (role, path, buffer) =>
  api(`/v1/agents/${team[role]}/files`, { path, content_base64: buffer.toString("base64") });

// Shutting a computer down as soon as it's no longer needed stops billing
const stopComputer = (role) => api(`/v1/agents/${team[role]}/computer/stop`, {}).catch(() => {});

Both routes boot the computer if it's off, and a file can be up to about 30 MB: a PDF, a spreadsheet or a screenshot moves the same way.

Step 4: the teamwork

This is where your server becomes the coordinator. It passes the researcher's notes to the writer, then goes back and forth between writer and reviewer until the text is approved (two rounds at most, so it can't loop forever). At the end it saves the newsletter and lets you know.

javascript
async function notify(text) {
  const { TELEGRAM_TOKEN, TELEGRAM_CHAT_ID } = process.env;
  if (!TELEGRAM_TOKEN) return;
  await fetch(`https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ chat_id: TELEGRAM_CHAT_ID, text: text.slice(0, 4000) }),
  });
}

let running = false;
async function runNewsletter() {
  if (running) return note("Already running, skipping this round");
  running = true;
  try {
    await ask("researcher",
      `Find the 5 most important news items of the week about ${TOPIC}. ` +
      "For each: a title, a three-line summary and the source URL. Save it all in ~/Documents/notes.md.");
    const notes = await download("researcher", "~/Documents/notes.md");
    await stopComputer("researcher");
    await upload("writer", "~/Uploads/notes.md", notes);

    let draft = await ask("writer",
      "The researcher's notes are in ~/Uploads/notes.md. Write this week's newsletter " +
      "(500 words at most, in Markdown) and answer with its text only.");
    for (let round = 1; round <= 2; round++) {
      const review = await ask("reviewer", `Review this newsletter:\n\n${draft}`);
      if (review.trim().startsWith("APPROVED")) { note("Reviewer: approved"); break; }
      draft = await ask("writer",
        `The reviewer asks for these corrections:\n\n${review}\n\nApply them and answer with the corrected text only.`);
    }
    await Promise.all(["writer", "reviewer"].map(stopComputer));

    await writeFile(`newsletter-${new Date().toISOString().slice(0, 10)}.md`, draft);
    await notify(`Newsletter ready:\n\n${draft}`);
    note("Newsletter ready");
  } catch (e) {
    note(`Error: ${e.message}`);
    await notify(`The newsletter failed: ${e.message}`).catch(() => {});
  } finally {
    running = false;
  }
}

The reviewer's feedback doesn't travel by magic: it is just a string your code reads (APPROVED or a list) and sends back to the writer. That is all there is to routing between agents. You can make it as rich as you like: a fourth agent deciding who should step in, teams running in parallel with Promise.all, or a human approving before anything goes out.

Step 5: recurring work with a cron

The API has no scheduler: your server decides when the team works. With node-cron it takes one line, here every Monday at 8 am, Paris time:

javascript
cron.schedule("0 8 * * 1", runNewsletter, { timezone: "Europe/Paris" });

Your server therefore has to run continuously (a small VPS, a container, a systemd service). If you'd rather not keep a process running, move runNewsletter() into a script and run it from a systemd timer or a scheduled GitHub Action: the agents and their files stay on their computers between runs.

Step 6: the live screen, and taking over

Last building block: a page that shows the log and lets you take over an agent's computer. The typical case: the researcher needs a site that requires signing in. You open its screen, sign in yourself in its Chrome, and since browser sessions persist between tasks, it stays signed in for the next runs. Your credentials never go through the model or through your code.

The end of server.js:

javascript
const route = (fn) => (req, res) => fn(req, res).catch((e) => res.status(500).json({ error: e.message }));
const app = express();
app.use(express.static("public"));

app.get("/api/state", (req, res) => res.json({ running, journal: journal.slice(-30) }));
app.post("/api/run", (req, res) => { runNewsletter(); res.json({ ok: true }); });

// An interactive link to an agent's screen (boots the computer if needed)
app.post("/api/live/:role", route(async (req, res) => {
  const id = team[req.params.role];
  if (!id) return res.status(404).json({ error: "Unknown role" });
  if ((await api(`/v1/agents/${id}/computer`)).status !== "running") {
    await api(`/v1/agents/${id}/computer/start`, {});
    for (let i = 0; i < 30 && (await api(`/v1/agents/${id}/computer`)).status !== "running"; i++) await sleep(2000);
  }
  const live = await api(`/v1/agents/${id}/computer/live`, { interactive: true, ttl_seconds: 900 });
  res.json({ url: live.url });
}));

// Local only: an interactive link gives control of the computer
app.listen(3000, "127.0.0.1", () => console.log("http://localhost:3000"));

And the page:

html
<!-- public/index.html -->
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My team</title>
<style>
  body { font: 15px/1.5 system-ui, sans-serif; max-width: 1000px; margin: 0 auto; padding: 16px; }
  button { padding: 6px 12px; margin: 2px; }
  iframe { width: 100%; aspect-ratio: 16 / 10; border: 0; border-radius: 12px; background: #111; margin: 12px 0; }
  pre { background: #f3f3f3; padding: 12px; border-radius: 10px; white-space: pre-wrap; }
</style>
<body>
  <h1>My agent team</h1>
  <p><button id="run">Run the newsletter now</button></p>
  <p>Take over the computer of the
    <button data-role="researcher">researcher</button>
    <button data-role="writer">writer</button>
    <button data-role="reviewer">reviewer</button>
  </p>
  <iframe id="screen" title="Agent's screen"></iframe>
  <pre id="journal">…</pre>
<script>
  document.getElementById("run").onclick = () => fetch("/api/run", { method: "POST" });
  for (const b of document.querySelectorAll("[data-role]")) {
    b.onclick = async () => {
      b.disabled = true;
      const r = await fetch("/api/live/" + b.dataset.role, { method: "POST" }).then((x) => x.json());
      b.disabled = false;
      if (r.url) document.getElementById("screen").src = r.url; else alert(r.error);
    };
  }
  setInterval(async () => {
    const s = await fetch("/api/state").then((x) => x.json());
    document.getElementById("journal").textContent = (s.running ? "Running…\n" : "") + s.journal.join("\n");
  }, 3000);
</script>
</body>
</html>

Run node server.js, open http://localhost:3000 and click "researcher": its desktop appears and you can click and type in it. Then hit "Run the newsletter now" and follow the log. If the computer shuts down while you are signing into a site, raise its idle_minutes with PATCH /v1/agents/{id}.

The server only listens on your machine, on purpose. Before putting it online, add real authentication in front of /api/live: whoever gets an interactive link controls the agent's mouse and keyboard while the link is valid.

What it costs

The computer costs €2 per hour while it is on, billed per second; the model, the tokens at the price shown by GET /v1/models. Durations depend on the topic and the sites, so take an example: 6 minutes for the researcher, 5 minutes between the writer and the reviewer. That is 11 minutes of computer time, about €0.37, plus a few minutes before shutdown where we haven't called computer/stop yet. With one run a week, count around €1.50 of computer time a month, plus the tokens of the three models. GET /v1/account gives your remaining balance, and the console breaks down what each agent costs. If the balance is empty, the API answers 402 (insufficient_balance) and your catch tells you on Telegram.

Going further

  • One team per customer. Create a trio per customer with metadata: { customer_id } and one row per team in your database; mind the limits of 60 agent creations per minute and 200 agents per account.
  • An AI coordinator. Replace the fixed loop with a fourth agent that receives the state of the work and answers with the next role to call, which your code then applies.
  • More channels. A Slack webhook or your own push service instead of Telegram: the API doesn't deliver notifications itself, and its agents don't send emails.
  • Follow it live. Relay each agent's event stream to the page, as in the base tutorial, to watch the answers being written.

And keep usage within the terms: no spam, fraud, abusive collection of personal data or aggressive scraping.

FAQ

Why not have the three agents share one computer?

With the API, each agent has its own, isolated one. That means more transfers to write, but an agent that makes a mistake or reads a booby-trapped page can't touch the others' files and sessions. Transfers go through GET and POST /v1/agents/{id}/files.

Can the agents talk to each other directly?

Not through the API: they talk through your server, which reads one agent's answer and sends it to another with POST /v1/agents/{id}/messages. That is also what lets you log, filter or approve every exchange.

Does the team keep working when my laptop is off?

The agents work on their cloud computers, in Paris. What has to stay on is your server running the cron. On a small VPS, that is "always on" in the Grok Bot sense.

What happens if a task takes longer than 120 s?

wait: true returns with status: "working". The ask() function from step 2 then waits for the agent to go back to idle and reads the rest with after.

Next steps: the API documentation, the console, the API overview, the base tutorial Build your own AI agent app and our comparison Baguette Bot vs Grok Bot.

ShareXLinkedInReddit