--- source_sha: "026fa67fb1f0" --- # Einen Agent dorthin bringen, wo die Menschen schon sind { #putting-an-agent-where-people-already-are } Ein Agent, der nur in diesem Dashboard antwortet, ist eine Demo. Derselbe veröffentlichte Agent antwortet an acht Stellen, und jede einzelne davon führt *dieselbe eingefrorene Version* durch dasselbe Budget, dasselbe Approval-Gate und dieselben Mandantenprüfungen — die Oberfläche wechselt, der Agent nicht. | Wo | Was es braucht | Wer der Besucher ist | |---|---|---| | **Dashboard** | nichts | ein angemeldetes Mitglied | | **Website-Widget** | ein ` ``` Das ist die ganze Integration. Das Skript hat keine Abhängigkeiten, keinen Build-Schritt und kein Framework — es läuft auf einer Seite, die bereits React, jQuery oder gar nichts lädt. ### 3. (Optional) Erzählen Sie ihm vom Besucher { #3-optional-tell-it-about-the-visitor } Ein Widget kann **Variablen deklarieren** - einen Namen, ob sie erforderlich ist und eine Zeile, die sagt, wofür sie da ist - und die Seite liefert sie: ```html ``` Das Snippet, das der Builder Ihnen gibt, trägt diese Zeile bereits, mit Ihren eigenen Schlüsseln darin, sobald Sie welche deklariert haben. Sie werden den Instruktionen des Agents als markierter Datenblock angehängt, unter einer Zeile, die sagt, dass es sich um Informationen über den Besucher handelt und nicht um Anweisungen, und dass sie nicht überprüfbar sind. Dieser letzte Teil gilt für jede einzelne von ihnen, auch bei einem `jwt`-Widget: das Widget liest `window.AgenticOSContext`, und ein Token authentifiziert, *wer der Besucher ist*, und nicht, *was die Seite über ihn gesagt hat*. Nichts davon darf also entscheiden, was der Agent tun darf. Daraus folgen drei Regeln: - **Ein Schlüssel, den niemand deklariert hat, wird verworfen.** Die Seite ist etwas, das ein Besucher bearbeiten kann; ohne Deklaration würde jeder Schlüssel, den er sich ausdenkt, zu einer Zeile in den Instruktionen eines Agents. - **Ein fehlender Pflichtwert lässt seine Zeile weg und wird protokolliert.** `required` ist ein Versprechen zwischen einem Integrator und sich selbst - es durchzusetzen würde einen Besucher seine Antwort kosten, wegen des Deployment-Fehlers eines anderen. - **Einmal pro Unterhaltung gesendet**, vor der ersten Frage, und aus jedem Frame gelesen statt zur Verbindungszeit: eine Single-Page-Anwendung erfährt, wer jemand ist, ohne sich neu zu verbinden. ### 4. (Optional) Sagen Sie ihm, wer der Besucher ist { #4-optional-tell-it-who-the-visitor-is } Für ein Widget innerhalb Ihres eigenen angemeldeten Produkts setzen Sie ein Token, **bevor** das Skript lädt. Ihr Backend signiert es; wir überprüfen es und sehen Ihre Nutzerdatenbank nie: ```html ``` Eines zu prägen, in jeder Sprache, die ein JWT signieren kann: ```python import time, jwt # PyJWT token = jwt.encode( {"sub": str(user.id), "iat": int(time.time())}, EMBED_SIGNING_SECRET, # the secret you set on the embed algorithm="HS256", ) ``` - `sub` ist erforderlich. Es identifiziert den Besucher für das Rate Limiting, und ein Token ohne `sub` wird abgelehnt — sonst würde ein einziges durchgesickertes Token zum Budget des ganzen Widgets. - **`iat` ist erforderlich und muss innerhalb der letzten 12 Stunden liegen** — es wird nicht nur dann geprüft, wenn es vorhanden ist. Ein Token ohne `iat` oder mit einem veralteten wird abgelehnt, sodass eines, das aus einem Browser entweicht, nicht für immer funktionieren kann. Ein `exp`, das Sie setzen, wird ebenfalls beachtet, aber nur, um dieses Fenster zu **verkürzen** (ein abgelaufenes Token wird zurückgewiesen); es kann ein Token nicht über die Obergrenze von zwölf Stunden hinaus verlängern. - Prägen Sie es pro Seitenaufruf, serverseitig. !!! danger "Liefern Sie das Signing-Secret niemals an einen Browser aus" Es signiert *jeden* Besucher. Das Token, das es prägt, ist das, was der Browser halten darf, und nur für die zwölf Stunden, die `iat` ihm gibt. --- ## Das rohe WebSocket { #the-raw-websocket } Das Widget ist ein Client eines dokumentierten Protokolls, keine Blackbox. Wenn Sie Ihre eigene Oberfläche wollen — eine Mobile-App, einen Kiosk, eine Komponente in Ihrem Designsystem — sprechen Sie mit demselben Socket: ``` wss://your-api.example.com/api/v1/embed/PUBLIC_KEY/ws[?token=SIGNED_JWT] ``` **Sie müssen das nicht selbst zusammensetzen.** Veröffentlichen Sie eines aus dem Builder — der Agent → **Availability** → *Raw WebSocket* — und seine Zeile druckt die URL, gebaut aus der eigenen Basis-URL des Deployments, mit einer Kopieren-Schaltfläche. Ein **Widget** druckt dasselbe neben seinem Script-Tag, denn ein Widget ist ein Client dieses Protokolls: der Wechsel zu einer eigenen Oberfläche ist ein Schritt und keine Neuentwicklung. Das `?token=` wird dort nicht gedruckt: im `jwt`-Modus wird das Token pro Besucher von Ihrem Backend geprägt, und ein echtes auf einem Dashboard-Bildschirm ist ein funktionierendes Zugangsmittel, das jemand über die Schulter mitlesen kann. !!! bug "Das Erste, was schiefgeht: ein fehlender `Origin`" Der Handshake muss einen tragen, der auf der Allow-Liste des Embeds steht. **Ein Browser sendet ihn für Sie; ein eigener Client sendet nichts, solange Sie ihn nicht setzen** - eine Mobile-App, ein Kiosk, ein serverseitiges Relay. Wie es aussieht, wenn er es nicht tut, ist `4003` in der Tabelle unten, keine Fehlermeldung. ```mermaid sequenceDiagram participant C as Your client participant E as /embed/{key}/ws participant R as run_stream C->>E: handshake (Origin, optional ?token=) alt origin not allowed, token bad, embed paused E-->>C: close 4003 - do not retry else admitted E-->>C: ready { visitor } C->>E: message { text } E->>R: the same loop /chat drives R-->>C: model_request_start R-->>C: text_delta … (a word at a time) R-->>C: final_result, then complete end ``` **Frames, die Sie senden** ```json { "type": "message", "text": "Do you ship to Poland?" } ``` Das ist das ganze eingehende Vokabular, dazu `context` (was die Seite über den Besucher sagt) und `file_ids` (was er angehängt hat, auf einer Seite, die Dateien annimmt). Jeder `message`-Frame zählt zu einem Limit pro Verbindung, auch wenn er leer oder fehlerhaft ist. Gültige Nachrichten verbrauchen zusätzlich das bestehende Besucherlimit; fehlerhafte Eingaben können das gemeinsame Kontingent anderer anonymer Besucher nicht erschöpfen. Ein Frame darf höchstens drei Datei-IDs enthalten; längere Listen werden vor dem Parsen abgelehnt. Ungültige IDs in einer begrenzten Liste werden verworfen und einmal ohne ihre Werte protokolliert. **Es enthält bewusst nicht die drei Felder, die der Frame des Dashboards trägt:** den Agent, das Modellprofil und die Umgebung. Ein Frame, der ein Modell wählen könnte, ist ein Besucher, der eines auf die Rechnung des Betreibers wählt, und einer, der einen Agent wählen könnte, ist ein Besucher, der mit etwas spricht, das niemand auf diesem Schlüssel veröffentlicht hat. Alle drei kommen von der Embed-Zeile. Ein unbekanntes Feld wird **ignoriert statt abgelehnt**. Ein Client, der im Browser von jemandem zwischengespeichert ist, kann älter sein als dieser Server, und das Socket deswegen zu schließen würde die Unterhaltung mitnehmen. **Frames, die Sie empfangen** !!! note "Das ist das Frame-Vokabular des Dashboards selbst, kein zweites" `/chat` und dieses Socket treiben eine Schleife (`app/services/run_stream.py`), also trifft eine Antwort hier Wort für Wort ein, genauso wie dort. Eine gehostete Seite zeigte früher einen Textklumpen nach dreißig Sekunden ohne alles, und das war die Schleife und nicht der Transport. Jeder Frame trägt `{ "type": …, "data": { … } }`. | `type` | `data` | Bedeutung | |---|---|---| | `ready` | `visitor` | Verbunden. `visitor: true`, wenn ein Token die Person identifiziert hat. | | `history` | `messages` | Was in dem Thread gesagt wurde, den dieser Besucher wieder aufnimmt — **eine gehostete Seite mit anonymem Besucher**, die einzige Verbindung, die einen Kontinuitätsschlüssel trägt: das Gespräch eines Widgets lebt so lange wie sein Socket, und ein `jwt`-Besucher wird bereits von seinem Token benannt. Jeder Eintrag ist `role`, `text` und `at`, sodass eine wiedergegebene Runde die Zeit darunter behält. | | `model_request_start` | — | Der Agent ist zum Modell gegangen. Zeigen Sie einen Indikator. | | `part_start` | `index`, `part_type`; `tool_name`, `tool_call_id` bei einem `ToolCallPart` | Ein Block der Antwort beginnt. Ein Tool-Aufruf nennt sich hier schon, damit ein Client ihn zeigen kann, während seine Argumente streamen. Wird nur für einen Block gesendet, den diese Oberfläche auch wirklich trägt — eine Seite, die kein Reasoning zeigt, kündigt keinen `ThinkingPart` an, denn schon die Ankündigung sagt, dass der Agent nachgedacht hat. | | `text_delta` | `index`, `content` | Wörter der Antwort. Hängen Sie sie an. | | `thinking_delta` | `index`, `content` | Das Reasoning des Modells. **Nur, wenn der Betreiber es eingeschaltet hat.** | | `call_tools_start` | — | Der Agent ist im Begriff, Tools zu benutzen. | | `tool_call` | `tool_call_id`, `tool_name`, `args` | Ein Schritt. `args` nur, wenn der Betreiber Ergebnisse zeigt. | | `tool_call_delta` | `index`, `args_delta` | Die Argumente eines Aufrufs, während sie streamen. | | `tool_result` | `tool_call_id`, `content` | Was der Schritt zurückgegeben hat. | | `sources` | `names` | Die Dokumente, die eine Suche in der Wissensbasis geliefert hat, beim Namen. **Nur bei einem Widget, dessen Betreiber Quellen eingeschaltet hat**, und nie die Passagen. | | `final_result_start` | `tool_name` | Die Antwort wird von einem Output-Tool erzeugt. | | `final_result` | `output` | Womit der Run geendet hat. Leer bei einer Runde, die geparkt hat. | | `complete` | — | Die Runde ist vorbei. Sie trägt **keine Nutzungsdaten**: was ein Run gekostet hat, ist Sache des Betreibers, nicht des Besuchers. | | `error` | `message` | Etwas, das der Besucher sehen sollte: Rate Limit, Budget erreicht, eine Ablehnung, eine Runde, die nichts erzeugt hat. | | `compaction_started`, `compaction_finished`, `compaction_impossible` | je Frame | Der Agent ordnet seine eigenen Notizen, weil der Thread das Fenster des Modells gesprengt hat. Wird immer gesendet, unabhängig von den Schaltern des Betreibers: eine Zusammenfassung dauert Dutzende Sekunden, und eine Oberfläche, die streamt und nichts sagt, steht diese Zeit einfach still ([#936](https://github.com/vstorm-co/agenticos/issues/936)). | Manche Dashboard-Frames erreichen ein öffentliches Socket nie, und sie sind Ablehnungen und keine Einstellungen. **`user_prompt_processed`** trägt den Prompt *so, wie er zusammengesetzt wurde* — die Platzierungsnotiz und den gelieferten Block über dem, was der Besucher getippt hat —, und das ist der Text des Betreibers und nicht der des Besuchers zum Nachlesen. **`ask_user` und `tool_approval_required` haben hier niemanden, der sie beantwortet, aber sie scheitern unterschiedlich.** Ein Besucher kann keine Nebenwirkung in der Organisation eines anderen freigeben, also **parkt** `tool_approval_required` den Run genau so, wie es auf einem Kanal geschieht, und die Runde endet mit `error` und dem Hinweis, dass eine Person entscheiden muss — anders als auf einem Kanal ohne den `/runs`-Link, denn der Leser dort ist ein Mitglied, das ihn öffnen kann, und hier ist er ein Fremder mit einem Link. `ask_user` parkt **nicht**: `AgentDeps.ask_user` ist auf dieser Oberfläche `None`, also *verweigert* das Tool, wenn das Modell es aufruft (`app/agents/ask_user.py`), und das Modell antwortet ohne diese Eingabe weiter — eine verschlechterte Antwort, kein geparkter Run. **Ein Client ignoriert, was er nicht zeichnet**, und `widget.js` ist das durchgearbeitete Beispiel: es liest `model_request_start`, `text_delta`, `final_result`, `complete` und `error` für die Antwort, `tool_call` und `tool_result`, um jeden Schritt als Chip zu zeigen (*Durchsucht die Wissensbasis*), und `sources` für die Dokumente, die eine Antwort genannt hat. Das Reasoning ignoriert es mit Absicht. Es trägt das Gesicht des Agents, rendert die Antwort als Markdown in Knoten statt in HTML, folgt der hellen oder dunklen Einstellung des Besuchers und füllt auf dem Telefon den Bildschirm. **Close-Codes** | Code | Bedeutung | |---|---| | `4003` | Abgelehnt. Der Origin ist nicht erlaubt, das Token ist gescheitert, oder das Widget ist pausiert. Nicht erneut versuchen — die Antwort ändert sich nicht. | | `4029` | Zu viele Verbindungen von dieser Adresse in der letzten Minute. Zurückstecken und erneut versuchen. | | `1011` | Dieser Client hat nicht gelesen. Ein Frame brauchte länger als 30 Sekunden, um ihn zu erreichen, also hat der Server aufgehört zu schreiben, statt die Datenbank-Session der Runde und den offenen Provider-Stream pro Frame offen zu halten. Verbinden Sie sich neu; eine gehostete Seite nimmt ihren Thread wieder auf. | Die Ablehnung ist bewusst ein Code mit einer Meldung. Eine Seite, die nicht auf der Allow-Liste steht, erfährt, dass sie nicht erlaubt ist, und nichts darüber, ob ein Token geholfen hätte. `4029` ist aus dem entgegengesetzten Grund davon getrennt: „nicht erlaubt" und „erlaubt, aber zu schnell" verlangen von einem Client Gegenteiliges — für immer aufhören und es später noch einmal versuchen —, sodass ein Client, der sie nicht auseinanderhalten kann, entweder auf eine Ablehnung eindrischt oder ein Limit aufgibt. Wie viele Verbindungen eine Adresse bekommt, ist `RATE_LIMIT_EMBED_PER_MINUTE`; wie viele *Nachrichten* ein Besucher bekommt, sobald er verbunden ist, ist das eigene Rate Limit des Widgets, gesetzt im Builder. Ein minimaler Client: ```js const socket = new WebSocket(`${BASE}/api/v1/embed/${KEY}/ws`); let answer = ""; socket.onmessage = (event) => { const { type, data } = JSON.parse(event.data); if (type === "text_delta") render((answer += data.content)); if (type === "final_result" && data.output) render((answer = data.output)); if (type === "complete") answer = ""; if (type === "error") render(data.message); }; socket.send(JSON.stringify({ type: "message", text: "hello" })); ``` `final_result` wird zugewiesen statt angehängt: es ist das, womit der Run *geendet* hat, und bei einem Provider, der keine Deltas gestreamt hat, bleibt es die einzige Kopie der Antwort. --- ## Was jede Oberfläche bietet, und warum die Unterschiede Unterschiede sind { #what-each-surface-offers-and-why-the-differences-are-differences } Drei Oberflächen führen denselben Agenten über denselben Runner aus: der Chat des Dashboards, dieses Socket und die öffentliche API. Sie bieten **nicht** denselben Lauf, und bis [#936](https://github.com/vstorm-co/agenticos/issues/936) sagte nichts, welche der Unterschiede Entscheidungen waren. Jetzt schon. Jedes „nein“ unten hat einen Grund, und ein Grund ist entweder „das wäre hier falsch“ oder „das ist noch nicht gebaut“ — niemals Schweigen. Die letzte Spalte ist Teams (#1985), stellvertretend für die Chat-Plattformen daneben. | | `/chat` (Dashboard) | Rohes WebSocket | Die öffentliche API | Teams | |---|---|---|---|---| | Streaming | ja | ja, gefiltert nach dem, was der Betreiber zeigt | **nein** — der POST ist der nicht streamende Weg; eine SSE-Variante ist eine eigene Frage | eine Nachricht, die beim Eintreffen der Antwort umgeschrieben wird | | Anhänge | ja | ja (`file_ids`) **auf einer gehosteten Seite** — der Upload-Endpunkt löst den Schlüssel über `find_page` auf, ein Widget oder ein rohes Socket hat also keinen Weg, der eine id erzeugen würde | ja (`file_ids`), und sie laufen denselben Weg wie überall sonst | ja, aus einem persönlichen Chat; eine Datei geht über die Zustimmungskarte von Teams zurück | | Gesprächskontinuität | ja | ja (`continuity_key`), auf einer gehosteten Seite mit anonymem Besucher | ja (`conversation_id`) | ein Chat oder ein Channel-Thread | | `environment_id` | ja | **nein, bewusst** — siehe unten | ja | **nein** — die Bindung entscheidet, wie auf jeder Chat-Plattform | | Modell-Übersteuerung | ja | **nein, bewusst**: eine öffentliche Oberfläche darf ihren Besucher nicht wählen lassen, was er ausgibt | **nein**: die Anfrage trägt keine `model_profile_id`. Welche Version läuft, bleibt über `environment_id` die Entscheidung des Aufrufers, und eine Umgebung kann eine anheften, die nie die Standardversion war | **nein**, wie auf jeder Chat-Plattform | | `ask_user` | ja | **nein, noch nicht** — siehe unten | **nein, zu Recht**: niemand wartet an einem HTTP-Request, um eine Frage zu beantworten | ja, als Adaptive Card | | Verdichtungshinweis | ja | ja | entfällt — es streamt nichts, dem man es sagen könnte | nein — die Antwort kommt, wenn sie fertig ist | | Delegations-Frames | ja | **nein, bewusst**: ein angehängter Sink lässt die Bibliothek pro Kind einen *gestreamten* Request öffnen, also bricht ein Delegierter, dessen Anbieter nicht streamen kann, in dem Moment, in dem jemand zusieht | entfällt — nichts wird gestreamt, worin man sie zeichnen könnte | **nein** — die Antwort kommt, wenn sie fertig ist, ohne gezeichnete Schritte | | Freigaben: parken den Lauf | ja | ja | ja | ja | | Freigaben: ein Weg zur Entscheidung | ja (`/runs`) | ein Satz, bewusst: einem Fremden mit einem Link zeigt man keine URL in die Konsole eines anderen | ja — das Ergebnis trägt `parked` und nennt, worauf gewartet wird und an welche Freigabe zu senden ist | ja — eine Adaptive Card, entschieden als das Mitglied, das sie drückt | | `@`-Erwähnungen | ja (`mentions`) | **nein, noch nicht**: ein Widget hat kein Menü zur Auswahl, und die Erwähnung eines Besuchers bräuchte dieselbe Prüfung in seinem Namen | **nein, noch nicht**: ein Skript nennt in der Nachricht selbst, was zu nutzen ist | **nein**, wie auf jeder Chat-Plattform | ### `environment_id` am Socket, und warum es dem Veröffentlichenden gehört { #environment_id-on-the-socket-and-why-it-is-the-publishers } Die API hat es, weil der Aufrufer die Organisation ist: eine Dev-Umgebung vor der Beförderung durchzuspielen ist ihre eigene Arbeit an ihrem eigenen Agenten. Der Aufrufer eines Sockets ist, wer den Embed-Schlüssel hält, und bei einem Widget ist das der Browser eines Fremden. Ein Frame, das die Umgebung wählt, ließe den Besucher entscheiden, welche *Version* des Agenten ihm antwortet — auch eine, die die Organisation nicht befördert hat, was das Gegenteil dessen ist, was das Veröffentlichen einer Version bedeutet. In dieser Form wird es also abgelehnt. Braucht eine First-Party-Integration eine benannte Umgebung, gehört sie in die Konfiguration des Embeds selbst, wo der Veröffentlichende einmal wählt und jeder Besucher dieselbe Antwort bekommt. ### `ask_user` am Socket, und was es bräuchte { #ask_user-on-the-socket-and-what-it-would-need } Der Modul-Docstring von `ask_user` sagt, Pause und Fortsetzung lebten „in der WebSocket-Sitzung“, und meint die Sitzung des *Chats*. Dieses Socket ist genauso lebendig, mit einem Menschen, der genauso davor sitzt — also bekommt ein Delegierter, der fragen würde, gesagt, es sei niemand erreichbar, was für einen geplanten Lauf die richtige und hier die falsche Antwort ist. Geschlossen wird es hier nicht, weil die fehlende Hälfte nicht die des Servers ist. Das Frame-Vokabular kennt weder `ask_user` noch `ask_user_response`, und das Widget hat kein Formular, um eines darzustellen — den Callback anzuhängen würde Läufe an einer Frage parken, die nichts beantworten kann, und das ist schlimmer als eine Ablehnung. Was es braucht: die zwei Frames, das Formular im Widget und eine Entscheidung über eine *gehostete Seite*, wo die gefragte Person eine Fremde ist und die Frage alles tragen kann, was der Agent zu sagen beschlossen hat. ### Freigaben am Socket: entschieden und bewusst nicht ausgeweitet { #approvals-on-the-socket-decided-and-deliberately-not-widened } Ein geparkter Lauf antwortet einem Besucher mit „Das muss jemand freigeben, bevor es laufen kann“, und das bleibt so. Das rohe Socket wird als Schnittstelle verkauft, die man selbst baut — eine mobile App, ein Kiosk — und einem First-Party-Client mit angemeldetem Nutzer könnte man den geparkten Aufruf und einen Weg zur Entscheidung im Prinzip geben. Er bekommt ihn nicht, weil der Embed-Schlüssel kein *Mitglied* ausweist: er weist die veröffentlichte Oberfläche eines Deployments aus, und am anderen Ende kann jeder sitzen, den die Integration hereingelassen hat. Eine Freigabe zu entscheiden ist die Handlung eines Mitglieds, über `approvals:decide` abgesichert, und gehört auf eine Oberfläche, die weiß, wer fragt. ## Eine gehostete Seite { #a-hosted-page } Die kürzeste Integration, die es gibt: **schicken Sie jemandem einen Link.** Keine eigene Website, kein `