Approve What Your Headless Coding Agent Does With One Telegram Button
The problem with letting agents run unattended
I run several coding agents headless on my workstation. One reviews pull requests, one implements tickets from a written plan, one watches repositories and regenerates documentation when the code moves. They are started by timers and scripts, so nobody is sitting at a terminal when they finish.
Most of their work is safe to do alone: reading code, running tests, writing a report to disk. A few steps are not. Posting a review comment under my name, pushing a branch, opening a PR, deploying to a shared environment. Those are outward facing and hard to take back, and I want to see them before they happen.
A permission prompt needs a terminal. Email is too slow to act on. A web dashboard is one more thing to keep running. What I already had open all day was Telegram on my phone, and Telegram bots support buttons under a message. So every risky step became a message with Approve, Show and Skip under it.
The whole flow

The top row is the agent's half. It does its work, writes the draft
(a comment, a PR description, a plan) to a file, and registers a small
pending item next to it: what kind of action this is,
where it goes, and a status of awaiting. An offer script
then sends the draft to Telegram with the three buttons.
The bottom row is mine. I read the message and tap. The tap reaches a bridge, a single long-running process that polls the bot, which hands it to an action handler. The handler does the publishing and writes the decision back onto the pending item.
The split in colour is the point of the design. The agent can only propose. It never holds the token that posts to GitHub or deploys the site. The publisher holds those, and it only acts when I tap.
One tap, step by step

Each check in that picture is there because something broke without it.
Who tapped. The bridge drops any button press that does not come from my own chat. If the bot is ever added to a group, or someone else finds it, their taps must do nothing.
What the id looks like. The item id travels inside the button and ends up pointing at a file on disk, so only plain letters, digits and dashes are accepted. Telegram also caps button data at 64 bytes, which I found out the first time an id was a long, readable title and the message simply failed to send. Short ids, everything else in the file.
Answer the tap straight away. If the bot does not acknowledge the button press, the button keeps spinning on the phone and you tap it again.
Only one process polls the bot. Telegram hands each update to one consumer. When two scripts poll the same bot, they steal each other's taps and the buttons appear to do nothing. Every agent's buttons go through the one bridge, each with its own prefix.
Remember where you got to. The bridge stores the last update it handled. Without that, a restart replays the day's taps and approves things twice.
The life of a pending item

There are two ways an item gets approved.
In publish mode the tap does the work: the handler
moves the item to publishing, posts the comment or opens
the PR, and marks it done or failed. Moving to
publishing before the slow call is the double-tap guard.
Phones lag and people tap twice, and the second tap finds the item
already past awaiting and changes nothing. A failed item is
not lost; once the cause is fixed it goes back to awaiting
and is offered again.
In gate mode the agent is still running and waiting
on the answer. The ticket-implementing agent writes a plan, offers it,
and then watches the pending item. The tap only records
approved, and the agent carries on and starts changing
code. Because the whole protocol is a file on disk, I can also approve
from a shell when my phone is not around, and every decision is still
there to read afterwards.
The one design rule I would not bend
It is tempting to store a ready-made command in the pending item and simply run it when Approve is tapped. It is convenient, and it means anything that can write a pending file can run whatever it likes the moment I tap a button on my phone, usually without reading closely.
So the item carries data, never code. There is a fixed list of kinds (a GitHub comment, a pull request, a blog post) and each kind has a publisher written by hand. A new kind of action means writing a new publisher, deliberately.
What I would tell someone building this
- Show the real thing. Approving a summary of a comment is not approving the comment. Send the exact text that will go out, or the diff. For anything long, the message carries a preview and Show sends the full draft as a file.
- Make Skip cheap. If saying no is more work than saying yes, you will start saying yes.
- Keep a log of decisions. An append-only history of what was proposed, what I chose and when has been more useful than I expected, mostly for spotting which kinds of proposal I keep skipping and fixing the agent that makes them.
The whole thing is a few hundred lines of shell around Telegram's bot API. It has been the difference between agents I check on and agents I let run while I sleep, because the parts that matter still wait for me.