Your agent's approval arrives in Telegram as a screenshot with two buttons — and the tap is the answer.
npm install handraise handraise-telegramThe adapter is not on npm yet. handraise is:
channels, which this package is built on, landed in handraise 0.5.1. Until the adapter is published, install handraise from npm and take this one from the repo — see Version note.
import { raiseHand } from "handraise"
import { telegram } from "handraise-telegram"
const { TELEGRAM_BOT_TOKEN = "", TELEGRAM_CHAT_ID = "" } = process.env
// Build it once and reuse it: one channel owns one poll loop. See below.
const channel = telegram({ botToken: TELEGRAM_BOT_TOKEN, chatId: TELEGRAM_CHAT_ID })
await raiseHand(page, {
mode: "approval",
reason: "The agent may not move money without a human",
action: "Submit $12,430 vendor payment to Acme GmbH",
channels: [channel],
})
// outcome "approved" → somebody pressed Approve, in Telegram or on the phoneThat is the whole integration. telegram() returns a
HandoffChannel; handraise calls
it when the handoff starts and hands it the screenshot the phone would show,
plus a way to answer.
Zero dependencies. fetch, FormData and Blob are globals on Node
20+ and on bun. The one thing it imports is node:crypto, for the random
nonce that authorizes a button press.
- Message @BotFather, send
/newbot, copy the token. - Send your new bot a message (a bot cannot write to you first), then open
https://api.telegram.org/bot<TOKEN>/getUpdatesand readchat.idout of the answer. For a group, add the bot to it and do the same. - Put both in your environment.
No webhook, no public URL, no ngrok: this package long-polls getUpdates,
so it only ever makes outbound requests. (That is why Telegram is the first
adapter; Slack needs a public interactivity endpoint.)
An approval is one photo — the page as the agent left it — captioned with
the reason and the exact step, with Deny and Approve under it. One tap
settles the raiseHand call that is waiting. The buttons then disappear and
the caption gains a line:
The agent may not move money without a human
Submit $12,430 vendor payment to Acme GmbH
Approved by Simon
If the phone got there first, the tap says so instead — "Already decided elsewhere — this tap changed nothing" — and nothing is overturned. handraise's first answer wins, whoever gives it.
A takeover is a message with the reason and the handoff link, because a takeover cannot be answered from a chat: the human has to drive the browser, and only the handoff page can do that.
| Option | Type | Default | |
|---|---|---|---|
botToken |
string |
required | From @BotFather. A credential — it is in every request URL. |
chatId |
string | number |
required | Numeric chat id, or @channelname. |
pollIntervalMs |
number |
25 s | How long one getUpdates call waits for a press before it is made again (Telegram's timeout). Must be positive and finite, at most 50 s — Telegram's own cap. |
maxWaitMs |
number |
6 min | How long to keep an approval open. Must be positive and finite, at most 24 h. See the limitation below. |
baseUrl |
string |
https://api.telegram.org |
The Bot API root. |
onWarn |
(event, detail) => void |
— | Called for every failure this package swallows — a chat call Telegram refused while catching up with a decision already made. detail has the token redacted. Nothing is printed without it. |
It closes when the handoff does. handraise 0.5.1 hands every channel a
settled promise, and this package waits on it alongside the button. So an
approval answered on the phone, or one that times out, or one whose browser
session dies, closes here immediately: the buttons come off and the caption
says which of those happened — "Decided on the phone: approved.", "Timed
out — nobody answered in time.", "The browser session ended before this was
answered."
And nothing is left running. The getUpdates call in flight is dropped
when the last approval closes, not waited out, so this package lets go of your
event loop instead of holding it for the rest of pollIntervalMs — 25 seconds
by default. scripts/exit-probe.ts measures exactly that, as a test: a child
process that settles a handoff and then does nothing exits 1 ms after
notify returns. Before the poll was aborted it exited 25.0 s after.
maxWaitMs is only a backstop. It bounds the wait for a handraise that
somehow never settles, which should not happen. It matters in one case: if you
raise timeoutMs on raiseHand past 6 minutes, raise maxWaitMs to match, or
the buttons go away while the agent is still waiting.
One sharp edge remains, and it is small: a press that lands in the same instant the approval closes is consumed but not acted on, so that person's button spinner hangs until Telegram gives up on it. The handoff is unaffected.
One process per bot, and that one is fine. getUpdates is a bot-wide
stream with a single cursor: an update is confirmed for the whole bot the
moment any call uses a higher offset, and Telegram answers 409 to a second
overlapping call. So:
- Inside one process, any number of approvals can be open at once. One
telegram({ … })owns one long-poll loop and hands each press to the approval whose button it is. Approvals from separatetelegram()calls in the same process are separate loops and will conflict — build the channel once and reuse it. - A second process on the same token is a conflict. Both get 409s and
both lose updates. This package does not retry that: polling stops at once,
the buttons come off, and
onWarngets the reason with what to do about it (stop the other poller, ordeleteWebhook). Give each process its own bot.
Large screenshots arrive as a file. Telegram will not take a photo over
10 MB, over 10 000 px of width plus height, or past a 20:1 aspect ratio. A
screenshot past any of those is sent with sendDocument instead — no inline
preview, but the caption, the buttons and the answer all work the same. A
1280x800 viewport is nowhere near any of these limits.
- The bot token is a credential and it is in every request URL. This
package never logs it, and every error it throws is redacted before it is
built —
String(error)on a failedfetchcarries the URL. Do not paste a token into an issue; if you have, revoke it with/revokein @BotFather. - A button press is authorized twice. Telegram says a client may submit any
callback_datait likes, so the data is a claim, not a credential. Each approval carries a fresh 96-bit random nonce, and the press has to come from the exact chat and message this package sent — which Telegram fills in itself and a stranger cannot forge. Knowing a handoff id, or seeing an old button, is not enough to answer anything. - Anyone in the chat can decide. A group chat means every member can press Approve, and Telegram's own tools decide who is in that group. Use a private chat with the bot, or a group whose membership is your approver list.
- The takeover link is a bearer credential. Whoever opens it drives a live browser session carrying the agent's cookies. It dies with the handoff, and the message says as much — but a chat is a durable log, so post takeover links only where you would post a password.
- The screenshot is the page. It can contain an account number, an address, a half-filled form. Telegram stores it; that is the trade for deciding from a phone. Approval mode sends one frame and no live view, which bounds what can ever leave.
bun run test # everything, against a local fake Bot API server
bun run dist:smoke # loads the built artifact under node
bun run test:live # one real message, waits for a real tapscripts/exit-probe.ts runs inside bun run test as a child process: it
settles a handoff, then does nothing, and the suite asserts that it exits. It
is not a unit test of anything — it is the only way to check the claim above
from outside the process making it.
bun run test starts an actual HTTP server that answers like the Bot API — the
multipart upload, the long poll that really blocks, the 401 for a wrong token —
because a mock of Telegram would only prove the mock. test:live needs
TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID and skips with instructions when
they are missing.
This package needs handraise ≥ 0.5.1, the release that added channels.
handraise is a peerDependency (>=0.5.1): nothing of it is imported at
runtime, and a second nested copy would put two HandoffChannel declarations
in one program. So install it yourself, alongside this one:
npm install handraise # on npm
npm install handraise-telegram # not yet — from this repo for nowMIT © Simon Doba