Onze agents doen veel zelf. Voor een paar dingen willen we eerst een mens: een wijziging op een klantsite, een commando dat je niet terugdraait, een publicatie. Die vraag komt als Telegram-bericht met twee knoppen. Zo werkt ook onze wekelijkse contentroutine: maandag schrijft een agent de artikelen, er komt een knop op de telefoon, woensdag gaat het live.
Het is een klein stuk gereedschap, maar we hebben er meer van geleerd dan we dachten. Het script kun je gratis downloaden.
Download: approval-gate.py (MIT-licentie, vrij te gebruiken)
Hoe werkt het?
- De agent vraagt goedkeuring aan met een omschrijving en het exacte commando. Er komt een JSON-bestand met status
pending, en een Telegram-bericht met ✅ en ❌. - Je drukt op een knop. Telegram stuurt dat als
callback_querynaar je webhook. - De webhook controleert wie er drukte en schrijft
approvedofdeniedin het bestand. - De agent wacht en krijgt een exitcode terug: 0 is ja, 2 is nee, 3 is verlopen.
- Bij 0 draait de agent precies het commando dat in het bestand staat.
Vanuit een script of agent ziet dat er zo uit:
id=$(python3 approval-gate.py request "Cache legen op de webshop" \
--command "wp litespeed-purge all" --source onderhoud-agent)
if python3 approval-gate.py wait "$id" --timeout 600; then
wp litespeed-purge all
fi
Het script heeft alleen de standaardbibliotheek van Python nodig. Je zet TG_BOT_TOKEN (van @BotFather) en TG_CHAT_ID (je eigen chat) in de omgeving.
De webhook: wie mag er drukken?
De knop is het makkelijke deel. De webhook bepaalt of het veilig is. Die van ons draait in een kleine Express-app en controleert drie dingen voordat er iets verandert:
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); // Telegram meteen bevestigen
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, "geen toestemming");
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, "verlopen of al beslist");
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" ? "✅ goedgekeurd" : "❌ afgewezen");
});
- Een geheim pad. Alleen wie de URL kent, komt erin.
- Het geheim van Telegram. Bij
setWebhookgeef je eensecret_tokenmee. Telegram stuurt dat bij elke aanroep terug in de headerX-Telegram-Bot-Api-Secret-Token. - Wie er drukte. In een privéchat is het chat-id gelijk aan je gebruikers-id. Zet je de bot in een groep, controleer dan
from.id. Anders mag iedereen in die groep op ja drukken.
Bevestig Telegram direct met een 200 en handel het daarna af. Na het besluit pas je het bericht aan met editMessageText; dan verdwijnen de knoppen en staat er wie wat besloot. De webhook zet je zo:
curl https://api.telegram.org/bot$TG_BOT_TOKEN/setWebhook \
-d url=https://jouwdomein.nl/telegram/$PATH_SECRET \
-d secret_token=$HEADER_SECRET \
-d 'allowed_updates=["callback_query"]'
Wat we leerden
Een uur is te kort. De eerste versie gaf elke vraag een uur. Drie keer verliep een vraag die 's nachts ontstond voordat er iemand wakker was. De knop bleef klikbaar, maar de webhook weigerde hem. Nu is de standaard 24 uur, en de contentroutine gebruikt 48 uur.
Verlopen moet je opschrijven. Het script zag dat een vraag verlopen was, maar in het bestand bleef pending staan. Wie het bestand zelf las, dacht dat de vraag nog open stond. Nu schrijft het script de overgang naar expired ook weg.
Een besluit werkt alleen als iemand het leest. De gate legt je ja of nee vast, maar de agent moet zelf komen kijken. We hadden agents die alleen hun eigen taak bijhielden. Een ja van half vier 's middags zou pas twee dagen later zijn opgemerkt. We hebben die agent toen met de hand wakker gemaakt. De nette oplossing is dat de gate de vragende agent zelf een seintje geeft zodra er besloten is.
Voer uit wat is goedgekeurd, niets anders. De gate garandeert alleen dat een mens ja zei. Laat je de agent na de goedkeuring het commando opnieuw bedenken, dan heb je iets anders goedgekeurd dan er draait. Daarom staat het commando in het bericht, en draait de agent letterlijk die tekst.
Test de weigeringen, niet alleen het ja. We testten een echte klik, een nagebootste klik, een verkeerd geheim (401), een klik uit een andere chat (status blijft pending) en een verlopen vraag.
Veelgemaakte fouten
Markdown in het bericht. Eén underscore in de omschrijving en Telegram weigert het bericht. De downloadversie gebruikt daarom HTML.
Webhook en getUpdates door elkaar. Heeft een bot een webhook, dan werkt getUpdates niet meer. Kijk eerst of er niets anders op die bot luistert. Berichten versturen blijft gewoon werken.
Geen vervaltijd. Een vraag zonder einde kan weken later nog worden goedgekeurd, in een situatie die allang anders is.
Alleen op het chat-id controleren in een groep. Controleer wie er drukte.
Werk je met de Agent SDK in je eigen interface? Lees dan hoe goedkeuringen en auto-modus in de Agent SDK werken. Voor de assistent in je hosting staat het bij Claude laten goedkeuren.