blog·4 min read

A yes/no button for your AI agent, in Telegram

Before an agent does something you can't undo, you want a human to say yes. How our approval with two buttons in Telegram works, with a free script.

Our agents do a lot on their own. For a few things we want a human first: a change on a customer's site, a command you can't undo, a publication. That question arrives as a Telegram message with two buttons. Our weekly content routine works the same way: on Monday an agent writes the articles, a button appears on the phone, and on Wednesday it goes live.

It is a small tool, but we learned more from it than we expected. You can download the script for free.

Download: approval-gate.py (MIT license, free to use)

How does it work?

  1. The agent requests approval with a description and the exact command. A JSON file is created with status pending, plus a Telegram message with ✅ and ❌.
  2. You press a button. Telegram sends that as a callback_query to your webhook.
  3. The webhook checks who pressed it and writes approved or denied into the file.
  4. The agent waits and gets an exit code back: 0 is yes, 2 is no, 3 is expired.
  5. On 0 the agent runs exactly the command stored in the file.

From a script or agent it looks like this:

id=$(python3 approval-gate.py request "Purge the cache on the web shop" \
      --command "wp litespeed-purge all" --source maintenance-agent)
if python3 approval-gate.py wait "$id" --timeout 600; then
  wp litespeed-purge all
fi

The script only needs Python's standard library. You set TG_BOT_TOKEN (from @BotFather) and TG_CHAT_ID (your own chat) in the environment.

The webhook: who is allowed to press?

The button is the easy part. The webhook decides whether it is safe. Ours runs in a small Express app and checks three things before anything changes:

const answer = (cq, text) => tg("answerCallbackQuery", { callback_query_id: cq.id, text });

app.post("/telegram/:secret", async (req, res) => {
  if (req.params.secret !== PATH_SECRET) return res.sendStatus(401);
  if (req.get("x-telegram-bot-api-secret-token") !== HEADER_SECRET) return res.sendStatus(401);
  res.sendStatus(200);                       // acknowledge Telegram straight away

  const cq = req.body?.callback_query;
  const m = String(cq?.data ?? "").match(/^appr\|([a-f0-9]{10})\|(yes|no)$/);
  if (!m) return;
  if (String(cq.from?.id) !== APPROVER_ID) return answer(cq, "not allowed");

  const file = path.join(STORE, `${m[1]}.json`);
  const a = JSON.parse(fs.readFileSync(file, "utf8"));
  if (a.status !== "pending" || Date.now() / 1000 > a.expires_at) return answer(cq, "expired or already decided");

  a.status = m[2] === "yes" ? "approved" : "denied";
  a.decided_at = Date.now() / 1000;
  a.decided_by = cq.from.username ?? String(cq.from.id);
  fs.writeFileSync(file, JSON.stringify(a, null, 2));
  await answer(cq, a.status === "approved" ? "✅ approved" : "❌ denied");
});
  • A secret path. Only someone who knows the URL gets in.
  • Telegram's secret. With setWebhook you pass a secret_token. Telegram sends it back on every call in the X-Telegram-Bot-Api-Secret-Token header.
  • Who pressed. In a private chat the chat id equals your user id. If you put the bot in a group, check from.id. Otherwise anyone in that group can press yes.

Acknowledge Telegram with a 200 right away and handle it afterwards. After the decision you update the message with editMessageText; the buttons disappear and it shows who decided what. You set the webhook like this:

curl https://api.telegram.org/bot$TG_BOT_TOKEN/setWebhook \
  -d url=https://yourdomain.com/telegram/$PATH_SECRET \
  -d secret_token=$HEADER_SECRET \
  -d 'allowed_updates=["callback_query"]'

What we learned

One hour is too short. The first version gave every question one hour. Three times a question raised at night expired before anyone was awake. The button stayed clickable, but the webhook refused it. The default is now 24 hours, and the content routine uses 48.

Write down that it expired. The script knew a question had expired, but the file still said pending. Anyone reading the file directly thought the question was still open. The script now writes the change to expired as well.

A decision only works if someone reads it. The gate records your yes or no, but the agent has to come and look. We had agents that only kept track of their own task. A yes given at half past three in the afternoon would only have been noticed two days later. We woke that agent up by hand at the time. The clean fix is for the gate to nudge the requesting agent itself as soon as there is a decision.

Run what was approved, nothing else. The gate only guarantees that a human said yes. If you let the agent come up with the command again after approval, what runs is not what you approved. That is why the command is in the message, and the agent runs that exact text.

Test the refusals, not just the yes. We tested a real click, a simulated click, a wrong secret (401), a click from another chat (status stays pending) and an expired question.

Common mistakes

Markdown in the message. One underscore in the description and Telegram rejects the message. The download version uses HTML for that reason.

Mixing a webhook and getUpdates. Once a bot has a webhook, getUpdates stops working. Check first that nothing else is listening on that bot. Sending messages keeps working.

No expiry. A question without an end can still be approved weeks later, in a situation that has long since changed.

Only checking the chat id in a group. Check who pressed.

Working with the Agent SDK in your own interface? Read how approvals and auto mode in the Agent SDK work. For the assistant in your hosting, see Claude approvals.

MK
Maarten Keizer

Founder of Invoker. Over twenty years in hosting, system administration and web development; builds the Claude hosting himself and tests everything on our own servers first.

about maarten