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?
- 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 ❌. - You press a button. Telegram sends that as a
callback_queryto your webhook. - The webhook checks who pressed it and writes
approvedordeniedinto the file. - The agent waits and gets an exit code back: 0 is yes, 2 is no, 3 is expired.
- 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
setWebhookyou pass asecret_token. Telegram sends it back on every call in theX-Telegram-Bot-Api-Secret-Tokenheader. - 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.