In this tutorial we build a personal agent in the spirit of OpenClaw: you message it on Telegram, it does the work on a computer (Chrome, terminal, files) and replies in the chat. While it works, a progress message updates in place; /screen sends you a screenshot of its screen, /stop stops it. The difference from a self-hosted OpenClaw: the agent and its computer run at Baguette Bot, and your small program opens no port at all. It all fits in a single bot.js file with no dependencies.
What we're building
OpenClaw is an open-source, MIT-licensed personal AI agent: you install it on your machine or a server, plug in your own model API key, and talk to it from WhatsApp, Telegram, Signal or Discord. It reads, writes, browses and runs commands for you. The flip side, covered in our OpenClaw alternative article: you host, update and secure the whole thing.
We don't reuse its code or its brand. We build your own Telegram agent on the same idea, with the Baguette Bot API for the agent and its computer:
- one agent per Telegram chat, found again through
metadata.telegram_chat_idand recorded in anagents.jsonfile; - live progress: one "Working" message edited as the agent's events come in, then the final answer;
/screen: a screenshot of its computer, sent as a photo;/stop: stops the current task;- an allow-list: only the Telegram user ids you approve can talk to it.
What you need
- Node 18 or later (
fetch,FormDataandBlobare built in). - A Baguette Bot API key, created in the console. It starts with
bb_live_. - A Telegram bot: message @BotFather in Telegram, send
/newbot, and keep the token it gives you. - About €1 of test budget: the prepaid balance starts with €1 free once your email is verified.
mkdir my-claw && cd my-claw
npm init -y && npm pkg set type=moduleNo npm i: we talk to the Telegram API with fetch, using long polling (getUpdates). grammy or telegraf would work too, but three methods are enough: getUpdates, sendMessage and sendPhoto (plus editMessageText for progress).
The complete bot
// bot.js (Node 18+, no dependencies)
import { readFile, writeFile } from "node:fs/promises";
const KEY = process.env.BAGUETTE_API_KEY;
const BASE = "https://api.baguettebot.com";
const TG = `https://api.telegram.org/bot${process.env.TELEGRAM_TOKEN}`;
const ALLOWED = new Set((process.env.ALLOWED_USERS ?? "").split(",").map((s) => s.trim()).filter(Boolean));
if (!KEY || !process.env.TELEGRAM_TOKEN || !ALLOWED.size) {
throw new Error("BAGUETTE_API_KEY, TELEGRAM_TOKEN and ALLOWED_USERS are required");
}
const auth = { Authorization: `Bearer ${KEY}` };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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 tg = (method, body) =>
fetch(`${TG}/${method}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
.then((r) => r.json())
.then((j) => { if (!j.ok) throw new Error(j.description); return j.result; });
// Telegram caps a message at 4096 characters: split it
async function say(chat_id, text) {
text ||= "(empty answer)";
for (let i = 0; i < text.length; i += 4000) await tg("sendMessage", { chat_id, text: text.slice(i, i + 4000) });
}
/* ---------- one agent per chat ---------- */
const INSTRUCTIONS = `You are your user's personal assistant; they talk to you from Telegram.
You work on your own computer (Chrome, terminal, files), which keeps its files between requests.
Answer in English, in short plain text: it is read on a phone.`;
const DB = new URL("./agents.json", import.meta.url);
let agents = {};
try { agents = JSON.parse(await readFile(DB, "utf8")); } catch { /* first run */ }
async function agentFor(chatId) {
if (agents[chatId]) return agents[chatId];
// agents.json lost? Find the agent again through its metadata.
const { data } = await api("/v1/agents?limit=500");
const agent = data.find((a) => a.metadata?.telegram_chat_id === String(chatId)) ??
await api("/v1/agents", {
name: `Telegram ${chatId}`,
instructions: INSTRUCTIONS,
idle_minutes: 5,
metadata: { telegram_chat_id: String(chatId) },
});
agents[chatId] = agent.id;
await writeFile(DB, JSON.stringify(agents, null, 2));
return agent.id;
}
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;
}
}
/* ---------- one task, followed live ---------- */
async function runTask(agentId, content, onProgress) {
const ctrl = new AbortController();
const res = await fetch(`${BASE}/v1/agents/${agentId}/events`, { headers: auth, signal: ctrl.signal });
if (!res.ok) throw new Error(`Event stream: HTTP ${res.status}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "", run = null, working = false, end = null;
try {
while (!end) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let i;
while (!end && (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 = /^data: (.+)$/m.exec(chunk)?.[1];
if (!type || !data) continue;
const ev = JSON.parse(data);
// Only send the task once the stream is open, so nothing is missed
if (type === "ready" && !run) run = await api(`/v1/agents/${agentId}/messages`, { content, wait: false });
else if (type === "status" && ev.status === "working") { working = true; if (ev.text) onProgress(ev.text); }
else if (type === "status" && working) end = ev.status; // idle or blocked
else if (type === "message" && ev.role === "tool" && ev.tool?.summary) onProgress(ev.tool.summary);
}
}
} finally { ctrl.abort(); }
if (!run) throw new Error("The stream closed before the task started");
// The stream dropped midway: wait for the end by polling the agent
while (!end) {
await sleep(5000);
const a = await api(`/v1/agents/${agentId}`);
if (a.status !== "working") end = a.status;
}
const messages = await messagesAfter(agentId, run.message_id);
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.content)?.content;
return { status: end, answer };
}
// A single "Working" message, edited at most every 3 s
function progress(chat_id) {
let id = null, last = "", next = "", timer = null, closed = false;
const flush = async () => {
timer = null;
if (closed || next === last) return;
last = next;
const text = `Working: ${next}`.slice(0, 4000);
try {
if (!id) id = (await tg("sendMessage", { chat_id, text })).message_id;
else await tg("editMessageText", { chat_id, message_id: id, text });
} catch { /* same text or too fast: harmless */ }
};
return {
update: (text) => { next = text; timer ??= setTimeout(flush, 3000); },
done: () => { closed = true; clearTimeout(timer); },
};
}
/* ---------- commands ---------- */
async function sendScreen(chat_id, agentId) {
const r = await fetch(`${BASE}/v1/agents/${agentId}/computer/screenshot`, { headers: auth });
if (r.status === 409) return say(chat_id, "My computer is off. It boots again with the next task.");
if (!r.ok) return say(chat_id, `Screenshot failed (HTTP ${r.status}).`);
const form = new FormData();
form.append("chat_id", String(chat_id));
form.append("photo", new Blob([await r.arrayBuffer()], { type: "image/jpeg" }), "screen.jpg");
const j = await fetch(`${TG}/sendPhoto`, { method: "POST", body: form }).then((x) => x.json());
if (!j.ok) throw new Error(j.description);
}
const busy = new Set();
async function handle(msg) {
const chat_id = msg.chat.id, text = msg.text?.trim();
if (!text) return;
if (!ALLOWED.has(String(msg.from?.id))) {
console.log(`Refused: user ${msg.from?.id} (@${msg.from?.username ?? "?"}), chat ${chat_id}`);
return; // no reply: a stranger learns nothing
}
const command = text.startsWith("/") ? text.split(/[\s@]/)[0] : null;
if (command === "/start") return say(chat_id, "Give me a task. /screen to see my screen, /stop to stop me.");
const agentId = await agentFor(chat_id);
if (command === "/stop") { await api(`/v1/agents/${agentId}/stop`, {}); return say(chat_id, "Task stopped."); }
if (command === "/screen") return sendScreen(chat_id, agentId);
if (busy.has(chat_id)) return say(chat_id, "I'm still on the previous task. /screen to see where I am, /stop to stop it.");
busy.add(chat_id);
const p = progress(chat_id);
try {
await tg("sendChatAction", { chat_id, action: "typing" });
const { status, answer } = await runTask(agentId, text, p.update);
p.done();
await say(chat_id, answer ?? "Done.");
if (status === "blocked") {
const live = await api(`/v1/agents/${agentId}/computer/live`, { interactive: true, ttl_seconds: 900 });
await say(chat_id, `I need you (a login, a captcha…). Take over here, the link expires in 15 minutes:\n${live.url}\nMessage me when you're done.`);
}
} catch (e) {
p.done();
await say(chat_id, `Error: ${e.message}`);
} finally {
busy.delete(chat_id);
}
}
/* ---------- long polling: no incoming connections ---------- */
let offset = 0;
console.log("Bot ready.");
for (;;) {
try {
const updates = await tg("getUpdates", { offset, timeout: 50, allowed_updates: ["message"] });
for (const u of updates) {
offset = u.update_id + 1;
if (u.message) handle(u.message).catch((e) => console.error(e));
}
} catch (e) {
console.error(e.message);
await sleep(5000);
}
}Run it and find your Telegram id
The allow-list is mandatory: without it, the bot refuses to start. To find your id, first run it with a dummy value, send it a message, and read the console:
BAGUETTE_API_KEY=bb_live_… TELEGRAM_TOKEN=123456:ABC… ALLOWED_USERS=0 node bot.js
# Refused: user 812345678 (@you), chat 812345678Run it again with your id (several, comma-separated, for family or team use):
BAGUETTE_API_KEY=bb_live_… TELEGRAM_TOKEN=123456:ABC… ALLOWED_USERS=812345678 node bot.jsSend it "Find me three London to Manchester trains on Saturday morning and tell me the cheapest": the "Working" message changes with each step, then the answer arrives. To keep it running all the time, any always-on machine will do (a small VPS, a Raspberry Pi, a systemd or pm2 service): it only needs outgoing connections.
How it works
One agent per chat. On a chat's first task, agentFor creates an agent with metadata: { telegram_chat_id } and records its id in agents.json. If the file disappears, the agent isn't lost: it is found again in GET /v1/agents through its metadata. This agent keeps its memory, files and browser sessions from one task to the next, which makes it feel like an assistant that knows you.
Progress. runTask opens the GET /v1/agents/{id}/events stream before sending the task with wait: false. Each status event carries a text ("Looking up timetables…") and each tool message a tool.summary: we pass them to a single Telegram message, edited at most every 3 seconds so the chat isn't flooded. When the status goes from working back to idle, we read the conversation after the task's message_id and send the agent's last answer.
Long tasks. The stream has no time limit: a twenty-minute task is followed just like a twenty-second one. The server sends a : ping comment every 15 s, which keeps the connection open. If it drops anyway, runTask falls back to polling GET /v1/agents/{id} every 5 seconds until the end. That's why we don't use wait: true, which returns after 120 s with status: "working".
/screen. GET /v1/agents/{id}/computer/screenshot returns a JPEG, or 409 when the computer is off. We pass it to sendPhoto as multipart/form-data.
/stop. POST /v1/agents/{id}/stop stops the task. The status goes back to idle, runTask finishes and the bot sends whatever the agent had written so far.
When the agent is stuck. If the agent switches to blocked (it has to sign in to an account, a captcha stops it), the bot creates a screen link with POST /v1/agents/{id}/computer/live and interactive: true. You open it on your phone, do what's needed, then message it "done". This link gives control of the computer to whoever has it for 15 minutes: it is only sent in your private chat.
Security: what changes compared with self-hosting
Many of the problems OpenClaw users ran into in 2026, recapped in our OpenClaw alternative article, come from the agent, its keys and its gateway running on a machine you have to protect yourself: CVE-2026-25253 (theft of the gateway token), tens of thousands of control panels exposed on the internet, infostealers targeting the configuration files that hold API keys, and a sandbox that is off by default according to The Register. Here is what this architecture changes:
| Self-hosted OpenClaw | This bot + the Baguette Bot API | |
|---|---|---|
| Where the agent acts | Your machine or your server | Its own Linux computer, hosted in Paris, isolated from other agents' |
| Open ports | A gateway and a control panel to protect | None: bot.js only makes outgoing requests (long polling) |
| Keys | In configuration files on the agent's machine | The API key stays in bot.js's environment, never on the agent's computer or in Telegram |
| Who can talk to the agent | Depends on your setup | A list of Telegram ids checked in your code, and the bot refuses to start without it |
| Spending | Per-token bill from your model provider | Prepaid balance: when it's empty, the API returns 402 and tasks stop |
What's still on you, honestly:
- Your two secrets. The Telegram token and the API key live in the environment variables of the machine running
bot.js. Don't commit them, and revoke the key in the console at the slightest doubt: it stops working immediately. - Telegram sees the messages. Chats with a bot aren't end-to-end encrypted. Don't send anything you wouldn't trust Telegram with.
- The agent acts on the web. It has a browser and a terminal on its computer. If you give it accounts, limit them to what it needs, and keep the allow-list short.
Costs
No subscription: a prepaid balance in euros.
- The computer costs €2 per hour while it's on, billed per second. It boots on the first task and shuts down after
idle_minuteswithout activity (5 here). A 2-minute task followed by 5 idle minutes is 7 minutes, about €0.23 of computer time. Loweridle_minutesif you send few requests, or keep it higher if you chain messages. - The model is billed by tokens used, at the price per million tokens from
GET /v1/models. The console shows what each agent costs, so what each Telegram chat costs.
A /balance command is easy to add: GET /v1/account returns balance_eur.
Going further
- Scheduled tasks. The API has no scheduler: use your own cron. With
npm i node-cron,cron.schedule("0 8 * * *", () => handle({ chat: { id: MY_CHAT }, from: { id: MY_ID }, text: "Summarise this morning's tech news" }))sends you a summary every morning at 8. - Editable instructions. An
/instructionscommand callingPATCH /v1/agents/{id}with newinstructionsplays the role of OpenClaw's skills: answer style, sites to prefer, things it must never do. - Pick the model. The default is
anthropic/claude-haiku-5.5;GET /v1/modelslists 300+, with their price and thevisionflag. A/modelcommand canPATCHthemodelfield. - Files. A document sent in Telegram can be downloaded with
getFileand uploaded to the agent's computer withPOST /v1/agents/{id}/files; the other way round,GET /v1/agents/{id}/files?path=…fetches what it produced, to send back withsendDocument. - Other chat apps. The same logic (one agent per conversation,
metadataas the link) applies to Slack, Discord or your own chat. The agents themselves don't send emails or make phone calls.
FAQ
Does the agent run when my computer is off?
The agent and its computer run at Baguette Bot, not at your place. Only bot.js must stay on to relay Telegram messages: a small VPS or any always-on machine is enough, with no open port.
Can several people use it?
Yes: add their ids to ALLOWED_USERS. Each has a private chat with the bot, so their own agent and their own computer. In a group, the whole group shares one agent, and only allowed members can give it tasks. An account can have up to 200 agents.
Why not a Telegram webhook?
A webhook needs a public HTTPS URL, so an open port to your program. Long polling (getUpdates) works the other way round: your program fetches the messages, and nothing reaches it from outside. For a personal agent, it's simpler and safer.
What happens if agents.json is deleted?
Nothing serious: on the next message, agentFor finds the chat's agent again in GET /v1/agents through metadata.telegram_chat_id, with its memory and files.
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 OpenClaw alternative comparison.