Der Agent im Browser
Artikel 11 · Serie: Ein lokaler Coding-Agent mit apfel
Der Agent hatte bisher zwei Gestalten. Erst ein Kommando, das einen Prompt schickt und die Antwort druckt, dann eine interaktive Sitzung im Terminal, die ein Gespräch hält und ihre Werkzeuge mit Bestätigung einsetzt. Beide leben im Terminal. Jetzt geben wir ihm eine dritte Gestalt: hinter einem Server, mit einer Oberfläche im Browser. Dabei ändert sich am Agenten selbst nichts. Was sich ändert, ist die Senke, in die seine Arbeit fließt. Der Stand ist eingefroren als Tag v1.0.
Dieselbe Logik, eine andere Senke
Die interaktive Sitzung aus Artikel 9 war von Anfang an so gebaut, dass sie ihre Ein- und Ausgabe nicht kennt. Sie bekommt drei Closures übergeben: eine, die eine Zeile liest, eine, die Text schreibt, eine, die einen Turn beantwortet. Das Terminal verdrahtet sie mit der Tastatur und dem Bildschirm. Genau diese Trennung zahlt sich jetzt aus. Der Server verdrahtet dieselbe Logik mit HTTP und einem Browser, ohne eine Zeile am Agent-Kern zu ändern.
Damit beide Oberflächen wirklich denselben Kern teilen, braucht es eine gemeinsame Sprache für das, was während eines Turns passiert. Wir geben den Momenten einen Namen, als Aufzählungstyp:
public enum AgentEvent: Sendable, Equatable {
case token(String)
case toolCall(name: String, arguments: String)
case toolResult(name: String, ok: Bool, summary: String)
case confirmation(tool: String, diff: String)
case done(reason: String)
}
Ein Turn fällt als Strom solcher Ereignisse heraus. Das Terminal rendert sie zu Text, der Server sendet sie als Server-Sent-Events an den Browser. Derselbe Kern, zwei Senken. Der Typ lebt im Agent-Kern und weiß nichts von HTTP; die Übersetzung ins Wire-Format ist eine Erweiterung im Server-Modul. So bleibt der Kern frei von der Transportschicht.
Zwei Schichten, zwei Aufgaben
Unter dem Agenten liegt weiterhin apfel-Serve, der das Modell als OpenAI-kompatiblen Endpoint bereitstellt. Darüber kommt nun ein Hummingbird-Server. Auf den ersten Blick sieht das nach einer Schicht zu viel aus, nach einem Proxy vor einem Proxy. Das ist es nicht. Die beiden Server haben verschiedene Aufgaben. apfel-Serve liefert Modell-Tokens. Hummingbird ist die Stelle, an der aus diesen Tokens ein agentischer Turn wird: Werkzeuge wählen, ausführen, Ergebnisse zurückspeisen, vor schreibenden Aktionen fragen. Der eine spricht das Modell-Protokoll, der andere orchestriert den Agenten.
Der Server ist ein eigenes Target, AgentServer, gebaut auf Hummingbird 2. Die Routen sind überschaubar:
let router = Router()
router.get("health") { _, _ in "ok" }
router.get("/") { _, _ in /* das Single-Page-UI */ }
router.post("chat") { request, context in /* einen Turn als SSE streamen */ }
router.post("confirm") { request, context in /* eine geparkte Bestätigung auflösen */ }
Vier Routen, und die beiden interessanten sind chat und confirm. Die erste öffnet einen Ereignisstrom, die zweite ist der Rückkanal für die Bestätigung.
Der Turn als Ereignisstrom
Eine Chat-Anfrage liefert keine fertige Antwort, sondern einen Strom. Server-Sent-Events sind dafür das schlichteste Mittel, das der Browser nativ versteht: eine offene HTTP-Antwort, in die der Server Zeile für Zeile schreibt, jede beginnend mit data: und durch eine Leerzeile getrennt. In Hummingbird 2 ist die Antwort ein ResponseBody mit einem Writer, den wir aus einem Ereignisstrom speisen:
let body = ResponseBody { writer in
for await event in stream {
try await writer.write(ByteBuffer(string: event.sseEncoded()))
}
try await writer.finish(nil)
}
Der Turn läuft nebenläufig und schiebt seine Ereignisse in den Strom, der Writer zieht sie heraus und schreibt sie als Frames. Im Browser konsumiert ein knappes Stück JavaScript die Frames und baut die Oberfläche daraus auf: Token werden an die Antwort gehängt, Tool-Calls als kleine Marken gezeigt, das Abschluss-Ereignis macht die Eingabe wieder frei. Kein Framework, nur fetch und ein Parser für die data:-Zeilen.
Geroutet wird ein Turn genau wie in der Terminal-Sitzung. Eine Anfrage, die eine Datei ändern will, geht durch den constrained Edit-Flow aus Artikel 7, nicht durch freies Tool-Calling, das beim Editieren scheitert. Alles andere, also Lesen, Auflisten, Erklären, Befehle ausführen, geht durch einen read-only Tool-Round-Trip. Die schreibenden Werkzeuge liegen gar nicht erst in dieser Registry; sie erreichen den Nutzer nur über den Edit-Flow mit seinem Gate.
Bestätigen ohne Tastendruck
Im Terminal blockiert das Bestätigungs-Gate auf einen Tastendruck. Es zeigt den Diff und wartet auf y, n oder a. Über HTTP gibt es keinen Tastendruck und keinen synchronen Menschen am anderen Ende. Trotzdem soll die Regel gelten: keine Datei wird geschrieben, kein Befehl läuft, bevor jemand zugestimmt hat.
Das Gate ist seit Artikel 5 ein Protokoll mit einer einzigen asynchronen Methode. Genau das macht die Web-Variante einfach. Der WebGate schreibt ein confirmation-Ereignis in den laufenden Strom und parkt den Turn auf einer Continuation:
public func confirm(_ action: PendingAction) async -> Decision {
await emit(.confirmation(tool: action.toolName, diff: action.preview))
if let buffered { self.buffered = nil; return buffered }
return await withCheckedContinuation { self.pending = $0 }
}
public func resolve(_ decision: Decision) {
if let pending { self.pending = nil; pending.resume(returning: decision) }
else { buffered = decision }
}
Der Turn steht still, mitten in der Arbeit. Im Browser erscheint der Diff mit drei Knöpfen. Ein Klick schickt POST /confirm mit der Entscheidung, resolve weckt die Continuation, der Turn läuft weiter. Der kleine Puffer fängt den Fall ab, dass die Antwort eintrifft, bevor der Turn überhaupt geparkt hat.
Im Lauf gegen apfel sieht das so aus. Wir bitten den Agenten, eine Zeile in eine Datei einzufügen. Der Strom hält an:
data: {"diff":"+ GREETING to note.txt","tool":"write_file","type":"confirmation"}
Hier endet der Strom vorerst. Kein done, kein Schreiben. Erst POST /confirm mit deny bringt ihn zum Abschluss:
data: {"text":"Insertion in note.txt declined.","type":"token"}
data: {"reason":"complete","type":"done"}
Die Datei bleibt unangetastet. Dieselbe Sicherheit wie im Terminal, übersetzt in zwei HTTP-Anfragen.
Wo das Modell den Strom stört
Der Server macht eine Eigenschaft des kleinen Modells sichtbarer als jede Oberfläche zuvor: seine Unzuverlässigkeit beim Tool-Calling, gemessen über drei Aufrufe derselben Bitte „nenne die Uhrzeit, nutze dein Werkzeug" (Eigenmessung v1.0). Einmal kam ein sauberer Tool-Call:
data: {"arguments":"{}","name":"get_time","type":"tool_call"}
Die anderen beiden Male nicht. Mal schrieb das Modell den Tool-Call als Text aus, in JSON gegossen, aber als Antwort statt als Aufruf, sodass kein Werkzeug lief. Mal verfehlte der Intent-Classifier die Grenze und schob eine harmlose Frage in den Edit-Flow. Das ist dieselbe Schwäche aus Artikel 4 bis 7, und der Ereignisstrom versteckt sie nicht, er zeigt sie Frame für Frame. Eine Oberfläche, die ehrlich streamt, streamt auch die Aussetzer mit.
Sitzungen und Grenzen
Der Server hält pro Browser eine Sitzung, angelegt beim ersten Kontakt und über einen Header zurückgegeben. Eine Sitzung trägt ihren Gesprächsverlauf über mehrere Turns und ihr gerade offenes Gate. Innerhalb einer Sitzung laufen die Turns nacheinander, verschiedene Sitzungen laufen unabhängig. Die Registry ist ein Actor, damit zwei gleichzeitige Anfragen, etwa ein chat und ein confirm, sich nicht in die Quere kommen.
Zwei Grenzen ziehen wir bewusst. Der finale Text kommt als ein Token-Ereignis, nicht Zeichen für Zeichen. Live ist die Ebene des Turns, also die Werkzeuge, die Bestätigung, der Abschluss; das zeichenweise Streamen des Modelltexts wäre ein weiterer Schritt und verlangte, den Loop selbst streamen zu lassen. Und es gibt keine Anmeldung, keine Persistenz, eine Sandbox pro Prozess. Ein produktiver Mehrbenutzer-Server mit Auth und Rate-Limiting ist ein eigenes Thema, das die Hummingbird-Reihe behandelt.
Demo-Repo: apfel-coding-agent v1.0
Der Stand dieses Artikels ist eingefroren als Tag v1.0: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v1.0
Den Server ausprobieren
Zuerst apfel-Serve, dann den Agent-Server, aus dem Repo-Root:
git clone https://codeberg.org/rotecodefraktion/apfel-coding-agent.git
cd apfel-coding-agent
git checkout v1.0
swift build --product apfel-agent-server
apfel --serve --port 11577 # das Modell (11434 oft von Ollama belegt)
.build/debug/apfel-agent-server \
--base-url http://127.0.0.1:11577 --port 8099 --workdir .
Dann http://127.0.0.1:8099 im Browser öffnen. Ohne Browser von Hand:
curl -sN -X POST http://127.0.0.1:8099/chat \
-H 'content-type: application/json' \
-d '{"message":"Read note.txt and summarise it."}'
Neu in v1.0 gegenüber v0.10:
Sources/AgentServer/— der Hummingbird-Server (Routen, SSE, WebGate, Session-Registry)Sources/apfel-agent-server/— das ExecutableSources/AgentCore/Agent/AgentEvent.swift— die gemeinsame Turn-Spracheweb/index.html— das Single-Page-UIdocs/usage-server.md,docs/adr/006-hummingbird-frontend.md
Fazit
Der Agent hat jetzt drei Oberflächen, und alle drei sitzen auf demselben Kern. Das ist die eigentliche Lehre dieses Artikels: Wer Orchestrierung und Ausgabe sauber trennt, bekommt die nächste Oberfläche fast geschenkt. Aus dem Terminal wurde ein Server, indem wir die Ausgabe-Senke tauschten und ein Gate über zwei HTTP-Anfragen führten. Der Agent bleibt, was er von Anfang an war, ein lokaler Client gegen ein Modell auf dem eigenen Gerät. Bevor wir ihn zum Höhepunkt der Reihe in Xcode setzen, halten wir im nächsten Schritt inne und fragen, worauf diese ganze Souveränität eigentlich ruht.
Vorheriger Artikel: Werkzeuge, die der Agent nicht schreibt: MCP. Nächster Artikel: Souveränität auf geliehenem Fundament. Repo-Tag: v1.0.