---
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 `