Push Substitutes Push
Approval notifications ran over a Telegram bot. Web Push from openape-free-idp and openape-chat replaces that now — VAPID, service worker, native browser permission. Same property, different transport. Only the substitution makes visible what was load-bearing.
22:47 in the evening, phone vibrates. An agent wants a git push on a branch it doesn't have a standing grant for yet. Lock screen, tap, approve page opens, yes. Three seconds, without opening the computer.
That worked. Every approval prompt no pattern let through went over a Telegram bot to me. Bot token, chat_id per user, a bit of setup at onboarding. Was built in, ran.
Today the same flow runs over Web Push directly out of openape-free-idp and openape-chat. VAPID, service worker, no more Telegram.
One thing this isn't
Before this looks like the next step of an old story: the blocking problem — agent waits for approval, blocks the pipeline — is not what's solved here. That was the topic of the last post and it was solved with async run + --wait + sysexits exit code 75. The pattern stays in the stack. That the agent doesn't know what's happening during the wait is okay — no harm, just visibility, and that's what --wait is for.
The two things are orthogonal. Async + --wait takes care of what does the agent do while I think. Today it's only about how does the request get to me.
What Telegram always was
Telegram was a fulfillment of the property, not the property itself.
The property I need is called out-of-band. Approval requests must not run on the same transport the agent is working on. When the agent works over my terminal SSH tunnel, the notification can't come through the same tunnel — otherwise I sit in front of a terminal where nothing happens and don't notice something is waiting on me. Approval transport ≠ worker transport. That's the property.
Telegram was the easy answer: a third party that by definition has nothing to do with my agent stack. Out-of-band was fulfilled because Telegram lives in another world.
But the third party brought its own setup:
- Bot token has to be created, rotated and kept safe
chat_idper user has to be known, otherwise no message goes out- Telegram account presupposed — no account, no approval push
- Third-party surface — everything that goes to Telegram runs through Telegram's infrastructure
What I wanted was the property. What I got was property + configuration tail.
Why Web Push
Web Push is out-of-band in a different way: the browser has its own push service (Apple, Mozilla autopush, FCM), which has nothing to do with my server. The IdP sends a VAPID-signed notification to the push service, which delivers it to the browser, the service worker shows it, I tap it, land on the approve page.
From my point of view: still out-of-band, the path doesn't go through the agent stack. From the user's point of view: everything changes.
- No bot token to rotate — VAPID keypair sits server-side, generated once
- No
chat_idsetup — the push subscription is created at first login on the device, automatically - No Telegram account — every modern browser can do this
- Native browser permission as explicit consent — the browser asks, the user decides, OS standard dialog, not built by me, not bypassable
The last one is the point that convinced me most. With Telegram the consent was diffuse: you have an account, you follow a bot, so you get pushes. With Web Push the consent is explicit, enforced by the browser: do you allow this origin to send you notifications? Yes/No. A clear answer from the user that I didn't obtain myself and don't have to enforce myself.
How it looks now
A VAPID keypair sits in the IdP. The public key is in the frontend bundle. At login the browser registers the service worker, which creates a push subscription that's stored in the IdP. When an agent makes an approval request, the server signs the notification with the VAPID private key and sends it to the push-service endpoint from the subscription.
// idp/server/push.ts (abbreviated)
webpush.setVapidDetails(
'mailto:patrick@delta-mind.at',
VAPID_PUBLIC,
VAPID_PRIVATE
)
await webpush.sendNotification(
subscription, // { endpoint, keys: { p256dh, auth } }
JSON.stringify({
title: 'Approval erforderlich',
body: `${request.agent} möchte ${request.action}`,
data: { grantId: request.grantId },
})
)
The service worker receives the event and shows the notification. On tap it opens the approve URL — the same endpoint as before, only the notification came in differently before.
// public/sw.ts (abbreviated)
self.addEventListener('push', (event) => {
const payload = event.data?.json()
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
data: payload.data,
requireInteraction: true,
})
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const { grantId } = event.notification.data
event.waitUntil(self.clients.openWindow(`/approve/${grantId}`))
})
That's all. No bot library, no polling loop, no external dependency except web-push as a server lib — and that's an npm lib, not a service.
What fell away
- Bot-token rotation and the operator path around it
chat_idmapping and the onboarding piece that creates that mapping- The justification for why the user needs a Telegram account for approvals
- A class of what if Telegram is down questions — those are now distributed across Apple, Mozilla, Google, which is structurally better than on Telegram alone
What stayed:
- The out-of-band property (different implementation, same property)
- The
--waitpattern for blocking workflows (orthogonal, stayed in the stack) - The approve page, the standing-grant match, the entire authorization path
What the substitution makes visible
While building, Telegram was part of the feature to me. If someone had asked me what my approval notifications are, I'd have said: Telegram bot that sends me pushes. That was concrete, it felt like an answer.
Only on substituting did it become clear that this wasn't the answer. Telegram was one implementation of a property. The property was notification over a channel that isn't the agent channel. Telegram fulfilled that — and at the same time tied the property to its own configuration clutter that had nothing to do with the property itself.
When you swap an implementation and the property stays, the property was load-bearing. When you swap an implementation and something else falls away too — bot token, chat_id, third-party prerequisite — then that was not load-bearing, but wrapped in during implementation.
That's not Telegram-specific. That's the pattern I see every time I replace a third party with a native solution: what stays was structural. What goes was a fulfillment detail.
You recognize architecture by the fact that it survives a substitution.