Push substituiert Push
Approval-Notifications liefen über einen Telegram-Bot. Web Push aus openape-free-idp und openape-chat ersetzt das jetzt — VAPID, Service Worker, native Browser-Permission. Gleiche Eigenschaft, anderer Transport. Erst die Substitution macht sichtbar, was tragend war.
22:47 abends, Handy vibriert. Ein Agent will ein git push auf einen Branch, für den er noch keinen Standing Grant hat. Lock Screen, Tap, Approve-Seite öffnet sich, Ja. Drei Sekunden, ohne den Rechner aufzumachen.
Das hat funktioniert. Jeder Approval-Prompt, den kein Pattern durchgelassen hat, ging über einen Telegram-Bot an mich. Bot-Token, chat_id pro User, ein bisschen Setup beim Onboarding. War eingebaut, lief.
Heute läuft derselbe Flow über Web Push direkt aus openape-free-idp und openape-chat. VAPID, Service Worker, kein Telegram mehr.
Eine Sache, die das nicht ist
Bevor das aussieht wie der nächste Schritt einer alten Story: das Blocking-Problem — Agent wartet auf Approval, blockiert die Pipeline — ist nicht das, was hier gelöst wird. Das war das Thema vom letzten Post und es wurde mit asynchronem Run + --wait + sysexits Exit-Code 75 gelöst. Der Pattern bleibt im Stack. Dass der Agent während der Wartezeit nicht weiß, was passiert, ist okay — kein Schaden, nur Sichtbarkeit, und dafür ist --wait da.
Die zwei Dinge sind orthogonal. Async + --wait kümmert sich um was macht der Agent während ich überlege. Heute geht es nur um wie kommt die Anfrage zu mir.
Was Telegram immer war
Telegram war eine Erfüllung der Eigenschaft, nicht die Eigenschaft selbst.
Die Eigenschaft, die ich brauche, heißt out-of-band. Approval-Anfragen dürfen nicht auf demselben Transport laufen, auf dem der Agent gerade arbeitet. Wenn der Agent über meinen Terminal-SSH-Tunnel arbeitet, kann die Notification nicht durch denselben Tunnel kommen — sonst sitze ich vor einem Terminal, in dem nichts passiert, und merke nicht, dass etwas auf mich wartet. Approval-Transport ≠ Worker-Transport. Das ist die Eigenschaft.
Telegram war die einfache Antwort: ein Drittanbieter, der per definitionem nichts mit meinem Agent-Stack zu tun hat. Out-of-Band war erfüllt, weil Telegram in einer anderen Welt lebt.
Aber der Drittanbieter brachte sein eigenes Setup mit:
- Bot-Token muss erstellt, rotiert und sicher gehalten werden
chat_idpro User muss bekannt sein, sonst geht keine Nachricht raus- Telegram-Account vorausgesetzt — kein Account, kein Approval-Push
- Drittanbieter-Surface — alles was an Telegram geht, läuft durch Telegrams Infrastruktur
Was ich gewollt habe, war die Eigenschaft. Was ich gekriegt habe, war Eigenschaft + Konfigurations-Tail.
Warum Web Push
Web Push ist out-of-band auf eine andere Art: der Browser hat seinen eigenen Push-Service (Apple, Mozilla autopush, FCM), der mit meinem Server nichts zu tun hat. Der IdP schickt eine VAPID-signierte Notification an den Push-Service, der stellt sie dem Browser zu, der Service Worker zeigt sie an, ich tappe drauf, lande auf der Approve-Seite.
Aus meiner Sicht: immer noch out-of-band, der Pfad geht nicht durch den Agent-Stack. Aus User-Sicht: alles ändert sich.
- Kein Bot-Token zu rotieren — VAPID-Keypair liegt server-seitig, einmal generiert
- Kein
chat_id-Setup — die Push-Subscription entsteht beim ersten Login auf dem Gerät, automatisch - Kein Telegram-Account — jeder moderne Browser kann das
- Native Browser-Permission als expliziter Consent — der Browser fragt, der User entscheidet, OS-Standard-Dialog, nicht von mir gebaut, nicht umgehbar
Das letzte ist der Punkt, der mich am meisten überzeugt hat. Bei Telegram war der Consent diffus: du hast einen Account, du folgst einem Bot, also kriegst du Pushes. Bei Web Push ist der Consent explizit, vom Browser durchgesetzt: erlaubst du dieser Origin, dir Notifications zu schicken? Ja/Nein. Eine klare Antwort vom User, die ich nicht selbst eingeholt habe und nicht selbst durchsetzen muss.
Wie es jetzt aussieht
Im IdP liegt ein VAPID-Keypair. Der Public Key ist in der Frontend-Bundle. Beim Login registriert der Browser den Service Worker, der eine Push-Subscription anlegt, die im IdP gespeichert wird. Wenn ein Agent einen Approval-Request stellt, signiert der Server die Notification mit dem VAPID-Private-Key und schickt sie an den Push-Service-Endpoint aus der Subscription.
// idp/server/push.ts (gekürzt)
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 },
})
)
Der Service Worker empfängt das Event und zeigt die Notification an. Auf Tap öffnet er die Approve-URL — derselbe Endpoint wie früher, nur die Notification kam vorher anders rein.
// public/sw.ts (gekürzt)
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}`))
})
Das ist alles. Keine Bot-Bibliothek, keine Polling-Loop, keine externe Abhängigkeit außer web-push als Server-Lib — und das ist eine npm-Lib, kein Service.
Was weggefallen ist
- Bot-Token-Rotation und der Operator-Pfad drumherum
chat_id-Mapping und das Onboarding-Stück, das dieses Mapping anlegt- Die Begründung, warum der User für Approvals einen Telegram-Account braucht
- Eine Klasse von was wenn Telegram down ist-Fragen — die liegen jetzt verteilt auf Apple, Mozilla, Google, was strukturell besser ist als auf Telegram allein
Was geblieben ist:
- Die Out-of-Band-Eigenschaft (andere Implementierung, dieselbe Eigenschaft)
- Der
--wait-Pattern für blockierende Workflows (orthogonal, im Stack geblieben) - Die Approve-Seite, der Standing-Grant-Match, der gesamte Authorization-Pfad
Was die Substitution sichtbar macht
Beim Bauen war Telegram für mich Teil des Features. Hätte mich jemand gefragt, was meine Approval-Notifications sind, hätte ich gesagt: Telegram-Bot, der mir Pushes schickt. Das war konkret, das fühlte sich nach Antwort an.
Erst beim Substituieren wurde klar, dass das nicht die Antwort war. Telegram war eine Implementierung einer Eigenschaft. Die Eigenschaft war Notification über einen Kanal, der nicht der Agent-Kanal ist. Telegram hat das erfüllt — und gleichzeitig die Eigenschaft mit eigenem Konfigurations-Krempel verbunden, der mit der Eigenschaft selbst nichts zu tun hatte.
Wenn man eine Implementierung tauscht und die Eigenschaft bleibt, war die Eigenschaft tragend. Wenn man eine Implementierung tauscht und etwas anderes auch wegfällt — Bot-Token, chat_id, Drittanbieter-Voraussetzung — dann war das nicht tragend, sondern beim Implementieren mit eingewickelt.
Das ist nicht Telegram-spezifisch. Das ist das Pattern, das ich jedes Mal sehe, wenn ich einen Drittanbieter durch eine native Lösung ersetze: das, was bleibt, war strukturell. Das, was weggeht, war Erfüllungs-Detail.
Architektur erkennt man daran, dass sie eine Substitution überlebt.