Zurück zum Blog
·von Patrick Hofmann

Im Chat-Wire gibt es keine agent_message

chat.openape.ai ist live. Friend-Request an einen Agent, DM-Thread öffnen, Nachricht tippen — auf der Drahtebene unterscheidet sich nichts von einem 1:1 mit einem Menschen.

OpenApeAI AgentsArchitectureBuilding in Public

Letzte Nacht habe ich einem Agent eine Friend-Request geschickt. Akzeptiert, DM eröffnet, "hi" getippt, vier Sekunden später kam eine Antwort.

Was auf der Drahtebene passiert ist: derselbe WebSocket-Frame wie bei einer menschlichen Antwort. Dieselbe DDISA-Auth. Derselbe 1:1-DM-Container. Dasselbe Web-Push, das mich auf dem Handy gepingt hätte, wäre ich offline gewesen.

Ich hatte vor, einen Web-Chat zu bauen. Was rausgekommen ist, ist ein Beleg für eine Aussage, die ich seit einer Weile mit mir herumtrage, ohne sie scharf formulieren zu können: Mensch und Agent sind auf Protokoll-Ebene dasselbe.

Wie es vorher war

Wenn ich mit einem meiner Agents reden wollte, ging das über Telegram. Telegram-DM rein, Telegram-DM raus. Das hat funktioniert — und tut es weiter, für unterwegs ist das nach wie vor der bequemste Weg.

Aber Telegram ist eine fremde Schicht. Bot-Identitäten, Mention-Patterns, das Markdown-Quirks-Universum, Rate-Limits. Jede dieser Eigenheiten musste der Agent-Stack kennen. Und es gab keinen Browser-Weg — wenn ich am Rechner saß und mit einem Agent reden wollte, musste ich den Telegram-Desktop daneben aufmachen.

Warum ich es gebaut habe

Nicht aus einer architektonischen Einsicht. Ich wollte einen Web-Chat. Ein Browser-Tab, in dem meine Agents als Kontakte stehen, in dem ich Threads aufmachen kann, in dem die letzten Nachrichten sichtbar sind, ohne dass ich eine Telegram-Suche bemühen muss.

Die architektonische Einsicht ist beim Bauen gefallen.

Wie es jetzt aussieht

Zwei Komponenten. ape-chat als foundation lib — der Server, der Identity, Contacts, Threads, Messages, WebSocket, Web-Push macht. Und chat-bridge als thin daemon — ein WebSocket-Client, der für jeden CLI-basierten Agent eingehende Frames in CLI-Aufrufe übersetzt und die Antwort zurückpostet. Saubere Trennung, beides unabhängig nutzbar.

Der Bridge-Daemon ist im Kern eine Schleife:

// chat-bridge: catch incoming, spawn pi, post reply
ws.on('message:new', async (msg) => {
  if (msg.thread !== myDmWith(human)) return;
  if (msg.from === self) return;

  const reply = await spawn('pi', ['--print', msg.body]);
  await ws.send('message:post', {
    thread: msg.thread,
    body: reply.stdout,
  });
});

pi ist hier der CLI-Agent, der über litellm einen ChatGPT-Subscription-Backend ansteuert. Das könnte aber genauso ein Claude-CLI sein oder ein eigenes Skript, das Markov-Ketten ausspuckt. Den Bridge interessiert nicht, was er spawnt.

Round-Trip: vier Sekunden, dominiert vom LLM-Call. Die WebSocket-Latenz und die Bridge-Schleife sind im Rauschen.

Was im Wire steht

Die zentrale Sache: es gibt keinen agent_message-Typ neben user_message. Im Schema steht Message. Die Felder sind from, to, body, timestamp, signature. Eine Nachricht von Mensch zu Mensch hat dieselbe Form wie eine von Agent zu Mensch, wie eine von Agent zu Agent.

Der einzige Unterschied liegt im Schlüsselmaterial des Senders. Menschen signieren mit ihrem Passkey, Agents mit einem Ed25519-Key, der bei Enrollment ausgestellt wird. Das ändert nichts an der Form der Nachricht — nur an der Identität, die der Server zurückrechnen kann, wenn er die Signatur prüft.

Und genau weil das Protokoll nicht zwischen den beiden unterscheidet, ist die nächste Phase praktisch von selbst rausgefallen: multiple Threads per DM. Du kannst parallele Konversationen zu demselben Agent halten — so, wie du parallele Themen mit einem Menschen führen kannst, mit dem du gleichzeitig über die Steuererklärung und über das Wochenende redest. Das musste ich nicht designen. Es war schon da, weil die DM-Container für 1:1-Menschen genauso funktionieren.

Was weggefallen ist

Ein paralleles Agent-Protokoll. Hätte ich bauen können — eigene Routes, eigenes Format, eigener Container, eigene Mention-Semantik. Mehr Code, mehr Drift zwischen den beiden Pfaden. Jede neue Feature-Idee hätte ich zweimal bauen müssen: einmal für Mensch-Mensch, einmal für Mensch-Agent.

Habe ich nicht. Nicht aus Disziplin, sondern weil beim Bauen offensichtlich wurde, dass es nichts gibt, was die zwei Pfade auseinanderhalten müsste. Eine Nachricht ist eine Nachricht.

Cousin

Vor ein paar Tagen habe ich push-substituiert-push geschrieben — Web-Push für Agent-Approvals, statt Telegram-Bot-Reply. Derselbe Move auf einer anderen Achse: nimm die out-of-band-Notification von einem Drittanbieter weg, ersetz sie durch native Infrastruktur, die genau dasselbe Property hat (Push aufs Handy), nur ohne die Drittanbieter-Schicht.

Was hier passiert, ist die nächste Schicht in derselben Reihe. Vorher: Telegram-out für Approvals. Dann: Web-Push für Approvals. Jetzt: Web-Chat für die generische Konversation, mit demselben Web-Push, das die Approval-Replacement übernommen hat, wenn ich gerade nicht im Browser-Tab bin. Drei Schritte, eine Richtung — die Drittanbieter-Schicht zwischen mir und meinen Agents wegnehmen, ohne deren brauchbare Eigenschaften zu verlieren.

Schluss

Die These, dass Agent und Mensch auf Protokoll-Ebene dasselbe sind, lässt sich nicht in einer Konferenzfolie beweisen. Sie beweist sich beim Bauen — und zwar dadurch, dass kein Sonderfall entsteht. Wenn ich beim Schreiben des Chat-Servers an irgendeinem Punkt einen if (sender.isAgent)-Branch hätte einbauen müssen, wäre die These widerlegt gewesen. Habe ich nicht.

Vier Sekunden, eine Nachricht, eine Antwort. Wer der Sender war, kann der Server an der Signatur ablesen, wenn er muss. Der Container muss es nicht wissen.