docs/channels.de.md
Rama main · réplica de lectura del repositorio original.
---
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 `<script>`-Tag | anonym, oder ein Nutzer, für den Ihr Backend bürgt |
| **Rohes WebSocket** | ein Embed-Schlüssel | was immer Ihre Integration sagt |
| **Eine gehostete Seite** | ein Link | anonym, und sonst nichts |
| **Die API** | eine Session | wer die Zugangsdaten hält |
| **Slack** | ein Bot-Token | ein Slack-Konto, optional mit einem Mitglied verknüpft |
| **Telegram** | ein Bot-Token | ein Telegram-Konto, optional verknüpft |
| **Mattermost** | ein Bot-Token und die URL Ihres Servers | ein Mattermost-Konto, optional verknüpft |
Das Dashboard öffnet sich auch als [Desktop-App](desktop.md): ein Fenster um
dieselbe Konsole, geladen vom selben Server, ohne irgendetwas Mitgeliefertes.
!!! abstract "Drei Regeln gelten auf jeder Oberfläche, durchgesetzt im Runner"
- **Ein Run gehört immer zu genau einer Organisation.**
- **Ein Ausgabenlimit wird vor jeder Modellanfrage geprüft, nie danach.**
- **Ein fehlgeschlagener Run steht mit dem, was er ausgegeben hat, weiterhin
in der Historie** — die Token waren ausgegeben, bevor er abbrach, und ein
Budget, das das ignoriert, ist kein Budget.
!!! info "Drei dieser acht sind eine Tabelle, deren Zeilen sich durch ein `kind` unterscheiden"
Ein Widget, ein rohes Socket und eine gehostete Seite sind jeweils ein
*Embed* — und ein `kind` steht mit dem Anlegen fest, denn ein bereits
eingefügtes Tag, ein bereits geschriebener Client und ein bereits
verschickter Link benennen alle dieselbe Zeile.
Ein öffentlicher Schlüssel, ein Rate-Bucket, ein Budget, ein Pause-Schalter und
ein Satz von Ablehnungen. Was sich unterscheidet, ist das, was es zu
konfigurieren gibt, und das, was einen Besucher einlässt — weshalb der Builder
zuerst fragt, welches davon Sie wollen, bevor er irgendetwas anderes fragt, und
weshalb eine Seite keine Liste erlaubter Origins hat statt einer ignorierten.
Jeder Run hält fest, welche Oberfläche ihn eingelassen hat — `web`, `embed`,
`api`, `slack`, `telegram` oder `mattermost` —, und genau das aggregiert das
Diagramm nach Oberfläche im Dashboard. Alle drei Embed-Arten halten `embed` fest.
Zwei historische Falten: Widget-Runs, die aufgezeichnet wurden, bevor es den Wert
`embed` gab, sind als `web` gespeichert, und Mattermost-Runs aus derselben Zeit
als `api`. Keines von beiden wird nachgetragen — Historie umzuschreiben wäre
geraten —, also falten Diagramme über alte Zeiträume diese Runs in die
Oberfläche, unter der sie aufgezeichnet wurden.
**Was ein Fremder darf, darf er in einem bestimmten Takt.**
Die ohne Session erreichbaren Oberflächen tragen ein Limit, das im Redis des
Deployments gezählt wird, sodass es über Worker hinweg gilt:
- die Run-API, **pro Aufrufer**;
- das Skript des Widgets und der Einlass, den es einleitet, **pro Adresse**, auf
je einem eigenen Zähler;
- die Konfiguration einer gehosteten Seite, **pro Seite** — die wird vom
Frontend-Server geholt statt vom Browser, sodass eine Adresse dort einen
Container benennt und jeden Besucher des Deployments in einen Bucket stecken
würde.
Das Skript wird getrennt von dem Einlass gezählt, dem es vorausgeht, denn ein
Seitenaufruf verbraucht beides, und ein gemeinsamer Bucket für beides hat die
Zahl, die ein Betreiber setzt, auf ein Drittel ihrer selbst gebracht.
`RATE_LIMIT_RUN_PER_MINUTE`, `RATE_LIMIT_EMBED_PER_MINUTE` und
`RATE_LIMIT_HOSTED_PAGE_PER_MINUTE` setzen sie, und
[Konfiguration](configuration.md#rate-limiting) enthält den einen Vorbehalt, der
sich vor dem Produktivbetrieb zu lesen lohnt — hinter einem Proxy kommt jeder
Besucher als der Proxy an, solange Sie nichts anderes sagen.
Was die *Ausgaben* auf einer gehosteten Seite rationiert, ist das Socket, das die
Seite öffnet, und das wird wie das des Widgets pro Adresse gezählt.
---
!!! tip "Geht es ums Integrieren statt ums Konfigurieren?"
[Die HTTP-API](api.md) behandelt die Authentifizierung, den
Organisations-Header, das Ausführen eines Agents über HTTP, die beiden
WebSocket-Endpunkte und den Fehler-Envelope.
## Das Website-Widget { #the-website-widget }
Der kürzeste Weg. Veröffentlichen Sie den Agent, legen Sie ein Embed an, fügen
Sie zwei Zeilen ein.
### 1. Das Embed anlegen { #1-create-the-embed }
Öffnen Sie im Builder den Agent → **Availability** → *Website widget*. Sie
wählen:
- **Allowed sites** — die Seiten, von denen aus dieses Widget geöffnet werden
darf. **Eine leere Liste erlaubt nichts**, deshalb wird ein Veröffentlichen
ohne eine solche Liste abgelehnt, statt ein Widget zu erzeugen, das nirgends
antwortet. Der Schlüssel im Script-Tag ist von Natur aus öffentlich, also ist
die Origin-Liste das, was tatsächlich verhindert, dass jemand anderes Ihren
Agent auf Ihre Rechnung laufen lässt. Dieselbe Regel gilt für ein Socket,
dessen Handshake gegen dieselbe Liste geprüft wird.
- **Auth** — `public` (anonyme Besucher) oder `jwt` (Ihr Backend bürgt für jeden
Besucher; siehe unten).
- **Look** — die Kopfzeile und die Zeile darunter, die Begrüßung, was im leeren
Eingabefeld steht, was die Startschaltfläche sagt, die Akzentfarbe und in
welcher Ecke es sitzt. Alle sieben, und die Begrüßung wird vom Widget
gezeichnet statt an das Modell geschickt: eine Begrüßung in der Historie des
Modells ist eine Runde, von der der Agent glaubt, er habe sie gemacht.
- **Vorgeschlagene Nachrichten** — bis zu vier erste Nachrichten als Schaltflächen
unter der Begrüßung; ein Klick sendet eine als Nachricht des Besuchers.
- **Quellen** — standardmäßig aus. Eingeschaltet nennt eine Antwort, die sich auf
die Wissensbasis stützt, diese Dokumente darunter: nur die Dateinamen, nie die
Passagen, in einem eigenen `sources`-Frame.
- **Context** — eine Notiz, die an die erste Nachricht des Besuchers angehängt
wird: *„Sie sind auf der Preisseite"*, *„Antworte auf Deutsch"*. Sie ersetzt
nie die eigenen Instruktionen des Agents, die zur veröffentlichten Version
gehören.
- **Rate limit** — Nachrichten pro Besucher pro Minute.
- **Voice input** — `off`, `browser` oder `server`; das entscheidet, wohin die
Stimme eines Besuchers geht. Siehe
[Diktat im Widget oder auf einer gehosteten Seite](#dictation-on-a-widget-or-a-hosted-page).
!!! danger "Eine leere Origin-Liste erlaubt nichts, und das ist Absicht"
Der Schlüssel im Script-Tag ist von Natur aus öffentlich, also ist die
Origin-Liste das Einzige, was jemand anderen davon abhält, Ihren Agent auf
Ihre Rechnung laufen zu lassen. Ein Veröffentlichen ohne eine solche Liste
wird abgelehnt, statt ein Widget zu erzeugen, das nirgends antwortet.
### 2. Das Snippet einfügen { #2-paste-the-snippet }
```html
<script src="https://your-api.example.com/api/v1/embed/PUBLIC_KEY/widget.js" async></script>
```
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
<script>window.AgenticOSContext = { plan: "pro", locale: "pl" };</script>
<script src="https://your-api.example.com/api/v1/embed/PUBLIC_KEY/widget.js" async></script>
```
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
<script>window.AgenticOSToken = "<%= agenticos_token_for(current_user) %>";</script>
<script src="https://your-api.example.com/api/v1/embed/PUBLIC_KEY/widget.js" async></script>
```
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 `<script>`-Tag, kein Client zu schreiben, keine
Anmeldung.
Öffnen Sie im Builder den Agent → **Availability** → *Hosted page*. Es gibt keine
Website zu benennen und nichts einzufügen — das Formular fragt nach einem Titel,
einer Begrüßung, einer Akzentfarbe und einem Logo, alles optional, und
veröffentlicht:
```
https://your-app.example.com/e/PUBLIC_KEY
```
Sie ist **ein Embed wie die anderen beiden**: dieselbe Art Schlüssel, dasselbe
Rate Limit, dasselbe Budget und derselbe Pause-Schalter. Sie zu pausieren stoppt
die Seite sofort, und jeden Link, der mit ihr schon verschickt wurde.
### Was sie schützt { #what-protects-it }
Sprechen Sie diesen Teil laut aus, bevor Sie eine veröffentlichen, denn er ist
das ganze Sicherheitsmodell:
> **Ein gehosteter Link im `public`-Modus ist dadurch geschützt, dass der
> Schlüssel nicht erratbar ist, dazu durch das Rate Limit des Embeds, sein Budget
> und seinen Pause-Schalter. Sonst nichts.**
Wer den Link hat, kann mit dem Agent sprechen. Das ist der Sinn eines Links, und
deshalb ist der Schlüssel 24 zufällige Bytes und nicht etwas Lesbares.
Es gibt hier bewusst keine Liste erlaubter Origins, und das Formular bietet auch
keine an: eine Allow-Liste ist eine Regel über die Websites *anderer Leute*, und
diese Seite ist eine, die wir ausliefern. Eine Seite wird vom eigenen Origin des
Deployments eingelassen — abgeleitet aus `FRONTEND_URL`, nie fest verdrahtet —
und sonst nirgendwo. Ein `CHECK`-Constraint lehnt eine Seite ab, die überhaupt
eine Liste trägt, denn eine gespeicherte liest sich wie das, was den Link
schützt, und das ist sie nicht.
### Zwei Dinge, die eine gehostete Seite ablehnt { #two-things-a-hosted-page-refuses }
Beide werden beim Veröffentlichen abgelehnt, mit einer Meldung, statt still auf
ein Widget zurückzufallen:
- **Eine Seite kann den `jwt`-Modus nicht verwenden**, und das Formular bietet
ihn nicht an. Das Token müsste in der URL reisen und damit in den
Browserverlauf, in `Referer`-Header und in jeden Chat-Client, in den der Link
eingefügt wird — und der Fragment-Trick, der einen Teil davon vermeidet, nimmt
dem Link das „verschicken und es funktioniert". Verwenden Sie für eine
nutzerbezogene Integration ein Widget oder ein Socket; `jwt` ist dort
unberührt. Ein `CHECK`-Constraint hält dieselbe Regel in der Datenbank.
- **Eine *erforderliche* Variable, die nicht URL-sicher ist, kann nicht auf einer
Seite sein** — siehe unten.
### Variablen aus der Adresszeile { #variables-from-the-address-bar }
Eine gehostete Seite hat keine Seite von Ihnen, aus der sie
`window.AgenticOSContext` lesen könnte. Ihre einzige Quelle für eine deklarierte
Variable ist die URL des Besuchers selbst:
```
https://your-app.example.com/e/PUBLIC_KEY?var_plan=pro
```
**Ein Query-Parameter ist vom Besucher kontrollierte Eingabe**, deshalb ist das
pro Variable ausgeschaltet und nur dort an, wo jemand es entschieden hat: haken
Sie im Builder *URL-safe* an der Variablen an. Ohne das wird ein in die
Adresszeile getipptes `?var_user_tier=premium` verworfen — was der Sinn der Sache
ist. Alles, was gar nicht deklariert ist, wird verworfen wie beim Widget.
Deshalb muss eine *erforderliche* Variable auch markiert werden: auf dieser
Oberfläche ist die URL der einzige Weg, eine zu liefern, sodass eine Variable,
die erforderlich und nicht URL-sicher ist, ein Versprechen ist, das die Seite
strukturell nicht halten kann.
### Wieder zurückkommen { #coming-back-to-it }
Die Unterhaltung eines Widgets dauert so lange wie sein Socket. Ein Link als
Lesezeichen ist ein stärkeres Versprechen, deshalb hält die Seite einen
zufälligen Besucherschlüssel in `localStorage` — einen pro öffentlichem
Schlüssel — und der Server bildet ihn auf eine Unterhaltung ab. Den Link erneut
zu öffnen gibt den Thread wieder, und der Agent wird an dasselbe Fenster
erinnert, das der Besucher liest.
**Der Schlüssel ist ein Inhaber-Zugangsmittel für diese Unterhaltung**: wer ihn
hält, nimmt den Thread wieder auf, einschließlich dessen, was schon darin steht.
Er besteht aus 128 zufälligen Bits und nichts über die Person. Die Website-Daten
zu löschen beginnt einen neuen Thread.
Dieser Schlüssel ist alles, was die Seite speichert, und deshalb zeigen **eine
gehostete Seite und eine geteilte Unterhaltung keinen Cookie-Hinweis** — die
beiden Oberflächen, die jemandem ausgeliefert werden, der kein Mitglied ist, sind
die beiden ohne optionales Cookie, in das einzuwilligen wäre. Das Banner des
Produkts erschien hier früher und bat um Erlaubnis für Analytics, die dieses
Deployment nicht betreibt, während es über dem Eingabefeld saß und Send verdeckte
(#644). Eine Einwilligungsabfrage für einen einzigen notwendigen Schlüssel ist
eine Abfrage, deren einzige Wirkung die Überdeckung ist.
Diese Form wird durchgesetzt, nicht angenommen — das Socket akzeptiert 32 bis 64
kleingeschriebene Hex-Zeichen als `visitor` und **verwirft alles andere**, wobei
es stattdessen einen frischen Thread öffnet. Das ist für einen eigenen Client
(unten) wichtig: Kontinuität an einer Kunden-ID, einer E-Mail-Adresse oder einem
Zähler festzumachen würde jedem Ihrer Nutzer eine Unterhaltung geben, in die der
Nächste durch Raten hineinspazieren kann. Ein verworfener Schlüssel kostet
Kontinuität und nie die Unterhaltung, sodass ein veralteter Wert im Browser von
jemandem keine Seite ist, die nicht lädt.
### Was sie anbietet { #what-it-offers }
Drei Einstellungen, und jede gehört dem Betreiber und nicht der Seite — eine
Capability, die eine Seite für sich selbst einschaltet, wäre eine, die niemand
ausschalten könnte.
- **Eine Schaltfläche, um einen frischen Thread zu beginnen**, standardmäßig an.
Sie prägt einen neuen Kontinuitätsschlüssel, sodass der alte Thread nicht
gelöscht wird: er ist nur nicht mehr der, den dieser Browser wieder aufnimmt.
- **Spracheingabe**, standardmäßig aus: `browser` diktiert über den eigenen
Browser des Besuchers, `server` über den Speech-to-Text-Provider der
Organisation. Welche von beiden es ist, entscheidet, wohin die Stimme eines
Besuchers geht - siehe
[Diktat im Widget oder auf einer gehosteten Seite](#dictation-on-a-widget-or-a-hosted-page).
Eine Seite, die veröffentlicht wurde, bevor es diese Wahl gab, behält, was sie
hatte: ihr altes *Mikrofon im Eingabefeld* wurde zu `browser`, und kein
Mikrofon wurde zu `off`.
- **Eine Möglichkeit, eine Datei anzuhängen**, standardmäßig aus. Siehe unten: es
ist das Einzige auf dieser Oberfläche, das einen Fremden etwas *speichern*
lässt.
### Wohin ein Fremder mit dem Link schreiben kann { #what-a-stranger-holding-the-link-can-write-to }
Alles andere auf einer öffentlichen Oberfläche liest. Diese hier schreibt, also
lohnt es sich, genau zu sagen, was ein Besucher wohin legen kann.
**Er kann eine Datei speichern**, und nur, wenn der Betreiber den Schalter
gesetzt hat. Die Bytes gehen denselben Weg wie der Upload eines Mitglieds — die
MIME-Allow-Liste, `CHAT_MAX_UPLOAD_SIZE_MB` (standardmäßig 10 MB — die eigene
Obergrenze der Chat-Oberfläche, nicht das größere `MAX_UPLOAD_SIZE_MB` der
Knowledge Base), der Parser, das Storage-Backend, eine `ChatFile`-Zeile — mit
drei Verengungen davor:
| | |
|---|---|
| **Eine eigene Obergrenze dieser Oberfläche** | `EMBED_MAX_UPLOAD_SIZE_MB`, standardmäßig 5 MB. Ein Mitglied, das einen fünfzig Megabyte großen Export hochlädt, ist jemand, den die Organisation beschäftigt; dieselbe Erlaubnis auf einem öffentlichen Link ist ein Weg, eine Festplatte von einer Adresse aus zu füllen, die niemand kennt. Es ist eine Obergrenze *zusätzlich zu* `CHAT_MAX_UPLOAD_SIZE_MB`, nie ein Weg daran vorbei |
| **Ein Limit pro Adresse und pro Besucher** | `RATE_LIMIT_EMBED_UPLOAD_PER_MINUTE`, im gemeinsamen Redis, und **beide** müssen es erlauben. Nur den Kontinuitätsschlüssel zu zählen begrenzt nichts: der Browser prägt ihn, und beliebige 32 Hex-Zeichen sind ein gültiger, sodass ein Skript ihn pro Datei variiert. Nur die Adresse zu zählen lässt einen Browser auf einer geteilten Adresse das Kontingent aller verbrauchen |
| **Drei Dateien pro Nachricht** | Was begrenzt, wie viel vom Prompt einer Runde das Dokument eines anderen ist |
Diese drei begrenzen, was *gespeichert* wird. Was begrenzt, was ein Fremder
dieses Deployment *empfangen* lassen kann, liegt eine Schicht über allen dreien,
denn der Multipart-Body wird geparst, bevor die Route läuft: eine Anfrage, die
mehr deklariert als die Obergrenze für die ganze Anfrage, wird mit 413
beantwortet, ohne gelesen zu werden. Siehe
[Konfiguration](configuration.md#the-size-of-a-request-as-opposed-to-the-size-of-a-file),
einschließlich dessen, was sie nicht abdeckt.
**Die Zeile gehört dem Mitglied, das die Seite veröffentlicht hat**, denn
`chat_files.user_id` ist `NOT NULL` und ein Besucher hat kein Konto — dieselbe
Antwort, die schon darauf gegeben wurde, als wer eine öffentliche Runde *läuft*.
Eine Seite, deren Veröffentlichender kein Konto mehr hat, kann deshalb überhaupt
keine Dateien annehmen und sagt „nicht verfügbar", statt sie gegen niemanden zu
speichern.
**Wohin die Datei dann geht, ist die Entscheidung des Runners, nicht die dieser
Oberfläche.** Es ist das Routing aus [Dateiverarbeitung](file-processing.md),
unverändert: in den Workspace des Agents, wo er einen hat, in den Prompt gefaltet,
wo er keinen hat, und ein Bild auf beiden Wegen bis zur Inline-Obergrenze. Nichts
an der Datei eines Besuchers ist ein Sonderfall.
Ein Frame darf nur eine Datei benennen, die dem Eigentümer dieser Seite gehört
und nicht schon an einer Nachricht hängt, sodass eine ID nicht in eine zweite
Runde oder in den Thread eines anderen eingespielt werden kann. Das ist
verhältnismäßig statt vollständig, und was es ausreichend macht, ist die ID
selbst: `uuid4` sind 122 zufällige Bits, sodass eine ID von einem anderen Besucher
ein Wert ist, den niemand erzeugen kann, ohne ihn bekommen zu haben.
**Sonst können sie nichts schreiben.** Keine Knowledge Base, keinen eigenen Pfad
im Workspace, keine Konfiguration, keine Variable, die nicht deklariert und
URL-sicher ist. Die Zeile der Unterhaltung und die Runden darin werden *über* sie
geschrieben, von der Plattform.
### Was der Besucher von der Arbeit sieht { #what-the-visitor-sees-of-the-work }
Drei weitere Schalter, und sie sind **Filter darauf, was der Server sendet**, und
nicht darauf, was die Seite zeichnet. Diese Unterscheidung ist das ganze Design:
in CSS verstecktes Reasoning ist das Reasoning eines Agents in den Devtools eines
Fremden, und eine Seite ist genau der Ort, wo ein Fremder welche offen hat. Ein
Besucher, der seine öffnet, sieht, was hier angehakt ist, und nichts sonst.
| Schalter | Standard | Was er durchlässt |
|---|---|---|
| **Was der Agent gerade tut** | an | Eine Zeile pro Schritt — *Searching the documents*, *Ran a query*. An, weil eine Seite, die dreißig Sekunden still wird, sich wie kaputt liest |
| **Was jeder Schritt zurückgegeben hat** | aus | Die Argumente, mit denen ein Schritt aufgerufen wurde, und was zurückkam. Für das Modell geschrieben, also taucht hier etwas Internes auf: eine Adresse, eine Zeile aus einem System, eine Passage, die niemand veröffentlichen wollte |
| **Das Reasoning des Agents** | aus | Was das Modell zu sich selbst sagt, bevor es antwortet. Nicht dafür geschrieben, dass es jemand liest, und keine Antwort, hinter die sich ein Betreiber stellen kann |
*Was jeder Schritt zurückgegeben hat* kann nicht allein eingeschaltet werden — es
gäbe keinen Schritt, der sich dafür öffnen könnte, und der Server verwirft beides,
egal was die Konfiguration sagt.
**Eine Runde sieht aus wie eine Runde im Web-Chat**, bis hin zum Rahmen darum: der
Name des Agents über der Antwort, der Avatar am Rand — das Logo der Seite, wo es
eines gibt, die Initiale des Agents, wo es keines gibt —, die Zeit unter jeder
Runde auf der Seite, auf der sie steht, und eine Eingabekarte mit dem Feld und
seinen Bedienelementen darin.
Drei Dinge, die der Web-Chat dort zeichnet, fehlen bewusst, und alle drei sind
dieselbe Entscheidung wie die Panels weiter unten: was die Runde gekostet hat, was
der Monat gekostet hat, und welcher Agent und welches Modell laufen sollen.
Das letzte davon, weil ein Frame, der ein Modell auswählen könnte, ein Besucher
ist, der eines auf die Rechnung des Betreibers auswählt.
**Eine Runde wird von den eigenen Komponenten des Web-Chats gerendert**, nicht von
einem zweiten Satz, der so aussieht wie sie.
`TurnParts` ist das, was das Dashboard rendert, und das, was die Seite rendert.
Also ist das Reasoning dieselbe Offenlegung, die Antwort dasselbe Markdown und
eine Folge von Tool-Aufrufen dieselbe Leiste — das Icon aus
`src/lib/tool-catalog.ts`, die Formulierung aus `toolStep`, und dieselben
Renderer, die sich unter einem Schritt öffnen für eine Wissenssuche, eine
Websuche, ein Diagramm, ausgeführten Code, einen geladenen Skill, eine
geschriebene Datei.
Es gibt bewusst keine zweite Tabelle von Tool-Namen und keinen zweiten
Runden-Renderer (#144). Was die Seite *nicht* zeichnet, ist alles, was damit zu
tun hat, Mitglied zu sein — siehe unten.
Eines liest sich notwendigerweise anders: ein Aufruf, der von einem MCP-Server
kam, heißt im Dashboard *Linear · Create issue* und hier mit einem vermenschlichten
Namen, denn die Zuordnung ist die Liste der Verbindungen der Organisation, und
sie zu lesen braucht eine Session.
**Ein generiertes Bild, gesprochene Sprache oder ein Video spielt auch auf der
Seite** (#2309). Die Konsole liest eines über `/api/v1/generated/{filename}`, und
das braucht eine Session, die ein Besucher nicht hat. Deshalb fügt das Socket
einem Ergebnis mit einem generierten Bild, gesprochener Sprache oder einem Video,
das eine von den eigenen Tools dieses Runs gespeicherte Datei nennt, beim Senden
ein `token` hinzu, und die Seite liest die Datei über
`/api/embed/<key>/generated/<file>?token=…`, das sie streamt und Byte-Bereiche für
einen Player durchreicht.
Das Token ist ein HMAC, unter dem `SECRET_KEY` des Deployments, über den Schlüssel
der Seite, genau diese eine Datei und ein Ablaufdatum sechs Stunden voraus. Es
öffnet nichts anderes: keine Datei einer anderen Unterhaltung, keine einer anderen
Organisation, keinen anderen Namen. Ein falsches, abgelaufenes oder fehlendes Token
bekommt dasselbe 404 wie eine fehlende Datei. Es wird hinzugefügt, wenn der Frame
hinausgeht, also speichert das Transkript keines, und die Karte ist das Ergebnis
dieses Schritts — sie erscheint nur, wo *Was jeder Schritt zurückgegeben hat* an
ist. Eine Socket-Integration findet dieselbe Adresse im `url` des Payloads, als
API-Pfad.
Ein Ergebnis, das eine Datei nennt, die dieser Run nicht gespeichert hat,
bekommt kein Token, wie auch immer das Tool heißt, das es zurückgab. Ein Link darf
120-mal pro Minute abgerufen werden, genug für die Byte-Bereiche eines Players;
darüber hinaus wird mit 429 geantwortet.
**Ein Widget und ein rohes Socket tragen keine Schalter und bekommen diese
Standardwerte**, abgelesen von `PageConfig` statt wiederholt — eine zweite Kopie
von „standardmäßig aus" ist eine Kopie, die der widersprechen kann, die jemand im
Builder liest. Was `widget.js` dann *zeichnet*, ist noch enger, und das steht
oben.
Keines von beiden nimmt Dateien an, und das liegt an der Route und nicht am
Client: der Upload-Endpunkt löst den Schlüssel über `find_page` auf, sodass ein
Widget-Schlüssel ihn erreicht und „nicht verfügbar" beantwortet bekommt. Ein
Widget lebt auf einer Seite, die der Betreiber ohnehin kontrolliert, und dorthin
gehört eine eigene Dateiauswahl.
### Was bewusst nur für Mitglieder ist { #what-is-deliberately-member-only }
Der Web-Chat zeichnet drei Panels, die eine öffentliche Oberfläche nicht zeichnet,
und jede Auslassung ist eine Entscheidung und keine Lücke — hier festgehalten,
damit sie nicht noch einmal als eine verhandelt wird.
| Panel | Auf einer öffentlichen Oberfläche | Warum |
|---|---|---|
| **Die Nutzungsleiste** | Nein, und es ist kein Schalter | Sie berichtet die Token der Runde, ihre Kosten, den Monat gegen das Cap der Organisation und wie voll der Workspace ist. Ein Besucher ist nicht derjenige, der zahlt, und das verbleibende Budget des Betreibers ist eine Tatsache über den Betreiber. `complete` trägt überhaupt keine Nutzungsdaten, also gibt es clientseitig nichts zu verbergen |
| **Das Datei-Panel** | Nein | Es listet alles im *Workspace* des Agents auf, der über die Unterhaltungen aller geteilt wird, die diesen Agent nutzen. Einem Fremden, der eine Datei angehängt hat, würde jede Datei gezeigt, die der Agent je bekommen hat. Sein eigener Anhang steht an seiner eigenen Runde, und das ist es, was ihm zusteht |
| **Das Delegations-Panel** | Nein | Es benennt die Delegierten per Slug, was jeder gefragt wurde und was jeder gekostet hat — die Form des Agent-Graphen der Organisation. Eine Seite, die das zeigte, würde ein internes Organigramm an jeden veröffentlichen, der den Link hat. Eine Delegation *läuft* trotzdem: sie ist ein `tool_call`-Schritt namens `task`, unter demselben Schalter wie jeder andere Schritt |
Das Muster hinter allen dreien: was ein Mitglied sieht, ist *über die
Organisation*, und was ein Besucher sieht, ist *über seine eigene Runde*. Ein
Panel, das diese Linie überschreitet, ist nur für Mitglieder, was auch immer es
kosten würde, es zu rendern.
### Wie sie aussieht { #what-it-looks-like }
Die Antwort wird als **Markdown** gerendert, genau wie im Web-Chat: ein Agent, dem
gesagt wurde, in Markdown zu antworten, antwortet darin, ob die Seite die
Sternchen nun neu interpretiert oder nicht. Was ein *Besucher* getippt hat, wird
nicht neu interpretiert — es ist kein Dokument.
Vier Felder, alle optional:
| Feld | Standard |
|---|---|
| **Page title** | der Name des Agents |
| **Welcome message** | keine. **Markdown**, geschrieben im selben Editor wie die Platzierungsnotiz und auf der Seite als Markdown gerendert. Wird vor der ersten Frage gezeigt und nie an das Modell geschickt — eine Begrüßung in der Historie des Modells ist eine Runde, von der der Agent glaubt, er habe sie gemacht |
| **Accent colour** | `#4f46e5`. Hell und Dunkel folgen weiterhin dem System des Besuchers |
| **Logo** | der Avatar des Agents; oder der der Organisation, einer, den Sie hochladen, oder keiner. Was auch immer Sie wählen, die Seite zeigt **nichts** statt eines kaputten Bildes, wenn keine Datei dahinter liegt — ein Agent ohne Avatar ist der Normalfall, und ein Browser kann einen 404 nicht von einem langsamen Bild unterscheiden |
Drei dieser vier sind Bilder, die diese Plattform ohnehin hält. Das vierte nimmt
eine Datei — PNG, JPEG, WebP oder GIF, bis 2 MB — und es kann erst hinzugefügt
werden, wenn die Seite existiert, denn ein Upload braucht eine Zeile, an die er
sich hängen kann.
Die Seite holt es von **ihrem eigenen Origin**, nicht von der API: `img-src` in
`next.config.ts` schließt eine API auf reinem `http` aus, sodass eine Seite, die
ein `<img>` auf eine solche richtete, in der Entwicklung und auf jedem Deployment,
das TLS anderswo terminiert, ein kaputtes Zeichen rendert.
`/api/embed/<key>/logo` im Frontend leitet es weiter.
**Was sie nicht annimmt, ist eine eigene URL von Ihnen.** Eine Seite, die wir
ausliefern und die ein vom Betreiber geliefertes Bild holt, ist eine Sache mehr,
die abgesichert werden muss. Und der gespeicherte Pfad ist eine *Spalte*,
geschrieben von der Upload-Route und nie von der Konfiguration, die Sie
absenden: der Pfad wird von einer öffentlichen Route zurückgelesen und gestreamt,
sodass einer, der aus einem Request-Body übernommen würde, ein Aufrufer wäre, der
jede Datei benennt, die der Prozess öffnen kann.
**Sie nimmt auch nicht Ihren Dateinamen oder Ihr Wort dafür, was die Datei ist.**
Weil die Seite das Logo von ihrem eigenen Origin holt, ist der Typ, den diese
Antwort trägt, ein Typ, dem der Browser auf diesem Origin vertraut — und
`script-src` erlaubt dort Inline-Skripte.
Ein Upload wird auf den `Content-Type` hin angenommen, den sein Client
*deklariert* hat, und der ist kein Beleg über die Bytes. Deshalb wird der Name auf
der Festplatte stattdessen aus dem Typ geprägt (`logo.png`, `logo.jpg`,
`logo.webp`, `logo.gif`), und sowohl die API-Route als auch der Frontend-Proxy
weigern sich, mit irgendetwas zu antworten, das nicht einer dieser vier Bildtypen
ist.
Ein gespeichertes `.html` oder `.svg` — von hier, oder von einem Avatar, der vor
Jahren über eine andere Route hochgeladen wurde — wird als **gar nichts**
ausgeliefert statt als Skript.
Die Seite ist `noindex`. Ein geheimer Link ist keine Seite, die indexiert werden
soll, und ein Crawler, der einem folgt, hat ihn veröffentlicht.
---
## Diktat im Widget oder auf einer gehosteten Seite { #dictation-on-a-widget-or-a-hosted-page }
Ein Widget und eine gehostete Seite haben jeweils eine Einstellung `dictation`:
`off`, `browser` oder `server`. **`browser`** lässt den eigenen Browser des
Besuchers die Sprache erkennen, sodass das Audio an den Anbieter des Browsers
geht - Google bei Chrome, Apple bei Safari - und nichts dieses Deployment
erreicht. Einem Browser ohne Spracherkennung, Firefox darunter, wird kein
Mikrofon gezeigt. **`server`** nimmt in der Seite auf und schickt das Audio
hierher, wo der Speech-to-Text-Provider der Organisation es transkribiert: der
Override des Agents und sein Schlüssel, wenn er einen hat, sonst die
Voreinstellung der Organisation und ihr Schlüssel - dieselbe Auflösung wie beim
[Diktieren im Chat](ml-services.md#dictation-in-chat).
Die Engine der Organisation für `/chat` hat über keine der beiden Oberflächen
etwas zu sagen. Eine Organisation, die ihre Mitglieder bei der Spracherkennung
des Browsers hält, kann einer öffentlichen Seite trotzdem `server`-Diktat geben,
und umgekehrt.
**Das Einschalten von `server` wird abgelehnt**, solange sich für den Agent kein
Provider mit brauchbarem Schlüssel auflöst, und das Embed-Formular zeigt die Wahl
deaktiviert, mit dem Grund. Geprüft wird nur das Umschalten, sodass eine Seite,
deren Schlüssel später gelöscht wurde, sich weiterhin umbenennen lässt. Solange
sich nichts auflöst, sagt ihre öffentliche Konfiguration `off`, und Besucher
sehen kein Mikrofon statt eines, das scheitert. Der Agent muss außerdem
veröffentlicht sein: eine Aufnahme für einen Agent, den niemand ausführt, wird
abgelehnt, bevor irgendetwas bezahlt wird.
**Die gehostete Seite** benutzt den eigenen Recorder des Eingabefelds der
Konsole: die Aufnahmeleiste mit Wellenform und Timer, die Hinweise unter dem Feld
und das Voice-Glow. Ihre Texte folgen der Sprache des Besuchers und nennen
keinen Administrator und keine Einstellung. **Das Widget** hat einen eigenen
Recorder in reinem JavaScript: eine Mikrofon-Schaltfläche, einen Pegelbalken in
der Akzentfarbe, einen Timer, Stop und Cancel und eine Zeile unter dem Feld,
wenn etwas schiefgeht. Seine Texte sind Englisch, wie der Rest des Widgets, und
es hat kein Glow. Auf beiden landet der Text im Feld und wird nie von selbst
gesendet; Enter beendet eine Aufnahme, und Escape verwirft sie.
Beide nehmen in dem Format auf, das der Server im `dictation_format` der
öffentlichen Konfiguration ankündigt: WAV mit 16 kHz, Mono und 16-Bit-PCM, aus
den Samples des Mikrofons gebaut, es sei denn, das Deployment
[nimmt komprimiertes Audio an](deployment.md#what-a-dictation-may-be-uploaded-as)
und der Provider nimmt, was `MediaRecorder` erzeugt - WebM, Ogg oder MP4.
Mistral, das weder WebM noch Opus dokumentiert, bekommt in jedem Fall WAV.
### Der Endpoint { #the-endpoint }
`POST /api/v1/embed/{public_key}/dictation` nimmt ein Multipart-`file` und eine
optionale `duration` und antwortet mit `{"text": ...}`. Die Zulassung folgt der
Oberfläche. Ein **Widget** wird so zugelassen wie sein Socket: der `Origin` muss
auf seiner Allow-Liste stehen, und im `jwt`-Modus reist das Token des Besuchers
als Formularfeld namens `token` - nie in der URL, die in Zugriffslogs landet,
und nie in einem Header, der einen CORS-Preflight bräuchte, den die Website
eines anderen nicht besteht. Eine **gehostete Seite** schickt ihren
Kontinuitätsschlüssel in `X-Visitor-Key`, einem eigenen Header, den nur der
eigene Origin dieses Deployments senden darf. Ein **rohes Socket** hat nichts,
in das es diktieren könnte, und bekommt 404.
Ablehnungen tragen denselben `details.reason` wie die
[Route des Eingabefelds](ml-services.md#dictation-in-chat): 400 `empty`,
`unsupported_type` oder `too_large`, 503 `not_configured`, `provider_error` oder
`timeout`. Anders als die Route des Eingabefelds lehnt diese eine komprimierte
Aufnahme, deren Länge sich nicht lesen lässt, als `unsupported_type` ab
([unten](#what-it-costs-and-what-bounds-it)). Alles, was das Embed selbst betrifft - unbekannt, pausiert, nicht auf
`server`, ein Origin, den es nicht erlaubt -, ist 404. Sobald ein Widget
zugelassen ist, trägt jede Antwort seinen Origin in
`Access-Control-Allow-Origin`, Ablehnungen eingeschlossen; sonst verbirgt ein
Browser den Grund, und eine zu lange Aufnahme sähe für den Besucher so aus, als
wäre er offline.
### Was es kostet und was es begrenzt { #what-it-costs-and-what-bounds-it }
Eine Transkription liegt **außerhalb jedes Modell-Budgets**: das Budget des
Embeds zählt die Runs, nicht die Diktate. Was die Ausgaben eines Fremden
begrenzt, sind zwei Rate-Limits und das längste Diktat der Organisation. Jede
Adresse darf `RATE_LIMIT_EMBED_DICTATION_PER_MINUTE` Aufnahmen pro Minute senden
(standardmäßig 6), und ebenso jeder Besucher eines `jwt`-Widgets, nach dem
Subject seines Tokens. Jede Seite und jedes Widget nimmt
`RATE_LIMIT_EMBED_DICTATION_PER_LINK_PER_MINUTE` pro Minute an (standardmäßig 10),
von allen zusammen, durch wie viele Adressen ein Skript auch rotiert.
Die Adresse wird gezählt, bevor der Link nachgeschlagen wird, also verbraucht
eine abgelehnte Anfrage die Minute ihrer Adresse. Die zehn Aufnahmen des Links
werden erst verbraucht, wenn der Besucher zugelassen ist, also kann jemand ohne
gültiges `jwt`-Token sie nicht für die Besucher aufbrauchen, die eines haben.
Auf einer Hosted Page oder in einem öffentlichen Widget wird jeder zugelassen,
also kann jeder mit dem Link seine Minute weiterhin verbrauchen; die
Organisation kostet das höchstens diese zehn Aufnahmen.
Ist das Redis des Deployments nicht erreichbar, **fallen diese Limits nicht
weg**: Jeder Worker zählt dieselben Kontingente im eigenen Speicher, bis Redis
wieder da ist. Die Obergrenze ist dann das Kontingent mal die Zahl der
API-Worker statt gar keiner. Die Limits für den Start von Sprachanrufen
verhalten sich genauso.
**Die Länge wird gemessen, bevor der Provider aufgerufen wird**, und eine
Aufnahme, die länger als `dictation_max_seconds` ist, wird als `too_large`
abgelehnt, mit drei Sekunden Spielraum dafür, dass der Browser nur einmal pro
Sekunde stoppt. Standardmäßig ist eine Aufnahme rohes PCM, und ihre Länge ist
ihre Größe - der Server zählt die Samples, also kann die Zahl nur stimmen. Wo
das Deployment komprimiertes Audio annimmt, wird die Länge einer WebM-, Ogg-
oder MP4-Aufnahme aus ihrem eigenen Header gelesen - der letzten Ogg-Seite, den
Zeitstempeln der WebM-Blöcke, den Movie- oder Fragment-Headern von MP4 -, und
eine, deren Länge sich nicht lesen lässt, wird als `unsupported_type`
abgelehnt. Eine Byte-Grenze allein reicht nicht: Opus mit niedriger Bitrate
bringt Stunden in den Bytes einer fünfminütigen Aufnahme unter.
!!! danger "Der schlimmste Fall für einen geleakten oder öffentlichen Link"
Das Limit des Links mal das längste Diktat: 10 Aufnahmen pro Minute zu je
bis zu 300 Sekunden sind 50 Minuten Audio pro Minute. Bei etwa
0,006 $ pro Minute, dem Listenpreis von `whisper-1`, sind das fast 0,30 $ pro
Minute, 18 $ pro Stunde oder 430 $ pro Tag, abgerechnet über das
Provider-Konto der Organisation. Senken Sie `dictation_max_seconds` oder das
Limit des Links, pausieren Sie das Embed, oder bleiben Sie bei `browser`, wo
das zu viel ist.
Mit dem Standard, rohem PCM, ist das exakt: Keine Datei kann mehr Sekunden
enthalten, als ihre Größe sagt. Wo das Deployment komprimiertes Audio
annimmt, gilt es nur für eine Datei, deren Header ihre wahre Länge angibt,
wie es jeder Browser tut. Eine von Hand gebaute Datei, die ihre Länge zu kurz
angibt, begrenzt stattdessen die Byte-Grenze - `dictation_max_seconds` plus
drei Sekunden zu 32.000 Bytes pro Sekunde -, die Audio mit niedriger Bitrate
mit bis zu etwa vierzigmal so vielen Sekunden füllen kann. Das ist das
Risiko, das der Administrator beim Einschalten in Kauf nimmt.
Jeder Versuch, der den Provider erreicht, hinterlässt einen Nutzungsdatensatz mit
der Oberfläche (`hosted` oder `widget`) und dem Agent, und **ohne Person**: ein
Besucher ist kein Mitglied, und der Aufruf gehört nicht dem, der veröffentlicht
hat. Das Audio wird nie gespeichert.
**Die Mikrofon-Abfrage gehört der Website des Kunden.** Der Browser fragt im
Namen der Seite, auf der das Widget läuft, also blockiert sie eine Website, die
`Permissions-Policy: microphone=()` sendet, ein `<iframe>` ohne
`allow="microphone"` oder reines HTTP. Das Widget sagt das unter seinem Feld,
statt stillschweigend zu scheitern.
## Sprachanrufe im Widget oder auf einer gehosteten Seite { #voice-calls-on-a-widget-or-a-hosted-page }
Ein Widget und eine gehostete Seite haben jeweils einen Schalter
**Sprachanrufe (Voice calls)** (`voice_conversation`), standardmäßig aus und
getrennt vom Diktat. Ist er an, können Besucher einen [Sprachanruf](voice.md) mit
dem Agent führen: denselben Anruf, den ein Mitglied aus `/chat` führt, mit
denselben Limits, ausgeführt im Kontext dessen, der veröffentlicht hat, und
aufgezeichnet mit der Oberfläche `embed`.
**Das Einschalten wird abgelehnt**, solange der Agent keinen veröffentlichten
Abschnitt „Stimme“ hat, mit dem sich ein Anruf führen lässt. Geprüft wird nur der Schalter, sodass sich ein Embed, dessen Agent
seine Stimme später verliert, weiterhin bearbeiten lässt. Solange ein Anruf
nicht starten würde, sagt die öffentliche Konfiguration
`voice_conversation: false`, und Besuchern wird kein Anruf angeboten; wenn er
starten würde, sagt die Konfiguration außerdem, welchen Transport der Anruf
benutzt (`voice_transport`).
**Auf einer gehosteten Seite** erscheint im Eingabefeld eine Anruftaste, und der
Anruf läuft in der [Anrufleiste](console.md#voice-calls) der Konsole selbst: der
Status, die Zeit gemessen am Limit des Anrufs, die Wellenform, Stummschalten,
Push-to-Talk und Beenden, mit Untertiteln in der Nachricht, die gerade gesprochen
wird. Sie ist auf das beschränkt, was ein Besucher braucht: keine
Freigabe-Bedienelemente, keine Anzeige der Minuten der Organisation, und das
Ende eines Anrufs sowie Ablehnungen werden in Worten erklärt, die ein Besucher
versteht. Jede gespeicherte Runde kommt in den Thread der Seite, sodass der
Besucher tippend weitermachen kann; **Neuer Chat (New chat)** legt auf.
**Im Widget** ist der Anruf ein eigenes Skript, `voice.js`, das erst geladen
wird, wenn ein Besucher das Panel zum ersten Mal öffnet, und nur, wenn die
öffentliche Konfiguration Anrufe anbietet - so bleibt das Skript, das jeder
Besucher herunterlädt, klein. Es wird unter
`/api/v1/embed/{public_key}/voice.js` ausgeliefert und auf den Zähler des
Widget-Skripts angerechnet, und seine URL trägt einen Hash des Skripts, sodass
eine zwischengespeicherte Kopie nie mit einem neueren Widget zusammentrifft.
Die Eingabezeile des Widgets wird zu einer abgespeckten Anrufleiste - eine
Pegelanzeige in der Akzentfarbe des Widgets, der Status, die Zeit gemessen am
Limit, Stummschalten und Beenden - ohne Push-to-Talk und ohne Freigabekarte.
Untertitel erscheinen in der Nachrichtenliste und machen den gespeicherten
Runden Platz, und eine Ablehnung, ein Limit oder eine besetzte Leitung erscheint
als eine Zeile unter der Leiste.
**Ein Anruf im Widget ist eine eigene Unterhaltung.** Der getippte Chat des
Widgets hält keinen Besucherschlüssel - jeder Seitenaufruf beginnt neu -, daher
werden die Beiträge eines Anrufs in einer eigenen Unterhaltung gespeichert, und
eine danach getippte Nachricht beginnt eine weitere. Auf dem Bildschirm stehen
sie in einer Liste; für den Agent ist das im Anruf Gesagte kein Kontext für das
danach Getippte. Die gehostete Seite hat einen Besucherschlüssel, dort sind
Anruf und Tippen ein Thread.
Beenden, das Schließen des Panels, das Verlassen der Seite oder eine Verbindung,
die sich nicht aufbauen lässt, beendet den Anruf und gibt Mikrofon, Audio und
Verbindung frei; bricht die Verbindung mitten im Anruf ab, wird sie innerhalb
von acht Sekunden einmal neu aufgebaut. Die Texte des Widgets zum Anruf sind auf
Englisch, wie der Rest des Widgets.
**Ein Besucher kann nichts freigeben.** Tools, die eine Freigabe brauchen,
werden aus dem Anruf eines Besuchers ganz weggelassen, sodass der Agent nie zu
einem greift; er sagt klar, wenn er dort etwas nicht tun kann. Ein Anruf bekommt
außerdem den Platzierungshinweis des Embeds und die deklarierten Variablen der
Seite, genau wie eine getippte Nachricht, sodass sich der Agent in beiden gleich
verhält.
**Ein Besucher erfährt nur, was ein Besucher braucht.** Die Schalter unter [was
der Besucher von der Arbeit sieht](#what-the-visitor-sees-of-the-work) filtern
die Frames eines Anrufs, wie sie getippte Antworten filtern: Ohne *Was der Agent
gerade tut* erfährt ein Besucher nicht, dass ein Tool lief, und ohne *Was jeder
Schritt zurückgegeben hat* trägt ein Schritt nicht mit, womit ein Tool
aufgerufen wurde oder was zurückkam. Das Limit, das ein Besucher sieht, ist das
eigene Limit des Anrufs, und eine Ablehnung wegen Einrichtung, Preis oder
Budget des Betreibers sagt nur, dass Anrufe gerade nicht verfügbar sind. Das Embed zu pausieren, seine
Anrufe abzuschalten oder es auf einen anderen Agent zu richten, beendet die
Anrufe darauf innerhalb eines Heartbeats. Die Frames stehen auf [der
API-Seite](api.md#voice-calls).
### Das Anruf-Socket { #the-call-socket }
`/api/v1/embed/{public_key}/voice/ws` spricht dieselben Frames wie
`/api/v1/ws/voice` der Konsole ([API](api.md#voice-calls)). Die Zulassung folgt
der Oberfläche, wie beim Chat-Socket. Ein **Widget** wird über seine Allow-Liste
zugelassen und, im `jwt`-Modus, über das Token des Kunden in `?token=`. Eine
**gehostete Seite** wird über den eigenen Origin dieses Deployments und den
Kontinuitätsschlüssel in `?visitor=` zugelassen, und der Anruf landet in dem
Thread, den dieser Schlüssel hält, sodass ein abgebrochener Anruf dort
fortgesetzt werden kann. Ein **rohes Socket** hat nichts, aus dem es anrufen
könnte.
Das Socket schließt mit `4029` jenseits eines Rate-Limits und mit `4003` für
alles, was das Embed betrifft - unbekannt, pausiert, ein rohes Socket, Anrufe
ausgeschaltet, ein Origin, den es nicht erlaubt -, ohne zu sagen, was davon.
Alles nach der Zulassung ist die eigene Ablehnung des Anrufs, gesendet als
`ended`-Frame mit ihrem Grund.
### Was ein Anruf einen öffentlichen Link kostet { #what-a-call-costs-a-public-link }
Anrufe liegen innerhalb der Budgets: Jeder ist ein Run, wird auf die Budgets des
Agents und der Organisation abgerechnet und dauert höchstens so lange wie der
längste Anruf des Agents. Darüber hinaus hat ein öffentlicher Link eigene
Limits:
| Limit | Standard | Gezählt |
|---|---|---|
| `RATE_LIMIT_EMBED_VOICE_PER_MINUTE` | 3 | Anrufstarts pro Adresse und, bei einem `jwt`-Widget, pro Token-Subject des Besuchers |
| `RATE_LIMIT_EMBED_VOICE_PER_LINK_PER_MINUTE` | 6 | Anrufstarts auf einer Seite oder einem Widget, von allen zusammen |
| `VOICE_MAX_CALLS_PER_EMBED` | 3 | Gleichzeitig offene Anrufe auf einem Embed; der nächste Anrufer wird als `line_busy` abgelehnt |
Das eigene `rate_limit_per_minute` des Embeds zählt **Nachrichten**, nicht
Anrufstarts, und begrenzt Anrufe deshalb nicht. Die Adresse wird gezählt, bevor
der Schlüssel nachgeschlagen wird, also verbraucht ein abgelehnter Start die
Minute seiner Adresse; die Starts des Links werden erst verbraucht, wenn der
Besucher zugelassen ist. Das Limit pro `jwt`-Subject ist eine Startrate, keine Obergrenze
für offene Anrufe; die offenen Anrufe begrenzt das Limit des Embeds, zusammen
mit denen aller anderen.
!!! danger "Der schlimmste Fall für einen geleakten Link"
Drei gleichzeitig offene Anrufe, jeder höchstens so lang wie der längste
Anruf des Agents - standardmäßig 10 Minuten, höchstens 50 -, verbrauchen
höchstens drei Anrufminuten pro Minute, über das Provider-Konto der
Organisation. Beendet wird das von den Budgets des Agents und der
Organisation: Jeder Anruf wird vor dem Start gegen das Verbleibende geprüft
und beendet, wenn das Budget ausgeht, die Runs der Mitglieder eingeschlossen.
Geben Sie dem Agent ein eigenes Budget, pausieren Sie das Embed oder
schalten Sie seine Anrufe aus, wenn das zu viel ist.
---
## Die öffentliche API { #the-public-api }
Überhaupt kein Frontend und kein Browser. Eine Anfrage, eine Antwort:
```bash
curl -X POST https://your-api.example.com/api/v1/agents/AGENT_ID/run \
-H "Authorization: Bearer $TOKEN" \
-H "X-Organization-Id: $ORG_ID" \
-H "Content-Type: application/json" \
-d '{"prompt": "Summarise this week's refunds"}'
```
Sie geht durch denselben Runner wie jede andere Oberfläche, also wird der Run
aufgezeichnet, das Budget gilt, und die Kosten landen im selben Dashboard — ein
API-Aufrufer kann Governance nicht dadurch umgehen, dass er die UI nicht benutzt.
Runs werden mit `api` gestempelt.
Die Antwort trägt `run_id`, `output`, `status` und das, was sie gekostet hat. Ein
`status` von `awaiting_approval` mit leerem Output bedeutet, dass ein Tool-Aufruf
geparkt ist: der Run steht in der Approval-Warteschlange und läuft weiter, wenn
jemand entscheidet.
Begrenzt pro Aufrufer statt pro Adresse — ein Büro hinter einem NAT ist nicht ein
Aufrufer — auf `RATE_LIMIT_RUN_PER_MINUTE`.
## Andere Agents, über A2A { #a2a }
Der Agent eines anderen Systems kann einem Agent über das
[Agent2Agent-Protokoll](https://a2a-protocol.org) Aufgaben senden. Das ist für jeden
Agent aus, bis jemand, der ihn veröffentlichen darf, auf seinem Tab
**Availability** **Agent2Agent (A2A)** einschaltet; dort stehen die beiden Adressen
für die andere Seite:
- die **Agent Card** unter `/api/v1/a2a/{agent_id}/.well-known/agent-card.json`,
ohne Key lesbar — Name, Beschreibung, Version des Agent, seine eine Fähigkeit und
wie man sich anmeldet;
- der **Endpunkt** für Aufgaben, `POST /api/v1/a2a/{agent_id}`, JSON-RPC 2.0.
Der Aufrufer sendet einen API-Key der Organisation oder ein von diesem Deployment
ausgestelltes OAuth-Zugriffstoken als Bearer-Token, das den Agent ausführen darf:
Eines, das es nicht darf, wird mit HTTP `403` abgewiesen, oder mit `404`, wenn es
den Agent gar nicht sehen darf, denn A2A überlässt die Autorisierung dem Transport.
`message/send` nimmt nur Textteile an und weist eine Nachricht mit einem Daten- oder
Dateiteil mit `-32005` ab, statt sie nur mit ihrem Text auszuführen.
`message/send` führt die veröffentlichte Version als gewöhnlichen Run aus, mit `a2a`
gestempelt, sodass Budget, Freigabe, Audit-Trail und Activity gelten. Die ID der
Aufgabe ist die des Runs, und ihre `contextId` ist das Gespräch, unter dem der Run
abgelegt wird; sie erneut zu senden, setzt dieses Gespräch fort. Ein Run, der für
eine Freigabe anhält, antwortet `input-required`; entschieden wird in der Konsole,
und `tasks/get` liest, wo er seitdem steht.
Eine Aufgabe liest, wer sie gesendet hat, oder wer jeden Run lesen darf
(`runs:view`); für alle anderen ist ihre ID unbekannt. Eine Nachricht mit `taskId`
wird abgelehnt, statt neben der wartenden Aufgabe zu laufen: Für etwas Neues sendet
man sie ohne, im selben `contextId`.
Synchron beantwortet: `message/stream`, Push-Benachrichtigungen und `tasks/cancel`
werden namentlich abgelehnt, und die Karte sagt, dass Streaming nicht angeboten wird.
Ein Agent, der nicht angeboten wird — aus, unveröffentlicht, archiviert oder einer
anderen Organisation —, antwortet mit `404`, Karte wie Endpunkt.
---
## SMS { #sms }
Eine SMS an eine Twilio-Nummer erreicht einen Agent wie eine Direktnachricht auf jeder anderen
Plattform: über denselben Router, die verknüpfte Identität, die Zugriffsrichtlinie und die
Budgets (#2082). Neu ist nur der Transport.
**Den Bot hinzufügen** unter Channels → SMS (Twilio). Sein Token ist die Twilio-Account-SID, der
Auth-Token und die Absendernummer, durch Doppelpunkte verbunden:
`ACxxxxxxxx:auth_token:+15551234567`. Ein SMS-Bot arbeitet nur per Webhook; fügen Sie nach dem
Speichern die angezeigte Webhook-URL in die Einstellung für eingehende Nachrichten der Nummer in
der Twilio-Konsole ein. Die Adresse, die Twilio signiert, wird aus `PUBLIC_BASE_URL` gebaut; diese Einstellung muss also die Adresse sein, die Twilio tatsächlich aufruft: Hinter einem Proxy, der sie umschreibt, wird jede SMS abgelehnt.
**Wer schreibt.** Eine Anfrage wird nur mit gültiger Twilio-Signatur angenommen - HMAC-SHA1 der
Adresse, die Twilio aufgerufen hat, und ihrer Felder, unter dem Auth-Token - alles andere ist ein
403, bevor ein Wort davon gelesen wird. Eine gültige Signatur beweist, dass die Anfrage von Twilio
kam. Sie beweist nicht, wer der Absender ist: Die Nummer ist eine Behauptung, eine Person handelt
also erst mit ihren eigenen Rechten, wenn sie die Nummer wie jede andere Kanal-Identität mit ihrem
Konto verknüpft hat. Die Verknüpfung läuft nach `SMS_LINK_MAX_AGE_DAYS` ab (standardmäßig 90, `0` behält sie für immer), weil eine Nummer neu vergeben oder übernommen werden kann; die Person verknüpft erneut.
**Was zurückkommt.** Der Webhook antwortet sofort, und der Agent antwortet über die Twilio-API,
sobald er eine Antwort hat. Eine Antwort ist reiner Text - Markdown-Zeichen werden entfernt, ein
Link wird ausgeschrieben - und bei etwa 1.500 Zeichen mit dem Hinweis gekürzt, dass der Rest in
AgenticOS steht; eine Frage kostet so einige Segmente, nicht ein Dutzend. SMS kann nicht streamen,
keine Nachricht bearbeiten und keine Schaltfläche drücken, eine Freigabe oder Frage kommt deshalb
als nummerierte Zeilen an und wird per SMS mit der Nummer beantwortet. Eine einzelne Zahl, die eine
Auswahl einer an diese Nummer gesendeten Frage benennt, ist die Antwort. Gleichzeitig wartende Fragen teilen nie Nummern - die zweite zählt dort weiter, wo die erste endete, auf 1 und 2 folgen also 3 und 4 - und die Entscheidung über eine lässt die andere offen. Eine Frage bleibt einen Tag offen, eine Mehrfachauswahl nimmt also mehrere Zahlen an; jeder andere Text ist eine gewöhnliche Nachricht.
Eingehende MMS-Dateien, auch ohne Text, nutzen nach Identitäts- und
Zugriffsprüfung die gemeinsame Verarbeitung von Kanalanhängen. Höchstens zehn
Dateien werden gelesen; ihre Medien-URLs müssen das Twilio-Konto des Bots und
die eingehende Nachricht benennen. Authentifizierte Downloads lehnen
Weiterleitungen ab, enden bei `CHAT_MAX_UPLOAD_SIZE_MB` und haben ein Gesamtlimit
von 30 Sekunden. Der gemeinsame Upload prüft Typ und Inhalt und speichert
akzeptierte Dateien in der Unterhaltung. Eine Zahl mit Datei ist eine Nachricht,
keine nummerierte Freigabe.
Ausgehende Antworten können ausdrücklich gewählte Workspace-Dateien und Diagramme
als MMS senden, auch ohne Text. Das normale Kanaldatei-Tool bestimmt weiterhin
Datei und Ziel. Der Adapter lehnt nicht unterstützte Typen, leere Dateien, mehr
als zehn Anhänge und insgesamt über 5 MB Text und Medien ab. JPEG/PNG/GIF können
dieses Gesamtlimit nutzen; andere akzeptierte Typen einschließlich PDF und CSV
sind auf 500 KB begrenzt. Netzbetreiber- und Länderunterstützung gelten weiterhin;
eine Ablehnung des Anbieters bleibt ein Fehler.
MMS braucht eine öffentlich erreichbare HTTPS-`PUBLIC_BASE_URL` und gemeinsames
Redis. Nur ausdrücklich an den Adapter übergebene Kopien werden bereitgestellt,
jeweils mit einem zufälligen Abrufnachweis für 15 Minuten. Twilio verwendet ihn
für GET und HEAD; der authentifizierte Dateispeicher wird nicht geöffnet. Twilios
Warteschlange ist auf zehn Minuten begrenzt, kürzer als die Lebensdauer der Kopie.
Links werden weder im Chat gespeichert noch dem Modell übergeben. Die API-Annahme
bedeutet Übermittlung an den Anbieter, nicht Zustellung durch den Netzbetreiber.
Siehe [Twilios Formate und Limits](https://www.twilio.com/docs/messaging/guides/accepted-mime-types).
## E-Mail { #email }
Eine Mail an eine Adresse erreicht einen Agenten wie eine Direktnachricht auf jeder anderen Plattform:
über denselben Router, dieselbe verknüpfte Identität, Zugriffsrichtlinie und Budgets (#2082). Neu ist
nur der Transport.
**Bot hinzufügen** unter Kanäle → E-Mail. Sein Token ist die Adresse, von der der Bot antwortet, zum
Beispiel `agent@mail.example.com`; Antworten gehen über den eigenen E-Mail-Anbieter der Installation
hinaus, daher muss `EMAIL_PROVIDER` einer sein, der wirklich versendet - ein Bot auf dem Anbieter `log`
verweigert die Antwort, statt sie vorzutäuschen. Ein E-Mail-Bot arbeitet nur per Webhook und braucht ein
Geheimnis: Erzeugen Sie eines und kopieren Sie es ins Relay.
**Das Relay.** Mail wird nicht abgefragt. Was sie empfängt - ein Cloudflare Email Worker, eine
Inbound-Route von Postmark oder Mailgun, ein Skript auf einem Mailserver - sendet pro Nachricht ein
JSON-Dokument per POST an die nach dem Speichern beim Bot angezeigte URL:
```json
{"from": "ada@acme.com", "subject": "Invoice", "text": "Please send it",
"sender_authentication": {"dmarc": "pass", "from": "ada@acme.com"},
"message_id": "<1@acme>", "in_reply_to": "<0@acme>", "references": ["<0@acme>"],
"attachments": [{"filename": "a.pdf", "content_type": "application/pdf",
"content_base64": "..."}]}
```
und signiert es mit zwei Headern: `X-AgenticOS-Timestamp` (Unix-Sekunden) und `X-AgenticOS-Signature`,
also `sha256=` plus der hexadezimale HMAC-SHA256 von `timestamp.body` unter dem Geheimnis. Eine Anfrage
ohne gültige Signatur oder mit mehr als fünf Minuten Abweichung ist ein 403, bevor ein Wort davon
gelesen wird, und ein Bot ohne Geheimnis weist alles ab. Der Body ist auf 25 MB begrenzt, bis zu zehn
Anhänge werden gelesen, und eine bereits gesehene Nachrichten-ID wird ignoriert, sodass ein erneuter
Versuch des Relays den Agenten nur einmal startet.
Eingehende Anhänge teilen ein Cache-Limit von 64 MiB kodierter Daten und 1.024 Dateien pro Bot. Jede Zustellung wird atomar gespeichert; bei Erschöpfung folgt 429 mit Retry-After, bevor Dateien oder Verlauf gespeichert werden. Lesen oder Verwerfen gibt die Kapazität sofort frei. Ungelesene Dateien verfallen nach 15 Minuten; die nächste Cache-Operation gewinnt ihre Kapazität zurück. Verarbeitungsfehler entfernen ebenfalls ungenutzte Anhänge. Antworten und Rückfragen bleiben im Verlauf ihrer eigenen Eingangsnachricht, auch wenn inzwischen eine weitere eintrifft.
Der Webhook speichert einen Job `process-email-delivery`, bevor er 200 zurückgibt. Der Job behält den validierten Nachrichteninhalt einschließlich Anhängen; ein API-Neustart oder abgelaufener Cache verliert dadurch keine angenommene Arbeit. Die Message-ID-Deduplizierung wird mit dem Job bestätigt; nach einem Rollback kann die Anfrage wiederholt werden. Worker prüfen Bot und Organisation erneut, serialisieren Zustellungen an denselben Gesprächspartner und zeigen fehlgeschlagene Versuche unter Admin → Background jobs. Beendete Job-Payloads unterliegen `BACKGROUND_JOB_RETENTION_DAYS`; Deployment-Administratoren können sie einsehen.
SMS- und iMessage-Webhooks speichern einen Job `process-channel-delivery`, bevor eine gültige Nachricht bestätigt wird. Payload und Message-ID-Beleg gehören zu einer Datenbanktransaktion; ein Rollback erlaubt Wiederholung und API-Neustarts erhalten angenommene Arbeit. Worker prüfen Organisation, Plattform und Aktivität des Bots erneut und ordnen Zahlenantworten aktuellen Rückfragen zu; abgelaufene SMS-Identitäten können alte Anfragen nicht genehmigen. Zustellungspayloads unterliegen der Aufbewahrung für Hintergrundjobs und sind für Deployment-Administratoren sichtbar.
**Wer schreibt.** Die Signatur beweist, dass das Relay die Anfrage gesendet hat, nicht wer die Mail
geschrieben hat: Eine Adresse ist eine Behauptung. Eine Person handelt erst dann mit ihren eigenen
Berechtigungen, wenn sie diese Adresse wie jede andere Kanalidentität mit ihrem Konto verknüpft hat.
Jede Nachricht muss `sender_authentication: {"dmarc": "pass", "from": "ada@acme.com"}` enthalten,
als Bestätigung eines DMARC-Erfolgs mit Domain-Abgleich für genau diese Adresse im From-Header.
Das Relay muss das Ergebnis vom vertrauenswürdigen empfangenden Mailserver beziehen, niemals aus
vom Absender gelieferten Authentifizierungsheadern. Fehlende, fehlgeschlagene oder abweichende
Bestätigungen werden vor Kontozuordnung, Anhangspeicherung und Freigabeverarbeitung ignoriert.
Bestehende Relays müssen dieses Feld vor dem Upgrade ergänzen; eine HMAC-Signatur allein reicht
nicht mehr zur Annahme einer Nachricht.
**Was zurückkommt.** Der Webhook antwortet sofort, und der Agent antwortet per Mail auf die letzte
Nachricht des Absenders: `Re:` und derselbe Betreff, mit gesetzten `In-Reply-To` und `References`, damit
ein Mailprogramm sie in den Verlauf einsortiert, und mit angehängten Dateien, die der Agent erzeugt hat.
Unter einer Antwort zitierter Verlauf wird abgeschnitten, bevor der Agent ihn liest. Eine Freigabe oder
Frage kommt als nummerierte Zeilen und wird beantwortet, indem man nur mit der Nummer antwortet; die
Nummern verhalten sich wie bei SMS, und eine Frage bleibt einen Tag offen.
Eingehende `references` müssen eine Liste von Zeichenketten ohne CR oder LF sein; aufbewahrte Einträge dürfen höchstens 998 Zeichen enthalten. Nachrichten mit ungültigen Referenzen werden vor der Verarbeitung ignoriert. Mehrzeilige Betreffzeilen werden vor dem Erstellen der Antwortheader zu einer Zeile zusammengeführt.
## Slack { #slack }
1. **api.slack.com/apps → Create New App → From scratch.** Benennen Sie die App
und wählen Sie den Workspace.
2. **OAuth & Permissions → Bot Token Scopes → Add an OAuth Scope**, und fügen Sie
die dreizehn unten hinzu. *Install to Workspace* bleibt ausgegraut, bis
mindestens einer hinzugefügt ist, und darauf wartet eine leere App.
3. **Install App → Install to Workspace → Allow.** Kopieren Sie das **Bot User
OAuth Token** (`xoxb-…`).
4. **Event Subscriptions → Enable Events → Subscribe to bot events**, und fügen
Sie die fünf Events unten hinzu.
5. Registrieren Sie den Bot: **Channels → Add channel**, Plattform `slack`, fügen Sie
das Bot-Token ein.
6. Wählen Sie einen Transport — Socket Mode braucht nichts nach außen Offenes und
ist auf einem Laptop die richtige Wahl:
- **Socket Mode.** *Basic Information → App-Level Tokens → Generate*, Scope
`connections:write`; fügen Sie das `xapp-`-Token hier in die Einstellungen
des Bots ein. Keine Request URL, kein Signing Secret.
- **Events API.** Fügen Sie das Signing Secret aus *Basic Information → App
Credentials* **zuerst** in die Einstellungen des Bots ein, und setzen Sie
dann Slacks *Request URL* auf
`https://your-api.example.com/api/v1/slack/BOT_ID/events` — die Bot-ID
steht in seiner Zeile unter **Channels**.
7. **App Home → Messages Tab**: aktivieren Sie ihn und haken Sie *Allow users to
send Slash commands and messages from the messages tab* an. Ohne das
verbirgt Slack das Nachrichtenfeld des Bots, und eine Direktnachricht ist
unmöglich.
8. Veröffentlichen Sie den Agent und binden Sie ihn dann: Builder → der Agent →
**Availability** → der Bot.
9. Laden Sie den Bot in einen Channel ein — `/invite @your-bot` — und erwähnen Sie
ihn.
!!! tip "Oder fügen Sie ein Manifest ein und überspringen Sie die Schritte 2, 4, 6 und 7"
**App Manifest** in der linken Navigation nimmt die ganze Konfiguration auf
einmal — Scopes, Events, Socket Mode und den Messages Tab —, was weniger
fehleranfällig ist als elf Durchgänge durch eine Scope-Auswahl. Erstellen Sie
die App *aus einem Manifest*, oder fügen Sie dieses über das einer
bestehenden App ein:
```yaml
display_information:
name: Support Copilot
features:
bot_user:
display_name: Support Copilot
always_online: true
app_home:
messages_tab_enabled: true
messages_tab_read_only_enabled: false
oauth_config:
scopes:
bot:
- chat:write
- files:write
- files:read
- app_mentions:read
- users:read
- channels:read
- groups:read
- channels:history
- groups:history
- im:history
- mpim:history
- im:read
- mpim:read
settings:
event_subscriptions:
bot_events:
- app_mention
- message.channels
- message.groups
- message.im
- message.mpim
socket_mode_enabled: true
token_rotation_enabled: false
```
Für die Events API setzen Sie stattdessen `socket_mode_enabled: false` und
fügen `request_url` unter `event_subscriptions` hinzu — und lesen Sie zuerst
die nächste Warnung, denn Slack validiert diese URL in dem Moment, in dem das
Manifest gespeichert wird.
!!! danger "Das Signing Secret kommt vor der Request URL hinein, nicht danach"
Slack überprüft eine neue Request URL, indem es eine signierte
`url_verification`-Challenge dorthin schickt, und diese Challenge nimmt
denselben Weg wie jedes andere Event. Ein Bot ohne Signing Secret antwortet
**auf alle mit 500**, also meldet Slack „Your request URL didn't respond with
the correct challenge value" — was sich wie ein Netzwerkproblem liest und
keines ist.
!!! warning "Aktivieren Sie keine Token-Rotation"
*Advanced token security via token rotation*, oben auf **OAuth &
Permissions**, lässt das `xoxb-`-Token ablaufen. Dieses Deployment speichert
ein statisches Bot-Token im Vault und erneuert keines, also funktioniert der
Bot und hört dann auf. Redirect URLs, PKCE, User Token Scopes, IP-Bereiche
und Enterprise-Managed Authorization auf dieser Seite sind ebenfalls alle
ungenutzt — lassen Sie sie in Ruhe.
### Die Slack-App aus einem Manifest { #the-slack-app-from-one-manifest }
Die Schritte oben bauen die App von Hand. Die Einstellungen des Bots hier bieten
auch **Slack-App-Manifest kopieren**: In Slack *Create New App → From a manifest*
wählen, einfügen, installieren und Bot-Token und Signing Secret hier wieder
einfügen. Das Manifest richtet jede Anfrage auf diese Bereitstellung und schaltet
ein, was aus dem Bot eine App macht:
- **Der Assistenten-Bereich.** Ein neuer Thread in Slacks KI-Ansicht bekommt drei
vorgeschlagene Prompts, und der Thread zeigt *is thinking…*, während der Agent
arbeitet.
- **App Home.** Der eigene Reiter des Bots nennt seinen Agent, sagt, wie viele
Runs der Person auf eine Freigabe warten, und verlinkt Konsole und Architekt.
- **`/agent <Frage>`** fragt den Agent dort, wo es getippt wurde, und die
Nachrichten-Abkürzung **Ask the agent about this** fragt zu einer Nachricht in
ihrem eigenen Thread. Beide laufen genau wie eine Erwähnung: dieselbe
Verknüpfungsprüfung, dieselben Limits und dasselbe Audit.
- **Schaltflächen** bei Freigaben und Fragen — siehe
[Freigaben und Fragen als Schaltflächen](#approvals-and-questions-as-buttons).
- **Unfurls.** Ein Konsolen-Link auf einen Agent der Organisation zeigt dessen
Namen.
Ein Bot bedient genau einen Agent, daher gibt es in einer Direktnachricht keine
Agent-Auswahl; ein zweiter Agent ist ein zweiter Bot.
Das Manifest ist **org-ready**, sodass ein Enterprise-Grid-Administrator die App
einmal für alle Workspaces der Organisation installieren kann; ein gewöhnlicher
Workspace installiert sie wie bisher. Jede Antwort nennt den Workspace der
fragenden Person, was Slack braucht, um sie unter Grid zu streamen.
### In welche Richtung die Verbindung läuft { #which-way-the-connection-runs }
Die Einstellungen des Bots fragen, **wie Slack AgenticOS erreicht**:
- **Slack ruft dieses Deployment auf.** Slack schickt jede Nachricht an die
öffentliche Adresse dieses Deployments, geprüft mit dem Signing Secret. Das
Manifest nennt die URLs.
- **AgenticOS verbindet sich mit Slack.** Das Deployment baut die Verbindung
selbst auf (Socket Mode), mit dem `xapp-`-Token auf App-Ebene, und braucht keine
öffentliche Adresse - die Wahl für ein Deployment hinter einer Firewall oder
on-premises. Das Manifest schaltet Socket Mode ein und nennt keine URL, da dort
nichts antworten würde.
Das Formular fragt nur nach den Zugangsdaten, die die gewählte Richtung braucht.
### Antworten, die Slack selbst zeichnet { #answers-slack-draws-itself }
Die AI-Apps von Slack zeichnen eine Antwort, während sie entsteht, statt sie an Ort
und Stelle zu bearbeiten. Die Antwort streamt unter der Frage, und alles, was der
Agent auf dem Weg tut - eine Websuche, ein Nachschlagen im Wissen, eine Rechnung -
ist eine Zeile, die von „läuft“ zu „fertig“ oder „fehlgeschlagen“ wechselt. Ein
Schritt, der Webseiten gelesen hat, verlinkt sie. Die Einstellungen des Bots zeigen
die Schritte als **Zeitleiste** oder als einen **Plan** unter der Frage, oder
schalten das Streaming ab, womit die Antwort wieder einmal pro Sekunde bearbeitet
wird. Dasselbe geschieht von selbst, wenn Slack das Streaming verweigert.
Eine Markdown-Tabelle in der Antwort wird als Slack-Tabelle gezeichnet, ein
Diagramm kommt wie immer als Bild. Ebenso ein [Visual](reference/capabilities.md#visuals),
gezeichnet vom Browser des Deployments, wenn `VISUAL_RENDER_CDP_URL` ihn nennt. Ist die fertige Antwort nicht das, was gestreamt
wurde - ein Output-Guardrail hat etwas geschwärzt -, wird die Nachricht ganz neu
geschrieben.
### Scopes und Events { #scopes-and-events }
Vierzehn Bot Token Scopes, jeder gerechtfertigt durch einen Aufruf, den der
Adapter macht:
| Scope | Was ihn braucht |
|---|---|
| `chat:write` | `chat.postMessage`, und `chat.update` für die Antwort, die umgeschrieben wird, während sie entsteht |
| `files:write` | `files.upload` — ein Diagramm oder eine Datei, die der Agent zurückschickt |
| `files:read` | ein Anhang, den jemand gepostet hat, geholt von `url_private_download` |
| `app_mentions:read` | in einem Channel genannt zu werden |
| `channels:history`, `groups:history`, `im:history`, `mpim:history` | die `message`-Events, und `conversations.history` für das Channel-Transkript |
| `channels:read`, `groups:read` | `conversations.info`, `.list` und `.members` — die Channel-Abfragen, die ein Agent aufrufen darf |
| `im:read`, `mpim:read` | `conversations.members` bei einer Direktnachricht. Leicht zu vergessen und still, wenn man es tut: die Mitgliedschaftsprüfung schlägt geschlossen fehl, sodass eine Unterhaltung, in der niemand bestätigt werden konnte, **aus der Unterhaltungsliste verschwindet**, statt einen Fehler zu erzeugen |
| `users:read` | `users.info`, was Member-IDs in Menschen verwandelt |
| `users:read.email` | `users.lookupByEmail`, was ein neues Mitglied findet, um es zu [begrüßen](#a-welcome-for-a-new-member). Eine Slack-App, die vor seiner Aufnahme installiert wurde, funktioniert ohne ihn weiter und begrüßt niemanden, bis sie aus dem neuen Manifest neu installiert ist |
Fünf Bot-Events: `app_mention`, `message.channels`, `message.groups`,
`message.im`, `message.mpim`.
`message.channels` und `message.groups` liefern **jede** Nachricht in jedem
Channel, in dem der Bot ist, nicht nur die, die ihn nennen — und das ist Absicht,
also abonnieren Sie beide. Was entscheidet, ob der Bot spricht, ist die Regel
unten, gelesen aus dem Event: Slack setzt `<@U0123>` für eine echte Erwähnung ein,
sodass der Adapter weiß, welche Nachrichten an ihn gerichtet waren, und sich aus
dem Rest heraushält. Dass die ganze Unterhaltung ankommt, ist das, was ein Agent
brauchen wird, der *selbst* entscheidet, ob eine Nachricht eine Antwort verdient;
ein auf `app_mention` verengtes Abonnement lässt sich nachträglich nicht
erweitern, ohne dass jeder Betreiber seine Slack-App bearbeitet.
| Wo | Wann er antwortet |
|---|---|
| **Eine Direktnachricht** | Immer. Es ist niemand sonst im Raum, also hieße eine Erwähnung zu verlangen, jemanden zu bitten, den einzigen Teilnehmer anzusprechen |
| **Eine Gruppen-Direktnachricht** | Nur, wenn er genannt wird. Mehrere Leute teilen sie, also ist sie ein Raum und keine Unterhaltung mit einer Person |
| **Ein Channel** | Nur, wenn er genannt wird — `@the-bot`, oder `@agent-slug` für den Agent dahinter |
Eine Erwähnung irgendwo in der Nachricht zählt, nicht nur am Anfang. Ein Handle,
das getippt wird, ohne Slack es auflösen zu lassen, bleibt reiner Text und ist
keine Erwähnung — die Plattform hat keine geliefert —, und `@channel`, `@all`,
`@here` und `@everyone` sprechen den Raum an und nicht einen Agent.
**Eine Nachricht mit angehängter Datei ist eine Nachricht.** Slack markiert eine
solche mit `subtype: file_share`, und sowohl die Datei als auch die Bildunterschrift
dazu erreichen den Agent — ein Bild mit *„was siehst du?"* darunter ist eine Runde,
nicht zwei. Abgelehnt bleibt, wenn die Plattform den Channel beschreibt, statt dass
jemand darin spricht: eine Bearbeitung, eine Löschung, ein Beitritt, eine
Themenänderung und alles, was der Bot selbst gepostet hat.
Der Bot sieht das, womit die App installiert wurde, und nichts darüber hinaus.
Einen Scope später hinzuzufügen bedeutet, die App neu zu installieren, was ein
neues `xoxb-`-Token prägt — fügen Sie auch dieses ein, sonst behält der Bot den
Zugriff, den er hatte.
!!! info "Ein Bot, der nichts beantwortet: was gemeldet wird und was nicht"
Die Verbindung schon. Ein Polling-Bot, dessen Stream sich nicht geöffnet hat
oder immer wieder scheitert, trägt in seiner Zeile unter **Channels** ein
Abzeichen **Not connected**, mit dem Grund darauf - der Supervisor weiß es,
und bis #1351 schrieb er das in das Container-Log und sonst nirgendwohin.
Der Rest ist weiterhin still, und das ist die Reihenfolge, in der man es
prüft: kein Agent an den Bot gebunden (die Zeile sagt es), ein fehlender
Scope oder ein fehlendes Event-Abonnement, dann eine falsche `BOT_ID` in der
Request URL. Das Letzte kann sich nicht selbst melden: die Events-Route
antwortet für eine Bot-ID, die sie nicht finden kann, mit **200 und tut
nichts**, mit Absicht, denn ein Prober soll nur erfahren, dass der Endpunkt
existiert - was die URL bereits gesagt hat.
Funktioniert in Channels und in DMs. Eine Nachricht von einem verknüpften Konto
läuft als diese Person — nie als der Bot; eine von einem Konto, das niemand
verknüpft hat, läuft unter der Bindung, und nur in einem Channel. Eine
Direktnachricht fragt zuerst nach dem Konto.
Ein verknüpftes Konto, dessen Mitglied die Organisation verlassen hat oder dessen
Konto deaktiviert wurde, wird wie ein nicht verknüpftes behandelt: in einer
Direktnachricht abgelehnt, in einem Channel unter der Bindung ausgeführt. Ein
Offboarding löscht weder die Mitgliedschaftszeile noch die Verknüpfung des
Chat-Kontos, deshalb wird die Rolle nur aus einer Mitgliedschaft gelesen, die sich
noch anmelden kann.
### Eine Unterhaltung pro Thread { #one-conversation-per-thread }
**Die Einheit ist der Thread, und das schließt jetzt eine Direktnachricht ein.**
Schicken Sie dem Bot eine Nachricht, und er antwortet in einem Thread, der an
Ihrer wurzelt; dieser Thread ist eine Unterhaltung und behält seinen Kontext, so
lange Leute darin antworten. Antworten Sie in einem Thread, der schon existiert,
und sie schließt sich diesem an.
Also bekommen zwei Leute, die im selben Channel Verschiedenes fragen, zwei
Unterhaltungen, und keine liest den Kontext der anderen — und eine Person, die in
einer DM nach zwei verschiedenen Dingen fragt, bekommt aus demselben Grund zwei.
!!! warning "Eine Unterhaltung fortzusetzen heißt, in ihrem Thread zu antworten"
Eine neue Nachricht, unten in einen Chat getippt, ist eine **neue**
Unterhaltung ohne Erinnerung an die letzte. Das ist der Handel, und er ist
beabsichtigt: ein Chat, der auf sich selbst schlüsselt, rollt nie über, also
läuft er in Tagen am Kontextfenster vorbei, und jede Runde zahlt für die
ganze Historie dahinter. Ein Thread pro Frage ist ein Kontext pro Thema statt
eines langen Transkripts, das jemand kürzen muss.
Eine DM schlüsselte früher auf den Chat. Unterhaltungen von davor werden
nicht migriert — sie werden nicht mehr erreicht, und nichts darin geht
verloren.
!!! info "Das war bis vor Kurzem falsch"
Eine Erwähnung oben in einem Channel schlüsselte früher auf den Channel,
während der Thread, den die Antwort öffnete, auf den Thread schlüsselte. Es
waren zwei Unterhaltungen, also beantwortete der Agent eine Frage und hatte
dann, eine Nachricht später in dem Thread, den er gerade erzeugt hatte, keine
Erinnerung daran — und jede unzusammenhängende Erwähnung in diesem Channel
häufte sich obendrein in einer Unterhaltung.
Jetzt schlüsselt die erste Nachricht auf den Thread, den eine Antwort öffnen
*wird*, sodass beide Hälften übereinstimmen. Unterhaltungen von vor der
Korrektur werden nicht migriert; sie werden einfach nicht mehr erreicht, und
nichts darin geht verloren.
## Telegram { #telegram }
1. Erstellen Sie einen Bot mit @BotFather, kopieren Sie das Token.
2. **Channels → Add channel**, Plattform `telegram`.
3. Registrieren Sie den Webhook aus der UI heraus, oder betreiben Sie in der
Entwicklung Polling — keine öffentliche URL nötig.
Den Webhook zu registrieren ist das, was Telegram das Secret des Bots übergibt,
und **ein Bot ohne Secret lehnt jeden Webhook-Aufruf ab**, statt ihm zu vertrauen.
Bei einem Bot, der von Polling auf den Webhook-Modus umgestellt wird, muss also
der Webhook registriert werden, bevor er irgendetwas beantwortet: das Secret wird
geprägt, wenn der Modus wechselt, und Telegram erfährt es erst, wenn der Webhook
registriert wird.
Der Bot setzt sein Befehlsmenü — `/agents` sagt, welcher Agent antwortet und was
er tut, neben `/new`, `/link` und `/help` —, zeigt *schreibt…*, während er
arbeitet, und nennt den Schritt, an dem er ist, sendet Antworten als Telegram-HTML,
umgewandelt aus dem Markdown des Agents, damit Codeblöcke und Links erhalten
bleiben, und antwortet im Thema einer Forengruppe in diesem Thema.
## Mattermost { #mattermost }
Mattermost ist selbst gehostet, also trägt ein Bot **die URL Ihres Servers**
ebenso wie sein Token — es gibt kein api.mattermost.com, auf das man zurückfallen
könnte. Einen ohne sie zu registrieren wird abgelehnt, statt angenommen und später
entdeckt zu werden: ein Bot, der seinen Server nicht kennt, kann nicht antworten,
kann seinen Event-Stream nicht öffnen und kann keine Datei holen, die jemand
angehängt hat.
Zwei Wege hinein; wählen Sie danach, ob Ihr Mattermost dieses Deployment erreichen
kann.
**Event-Stream (nichts nach außen offen).** Die richtige Wahl hinter einem VPN.
1. In Mattermost: *Integrations → Bot Accounts → Add Bot Account*. Kopieren Sie
das Token, das es einmal zeigt — das ist das **Bot-Token**.
2. Registrieren Sie es: **Channels → Add channel**, Plattform `mattermost`, fügen Sie
das Token ein und setzen Sie **Server URL** auf Ihr Mattermost, z. B.
`https://mattermost.acme.internal` oder `http://mattermost:8065` innerhalb von
Compose. Lassen Sie das Webhook-Token leer.
3. Fügen Sie den Bot dem **Team** hinzu, was *Integrations → Bot Accounts* nicht
tut: *System Console → User Management → Users*, suchen Sie ihn, **Manage
Teams**, fügen Sie das Team hinzu — oder `mmctl team users add <team> <bot>`.
Bis dahin weigert sich ein Channel, ihn einzulassen, und sagt, er „is not a
part of this team".
4. Laden Sie den Bot in einen Channel ein. Das Deployment öffnet ein
authentifiziertes WebSocket zu Ihrem Server, und jedes `posted`-Event trifft
darauf ein.
**Jeder** Post, und das ist das Wissenswerte an diesem Transport: das Socket ist
kein Abonnement auf Nachrichten, die an den Bot gerichtet sind, es ist der
Channel. Die Regel ist also die, der ein Kollege folgt — und sie gehört dem Bot
und nicht einem Weg, ihn zu erreichen, sodass der Outgoing Webhook unten derselben
Tabelle gehorcht:
| Wo | Wann er antwortet |
|---|---|
| **Eine Direktnachricht** | Immer. Es ist niemand sonst im Raum, also hieße eine Erwähnung zu verlangen, jemanden zu bitten, den einzigen Teilnehmer anzusprechen |
| **Ein Channel** | Nur, wenn er genannt wird — `@the-bot`, oder `@agent-slug` für einen der Agents, die darauf verfügbar sind |
*Wie* er weiß, dass er genannt wurde, gehört dem Transport, denn die beiden
Payloads sagen Verschiedenes:
| Transport | Was er liest |
|---|---|
| **Event-Stream** | Mattermosts eigene Erwähnungsliste auf jedem `posted`-Event, gegen das Konto, das der Bot einmal pro Session auflöst |
| **Outgoing Webhook** | Das `trigger_word`, auf das die Integration ausgelöst hat. Der Body trägt keine Erwähnungsliste, also kann `@the-bot` hier nicht gelesen werden — setzen Sie das Trigger-Wort auf das Handle des Bots, wenn Leute ihn so erreichen sollen |
Ein `@agent-slug` braucht keines von beidem und funktioniert auf beiden: es wird
aus dem Text gelesen, denn ein Slug ist ein Name in *diesem* Produkt und steht
deshalb nie in einer Erwähnungsliste.
Der Stream liest aus demselben Grund die Liste, statt Text abzugleichen: `@ada`
ist jemand, dessen Anzeigenamen der Bot nicht auflösen kann, und ein Bot namens
`bot` sollte nicht auf das Wort „robot" antworten. Ein Handle, das sich als weder
der Bot noch einer seiner Agents herausstellt, wird in einer Direktnachricht
beantwortet und in einem Channel übergangen, denn dort war es der Kollege von
jemandem.
**`@channel`, `@all`, `@here` und `@everyone` sprechen den Raum an, nicht einen
Agent.** Sie haben die Form eines Slugs, und eine kanalweite Erwähnung setzt jedes
Mitglied des Channels — den Bot eingeschlossen — auf die Erwähnungsliste der
Plattform, sodass eine Ankündigung als Nachricht gelesen wurde, die einen Agent
nennt, den niemand hat. Diese vier Handles sind hier nie eine Erwähnung, und ein
Agent, der nach einem von ihnen benannt ist, bekommt `-agent` ans Ende seines
Handles, damit er erreichbar bleibt.
Wenn das eigene Konto des Bots nicht aufgelöst werden kann, beantwortet der Stream
alles, so wie er es tat, bevor es diese Regel gab: auf einem Server, der nicht
sagen will, wer wir sind, still zu werden, ist der schlimmere der beiden Fehler.
Der Webhook hat keinen solchen Rückfall und braucht keinen — eine Integration ohne
Trigger-Wort ist ein Channel-Filter, und ein Channel-Filter sagt nichts darüber,
für wen ein Post war.
**Outgoing Webhook.** Für ein Mattermost, das diese API erreichen kann.
1. Erstellen Sie das Bot-Konto und registrieren Sie es genau wie oben.
2. *System Console → Integrations → Outgoing Webhooks → Add*, mit der
Callback-URL `https://your-api.example.com/api/v1/mattermost/BOT_ID/webhook` —
die Bot-ID steht in der Zeile, sobald er registriert ist, und
`channel-webhook-register` druckt die ganze URL. Seine **Trigger Words** sind
das, was den Bot auf diesem Transport anspricht, gemäß der Tabelle oben;
lassen Sie sie leer, und nur ein `@agent-slug` erreicht ihn in einem Channel.
3. Mattermost zeigt ein **Token**, wenn der Webhook gespeichert wird. Fügen Sie
das hier in das Feld **Webhook token** des Bots ein.
Das Token ist das eine, was die Leute zweimal falsch machen, deshalb lohnt es
sich, genau zu sein: **Mattermost erzeugt es, und Sie fügen es in AgenticOS ein**
— die entgegengesetzte Richtung zu Telegram, wo dieses Deployment das Secret
erzeugt und es beim Registrieren des Webhooks übergibt. Für Mattermost wird lokal
nichts erzeugt, denn ein lokal erzeugter Wert ist einer, den Mattermost nie senden
wird.
Mattermost signiert Webhook-Bodies nicht so, wie Slack es tut — das Token im
Payload ist die ganze Prüfung —, also **lehnt ein Bot ohne Webhook-Token jeden
Aufruf ab**, statt ihm zu vertrauen. Die Zeile des Bots sagt das mit einem
Abzeichen.
Beides lässt sich auch von der Kommandozeile aus erledigen, was auf einem
Deployment ohne Browser davor der einzige Weg ist:
```bash
uv run agenticos cmd channel-add-bot \
--platform mattermost --name "Ops" --token <bot-token> \
--org <organization-id> \
--api-base-url https://mattermost.acme.internal \
--webhook-secret <token-from-mattermost> # omit for the event stream
uv run agenticos cmd channel-test-message --bot-id <uuid> --chat-id <channel-id>
```
**Was eine Server-URL sein darf.** Schema und Form werden geprüft — http oder
https, ein Host, kein `user:pass@` —, und eine private Adresse oder eine
Loopback-Adresse ist bewusst erlaubt, denn ein selbst gehostetes Mattermost hinter
einem VPN ist genau das Deployment, wofür es das gibt.
Instanz-Metadaten-Adressen sind die Ausnahme und werden abgelehnt. Die Grenze, die
tatsächlich hält, ist die Berechtigung, Channel-Bots zu verwalten, nicht diese
Prüfung.
Freigaben und Fragen kommen als Schaltflächen. Mattermost ruft jede Schaltfläche
unter `https://your-api.example.com/api/v1/mattermost/BOT_ID/actions` zurück, also
muss der Mattermost-Server die API erreichen; in einem privaten Netz tragen Sie
den Host der API unter *System Console → Developer → Allow untrusted internal
connections to* ein.
### `/agent` in Mattermost { #agent-in-mattermost }
Ein Slash Command fragt den Agent dort, wo er eingegeben wurde, wie `/agent` in
Slack. In Mattermost: *Integrations → Slash Commands → Add Slash Command*:
Auslösewort `agent`, Methode `POST` und die Request URL, die die Einstellungen des
Bots zeigen. Mattermost zeigt dann ein Token; das gehört in das Feld **Slash
command token** des Bots. Jede Anfrage trägt es, und ein Bot ohne Token lehnt alle
ab.
## Microsoft Teams { #microsoft-teams }
Ein Teams-Bot ist eine **Azure-Bot**-Ressource, angelegt als **Single-Tenant** in
Ihrem Entra-Mandanten — Microsoft legt seit Juli 2025 keine Multi-Tenant-Bot-
Ressourcen mehr an — und wird über das eigene Protokoll des Bot Connectors statt
über ein SDK erreicht (#1985).
1. Legen Sie im Azure-Portal einen **Azure Bot** vom Typ *Single Tenant* an.
Kopieren Sie unter *Configuration* die **Microsoft App ID** und den Mandanten, und
legen Sie unter *Certificates & secrets* der App-Registrierung einen **geheimen
Clientschlüssel** an.
2. Registrieren Sie ihn: **Channels → Channel hinzufügen**, Plattform `teams`, mit
dem Schlüssel als Token und App- und Mandanten-ID daneben.
3. Fügen Sie den **Messaging-Endpunkt** aus den Bot-Einstellungen in die
Konfiguration des Azure Bots ein und aktivieren Sie dort den Kanal **Microsoft
Teams**.
4. **Laden Sie das App-Paket** aus den Bot-Einstellungen herunter — das Manifest und
seine zwei Icons, für diesen Bot gebaut — und laden Sie es in den Teams-App-Katalog
Ihres Mandanten hoch. Fügen Sie die App einem persönlichen Chat, einem Gruppenchat
oder einem Team hinzu.
**Jede Aktivität wird geprüft, bevor sie gelesen wird.** Der Connector signiert
jede mit einem JWT: Die Signatur wird gegen die Schlüssel geprüft, die der Aussteller
veröffentlicht, der Aussteller muss das Bot Framework oder Ihr eigener Mandant sein,
die Zielgruppe muss die App ID dieses Bots sein, das Token darf nicht abgelaufen
sein, und der genannte Dienst muss der der Aktivität sein. Eine Anfrage, die eine
dieser Prüfungen nicht besteht, ist ein 401. Der Bot ist Single-Tenant, also wird
auch eine Aktivität aus einem anderen Mandanten abgelehnt, und das Token des Bots
geht nur an eine Bot-Connector-Adresse.
| Wo | Wann er antwortet |
|---|---|
| Ein persönlicher Chat | Auf jede Nachricht. Der Chat ist eine Unterhaltung |
| Ein Gruppenchat | Wenn er @erwähnt wird |
| Ein Channel | Wenn er @erwähnt wird, in dem Thread, in dem er gefragt wurde — jeder Thread eine eigene Unterhaltung |
**Wer spricht**, ist die Entra-Objekt-ID des Mitglieds, in jedem Chat des Mandanten
dieselbe. Wo dieses Deployment Personen mit Entra im selben Mandanten anmeldet,
wird ein nicht verknüpftes Teams-Konto mit dem Mitglied verknüpft, das sich mit der
Adresse aus der Teams-Mitgliederliste anmeldet, ohne Link zum Klicken; überall sonst
gilt der Verknüpfungsablauf wie auf jeder Plattform, und `require_link` entscheidet
wie dort.
**Freigaben und Fragen sind Adaptive Cards.** Ein Druck wird als das Mitglied
entschieden, das gedrückt hat, und die Karte wird durch das Gewählte ersetzt. Eine
Datei, die der Agent sendet, erreicht einen persönlichen Chat über die
Zustimmungskarte von Teams; in einem Gruppenchat oder Channel nennt die Antwort sie
stattdessen, weil Teams dort keine Datei von einem Bot annimmt. Ein Diagramm wird in
die Nachricht gezeichnet.
**Ein Trigger kann seine Antwort in Teams posten.** Jede Unterhaltung, in der der Bot
ist, wird gemerkt, und ein Trigger kann einen der Team-Channels oder Gruppenchats der
Organisation nennen: Die Antwort jeder Ausführung wird dann dort als neue Nachricht
gepostet, unter dem Namen des Triggers. Ein persönlicher Chat wird nie angeboten — er
gehört jemandem.
## Discord { #discord }
Ein Discord-Bot ist eine Anwendung im Discord Developer Portal, angesprochen über
Discords eigenes Gateway und die REST-API, ohne Bibliothek dazwischen (#2079).
Nachrichten, Slash-Befehle und Schaltflächen-Klicks kommen alle über die eine
Gateway-Verbindung des Bots, sodass **ein Deployment für Discord keine öffentliche
Adresse braucht** und ein Discord-Bot keinen Webhook-Modus hat.
1. Erstellen Sie im [Developer Portal](https://discord.com/developers/applications)
eine Anwendung. Setzen Sie unter **Bot** das **Token** zurück, kopieren Sie es
und schalten Sie **Message Content Intent** ein. Ohne den lehnt Discord die
Verbindung ab, und die Zeile des Bots sagt, welcher Schalter fehlt. Setzen Sie
unter **Installation** den **Install Link** auf *None* und schalten Sie dann
unter **Bot** **Public Bot** aus. Einen öffentlichen Bot kann jeder zu seinem
eigenen Server hinzufügen, deshalb verweigert
der Bot die Verbindung, solange er öffentlich ist. Ein Bot, der während der
Verbindung öffentlich wird, hält an, sobald er das nächste Mal einem Server
hinzugefügt wird.
2. Kreuzen Sie unter **OAuth2 → URL Generator** die Scopes `bot` und
`applications.commands` an sowie die Berechtigungen **View Channels**, **Send
Messages**, **Send Messages in Threads**, **Create Public Threads**, **Read
Message History** und **Attach Files**, und öffnen Sie dann die URL, um den Bot
einem Server hinzuzufügen. Ohne **Attach Files** wird eine Datei oder ein
Diagramm des Agenten abgelehnt, und der Text, der damit gesendet wurde, geht
ebenfalls verloren.
3. Registrieren Sie ihn: **Channels → Channel hinzufügen**, Plattform `discord`,
mit dem Token.
**Server ausdrücklich genehmigen.** Tragen Sie im Formular zum Hinzufügen oder Bearbeiten des Bots die **Genehmigten Discord-Server-IDs** ein. Aktivieren Sie Discords Entwicklermodus, kopieren Sie jede Server-ID und trennen Sie IDs durch Kommas oder Leerzeichen. Übergeben Sie in der CLI `--discord-guild 123456789012345678` an `channel-add-bot`; wiederholen Sie die Option für weitere Server. Eine leere Liste erlaubt nur Direktnachrichten. Von READY gemeldete Installationen sind keine Genehmigungen; Entfernen und erneutes Hinzufügen umgeht die Liste nicht.
`/ask` bestätigt die Interaktion privat, bevor Kanalabfragen oder Datenbankprüfungen beginnen. Nur eine zugelassene Frage wird öffentlich gepostet; Ablehnungen und Einladungen zur Kontoverknüpfung bleiben privat. Einladungen verbrauchen das Limit des Absenders. Die Servergenehmigung bestimmt die Orte; `jwt_linked` oder `require_link` verlangt weiterhin ein Organisationskonto. Der Konsolenzugriff auf gespeicherte Unterhaltungen erfordert **View Channel** und **Read Message History**, bei privaten Threads zusätzlich die Mitgliedschaft.
Der Bot registriert seine Befehle beim Verbinden: `/ask`, `/agents`, `/new`,
`/link`, `/unlink` und `/help`, dazu **Ask the agent** unter *Apps* auf jeder
Nachricht. Discord kann eine Weile brauchen, bis neue Befehle auf jedem Server
erscheinen.
| Wo | Wann er antwortet |
|---|---|
| Eine Direktnachricht | Auf jede Nachricht. Die Direktnachricht ist eine Unterhaltung, und `/new` beginnt sie neu |
| Ein Serverkanal | Wenn er @erwähnt wird oder eine Nachricht auf ihn antwortet. Die Antwort öffnet einen Thread auf der Frage, und der Thread ist die Unterhaltung |
| Ein Thread, den der Bot geöffnet hat | Auf jede Nachricht darin |
| `/ask` | Überall: die Frage, bis zu 2.000 Zeichen, wird gepostet und die Antwort im Thread darunter gegeben |
| *Apps → Ask the agent* | Auf jeder Nachricht: der Agent wird zu dieser Nachricht gefragt, in einem Thread darauf |
`/agents`, `/link` und die anderen Befehle antworten privat, nur dem, der sie
ausgeführt hat. `/new` beginnt die Unterhaltung eines Threads neu; in einem
Server-Kanal, in dem jede Erwähnung ohnehin eine neue beginnt, sagt es das
stattdessen. In einem Kanal, in dem der Bot keine Threads öffnen darf, ist die
Antwort stattdessen eine Antwort auf die Frage, und wer darauf antwortet, setzt
dieses Gespräch fort.
**Die Antwort wird an Ort und Stelle geschrieben.** Der Bot postet eine Nachricht
und bearbeitet sie, während die Antwort einströmt, und läuft nach 2.000 Zeichen in
weitere Nachrichten über. Geschrieben wird nur Text, den die Guardrails schon
geprüft haben, sodass ein geschwärztes Geheimnis nie zu sehen ist, auch nicht für
einen Moment. Nichts, was der Bot postet, kann jemanden anpingen: `@everyone` und
Rollenerwähnungen in einer Antwort bleiben einfacher Text.
**Freigaben und Fragen sind Schaltflächen**, bei einer Frage mit mehr als 25
Optionen Auswahllisten. Unter jeder Antwort stehen Daumen, und ein Daumen nach
unten öffnet ein Discord-Formular, das fragt, was falsch war. Eine Reaktion auf die
Frage gibt es auf Discord nicht.
**Wer spricht**, ist die Discord-Benutzer-ID, mit `/link` wie auf jeder Plattform
mit einem Mitglied verknüpft. Eine Unterhaltung aus einem Serverkanal wird in der
Konsole denen gezeigt, die den Kanal noch sehen können, nach den Rollen des Servers
und den Berechtigungsüberschreibungen des Kanals, denn das heißt auf Discord, in
einem Kanal zu sein. Ein privater Thread ist nur auf Einladung zugänglich und
daher ein eigener Raum: Eine Unterhaltung daraus wird nur denen gezeigt, die
noch in diesem Thread sind, und die Notizen, die der Agent dort führt, werden
nicht mit dem Kanal geteilt.
**Ein Thread, in den der Bot mittendrin geholt wird, wird zuerst gelesen.** Die
letzten Nachrichten darin, mit Autor und Zeit, und die Dateien, die Leute dort
gepostet haben, erreichen den Agenten vor der Antwort, wie bei Slack und
Mattermost; Dateien, die der Bot selbst gepostet hat, bleiben außen vor. Nötig
ist **Read Message History**, und `read_channel_history` liest einen Thread oder
einen Kanal genauso, wo die Anbindung es erlaubt.
### Die Rollen eines Servers als Gruppen { #discord-roles }
Ein Administrator kann eine Rolle auf einem der Server des Bots einer Gruppe der
Organisation zuordnen (#2569). Öffne **Kanäle** und dann die Aktion
**Discord-Rollen** in der Zeile des Bots. Server und Rolle werden aus dem gewählt,
was der Bot selbst von Discord liest, daher werden `@everyone` und die Rollen, die
Discord für einen Bot verwaltet, nicht angeboten. Eine Zuordnung anzulegen verlangt
`members:manage`, und die Routen verlangen zusätzlich `channels:manage`.
Ein verknüpftes Mitglied mit der Rolle kommt vor jeder Nachricht an einen der
Discord-Bots der Organisation in die Gruppe, und erneut durch den Job
`sync-discord-roles` alle 15 Minuten. Wird ihm die Rolle entzogen, verlässt es die
Gruppe, und der Job bemerkt das auch bei jemandem, der dem Bot nicht mehr schreibt.
Der Bot fragt Discord jeweils nach den Rollen eines Mitglieds, braucht also keinen
privilegierten Intent, und eine neue Zuordnung erfasst Personen bei ihrer nächsten
Nachricht statt sofort.
Diese Prüfungen vor einer Nachricht zählen gegen das `rate_limit_rpm` des Absenders,
in einem eigenen Kontingent, Befehle wie `/help` eingeschlossen. Wer es überschreitet,
wird gebeten, langsamer zu schreiben, und Discord wird erst nach Ablauf der Minute
wieder gefragt.
Eine Zuordnung kann auch eine Rolle in der Organisation nennen. Eine verknüpfte
Person mit der Discord-Rolle, die noch kein Mitglied ist, tritt der Organisation dann
mit dieser Rolle bei. Das erspart es, jeden in einem Community-Server einzeln
einzuladen. Eine Rolle zu nennen verlangt `roles:manage`, und nur eine Rolle, die die
eigene Rolle des Aufrufers strikt übertrifft. Die Reihenfolge zwischen mehreren
passenden Rollen und die Regeln dazu, was die Synchronisierung nie anfasst, sind die
der [Verzeichnissynchronisierung](directory.md#what-the-sync-never-touches):
- Nur ein verknüpftes Konto zählt. Ein Discord-Nutzer, der nie `/link` ausgeführt
hat, kommt nirgendwohin, welche Rollen er auch hat.
- Eine Rolle passt nur auf dem Server, den die Zuordnung nennt, gelesen von dem Bot,
dem die Zuordnung gehört. Dieselbe Rollen-ID auf dem Server eines anderen Bots
passt auf nichts.
- Die Synchronisierung ändert nur Zeilen, die sie selbst angelegt hat, auf der
Mitgliederseite und in einer Gruppe mit **Discord** markiert. Eine von Hand
hinzugefügte Gruppenmitgliedschaft bleibt, wenn die Rolle entzogen wird, eine
Mitgliedschaft, die ein Administrator angelegt hat, behält ihre Rolle, und ein
Eigentümer wird nie herabgestuft oder entfernt. Wen die Synchronisierung in die
Organisation aufgenommen hat, verlässt alle ihre Gruppen, wenn sie ihn wieder
entfernt, wie jemand, der von Hand entfernt wird.
- Ist ein Server nicht lesbar, bleiben seine zugeordneten Gruppen erhalten. Mögliche Organisationsrollen erhalten nur bereits vorhandene Befugnisse; eine ungelesene `viewer`-Zuordnung erhält kein `admin`. Bestätigte Freigaben gelten weiterhin.
Jeder Abgleich hat ein gemeinsames Netzwerkbudget von zehn Sekunden. REST-Abfragen teilen das `rate_limit_rpm` des Bots pro Benutzer über alle verknüpften Konten, getrennt vom Limit der Nachrichtenprüfungen. Das Löschen einer zugeordneten Gruppe erfordert ebenfalls `channels:manage`.
Die Serverliste erlaubt eine gleichzeitige Anfrage pro Organisation. Seitennavigation und Rollenabfragen teilen ein Budget von zehn Sekunden; Rollen werden für höchstens acht Server gleichzeitig gelesen. Oberhalb der unterstützten 2.500 Server oder bei unvollständiger Antwort schlägt die Ermittlung ausdrücklich fehl.
Wer die Verknüpfung des Discord-Kontos löst, verliert, was dessen Rollen gegeben
haben, es sei denn, ein anderes verknüpftes Discord-Konto verdient es noch. Eine
gelöschte Zuordnung nimmt zurück, was sie gegeben hat, bei der nächsten Nachricht
der Person oder beim nächsten Lauf des Jobs. Den Bot oder eine zugeordnete Gruppe zu
löschen oder dem Bot einen neuen Token zu geben, nimmt dasselbe zurück und verlangt
deshalb, was das Löschen jeder dieser Zuordnungen verlangt. Jede Änderung steht im Audit-Log als
`discord.member_added`, `discord.role_changed`, `discord.member_removed`,
`discord.group_joined` oder `discord.group_left`, und das Anlegen oder Löschen einer
Zuordnung als `discord.mapping_created` oder `discord.mapping_deleted`.
Noch nicht: eine Verbindung bedient einen Bot auf höchstens 2.500 Servern, darüber
verlangt Discord Sharding und der Bot hält mit diesem Grund an. Die Tools für
Kanalmitglieder, Kanaldetails und Kanalsuche werden auf Discord nicht angeboten.
Wenn beide Quellen Zugang gewähren, hat das Verzeichnis bei der Anmeldung Vorrang.
Entfällt diese Freigabe, übernimmt eine verbleibende Discord-Rolle dieselbe
Mitgliedschaft ohne Schlüssel zu widerrufen. Siehe [Mitgliedschaftsübergabe](directory.md#what-the-sync-never-touches).
## Ein Bot, der nicht starten kann, hört auf, statt es erneut zu versuchen { #a-bot-that-cannot-start-stops-rather-than-retrying }
Telegram-Polling, Slack Socket Mode, der Mattermost-Event-Stream und das
Discord-Gateway laufen alle unter einem Supervisor, der eine abgerissene Session neu verbindet. Ein fehlender
oder zurückgewiesener Konfigurationswert ist keine abgerissene Session, und der
Supervisor behandelt ihn anders: er vermerkt den Bot mit dem Grund als
ausgefallen, protokolliert einmal und hört auf. Nichts, was er tut, würde die
Zeile ändern — ein Betreiber muss das `xapp-`-Token für Slack oder die Server-URL
für Mattermost ergänzen oder ein Bot-Token ersetzen, das Telegram zurückweist.
Das ist wichtiger, als es klingt. Einen Start, der sofort scheitert, erneut zu
versuchen unterbricht nie, also dreht sich der Supervisor, ohne abzugeben, und
jede andere Aufgabe auf dem Prozess — Anfragen, Health Checks, Chat-WebSockets —
wird nicht mehr eingeplant. Die API bleibt oben und beantwortet nichts. Beide
auslösenden Zustände sind gewöhnliche Zeilen, die jemand noch nicht ausgefüllt
hat, sodass der Ausfall jederzeit einen Neustart entfernt war.
Wenn ein Bot still ist, prüfen Sie das Log auf `not started`, bevor Sie ein
Netzwerkproblem annehmen.
Eine abgerissene Session ist etwas anderes: die wird erneut versucht, mit fünf
Sekunden Wartezeit, verdoppelt bis zu einer Minute, sodass ein Server, der eine
Stunde ausfällt, nicht von jedem Bot darauf 720-mal bearbeitet wird. Eine Session,
die sauber endete, beginnt die Leiter von vorn. Die Zeile, die vor jedem Warten
protokolliert wird, benennt die Verzögerung, die sie gleich abwartet, und dieselbe
Schleife bedient jede dieser Plattformen, sodass die Regel zwischen ihnen nicht
auseinanderlaufen kann.
---
## Freigaben und Fragen als Schaltflächen { #approvals-and-questions-as-buttons }
Ein in Slack, Teams, Telegram, Mattermost oder Discord gestarteter Run kann für einen Menschen
anhalten: Ein freigabepflichtiger Tool-Aufruf wartet auf eine Entscheidung, oder
der Agent stellt eine `ask_user`-Frage. Der Chat bekommt die Auswahl dort, wo er
ist — eine Nachricht pro Freigabe mit **Approve** und **Reject**, eine Nachricht
pro Frage mit ihren Optionen und **Skip**. Ein Druck entscheidet oder antwortet,
der Run läuft weiter, und seine Antwort erscheint im selben Chat; die gedrückte
Nachricht behält die Frage und zeigt die Wahl.
Eine Frage mit mehreren Antworten bleibt offen: Jeder Druck fügt eine Option
hinzu oder nimmt sie weg, der Chat erfährt, was bisher gewählt ist, und **Done**
schickt sie ab. Der Durchlauf geht weiter, sobald alles geklärt ist, wofür er
angehalten hat - jede Freigabe entschieden, jede gestellte Frage beantwortet, in
wie vielen Nachrichten auch immer - und die letzte Antwort schickt alle zusammen.
Eine Frage, die eine Antwort in eigenen Worten zulässt — und eine ganz ohne
Optionen — sagt das unter sich: Die nächste Nachricht der Person in diesem Chat
ist die Antwort, kein neuer Turn neben einem Durchlauf, der noch wartet. Es zählt
nur eine Nachricht der Person, der die Frage gestellt wurde, und sie geht an die
älteste solche Frage.
Eine Frage kann nur einem verknüpften Mitglied gestellt werden. Ein Absender ohne
verknüpftes Konto läuft als Herausgeber des Bindings, also wird eine Frage, die der
Agent ihm stellt, abgelehnt und der Turn geht ohne Antwort weiter, statt auf
Schaltflächen zu warten, die nur der Herausgeber drücken könnte.
Eine Freigabe liest sich als Bitte des Agents, in seiner eigenen Stimme: was er tun
will und das eine Argument, das sagt, worum es geht — *I need your approval to
**search in Notion**: “what's new in releases”*. Ein Tool von einem MCP-Server wird
mit seinem Dienst und seiner Aktion benannt statt mit seiner Kennung samt Präfix,
und die übrigen Argumente bleiben in **Approvals** in der Konsole, wo dieselbe
Anfrage mit dieser einen Zeile beginnt und den Rest hinter **Details** faltet.
Ein Druck handelt als die Person, die gedrückt hat, über ihr verknüpftes Konto:
Entscheiden braucht `approvals:decide`, und nur die gefragte Person kann eine
Frage beantworten. Eine anderswo getroffene Entscheidung handelt als die Person, die sie getroffen hat, auch wenn es ein App-Admin ist ([unten](#decided-outside-the-chat)). Wer nicht verknüpft ist, wird um `/link` gebeten. Ein Run
wartet `QUESTION_EXPIRY_HOURS` auf eine Antwort und `APPROVAL_EXPIRY_HOURS` auf
eine Entscheidung, wie überall. Slack signiert seine Drucke; Mattermost signiert
nichts, daher trägt jede Schaltfläche die eigene Signatur dieser Bereitstellung
über ihren Wert, und ein Druck ohne sie wird abgelehnt.
Wer gedrückt hat, kommt
ebenfalls nicht aus dem Inhalt des Drucks: Die Trigger-ID, die der Mattermost-Server
für jeden Druck signiert, nennt die Person und wird gegen den öffentlichen
Schlüssel geprüft, den der Server veröffentlicht; ein Druck, den niemand belegen
kann, wird ebenfalls abgelehnt.
**Ein Browsen, das einen Menschen braucht.** Ein Agent mit dem
[Webbrowser](reference/capabilities.md#browser-automation) postet in seinem Schritt den
Link zur Live-Ansicht des Browsens — *Watch or take over* — in Slack, Telegram und
Mattermost gleichermaßen. Braucht die Seite jemanden, eine Anmeldung oder ein Captcha,
wechselt der Schritt zu *Needs you* und sagt, wofür, und nennt, wer den Browser hat,
solange ein Mensch ihn hält. Der Link öffnet die Konsole, Übernehmen braucht also ein
Konsolenkonto: die Person, für die der Run handelt, oder wer den Agent bearbeiten darf.
### Außerhalb des Chats entschieden { #decided-outside-the-chat }
Eine im Chat gestellte Anfrage lässt sich auch in den **Approvals** der Konsole,
im Web-Chat oder über die HTTP-API entscheiden. Tut das eine Person und ist damit
das Letzte erledigt, worauf der Run wartet, setzt der Server den Run als sie fort
und postet die Antwort in den Chat, in dem gefragt wurde. Die Konsole sagt das,
statt den Run selbst fortzusetzen, daher wird der Run einmal fortgesetzt. Eine
Frage funktioniert genauso, wenn die gefragte Person sie in der Konsole beantwortet.
Eine Entscheidung mit einem API-Schlüssel wird festgehalten und setzt den Run nicht
fort: Einen Run, den eine Person gestartet hat, setzt nur eine Person fort. Über die
API sagt `continues_in_chat` einem Client, welcher Fall vorliegt (siehe
[Einen Agent ausführen](api.md#running-an-agent)).
Drei Enden, die außerhalb des Chats passieren, posten dort je eine feste
Nachricht, damit niemand auf Schaltflächen wartet, die nichts mehr bewirken. Die
offenen Schaltflächen des Chats werden im selben Moment abgeschlossen.
| Ende | Der Chat erfährt |
|---|---|
| Der Run läuft ab, nach `APPROVAL_EXPIRY_HOURS` bei einer Entscheidung oder `QUESTION_EXPIRY_HOURS` bei einer Frage | „This request expired without a decision." |
| Jemand drückt in der Konsole **Stop** | „This run was stopped from the console." |
| Ein Run wurde entschieden und nichts hat ihn etwa eine Stunde lang fortgesetzt | „A decision was recorded, but the run could not continue." |
Das Letzte ist ein Sicherheitsnetz. Es bleibt in zwei Fällen still: wenn die
Entscheidung mehr als 7 Tage alt ist und wenn der Chat bereits erfahren hat, dass
der Run nicht fortgesetzt werden konnte. Ein Chat erhält höchstens eine Mitteilung
je Parken eines Runs.
Eine Schaltfläche, die gedrückt wird, nachdem die Anfrage anderswo beantwortet
wurde, antwortet „That has already been answered." und ändert nichts.
Eine Fortsetzung kann abgelehnt werden, etwa wenn der Chat seit der Frage neu
begonnen hat, seine Zugriffsrichtlinie der fragenden Person jetzt den Zugang
verweigert, die entscheidende Person Freigaben nicht mehr entscheiden darf, der Bot
vom Agenten entfernt wurde oder der Agent nicht mehr gebaut werden kann. Der Chat
erfährt es einmal: „That was decided outside the chat, but the run could not
continue." **Resume** auf der Seite des Runs funktioniert weiterhin, bis etwa eine
Stunde nach der letzten Entscheidung. Es läuft
wie bisher, ohne die Raumprüfung, und postet die Antwort nur in einen Chat, der
die fragende Person noch zulässt. Eine Mitgliedschaftsprüfung, die die Plattform
nicht beantworten kann, hält eine Fortsetzung nicht auf.
Ein vor 1.29.0 geparkter Run hat keinen gespeicherten Chat, daher setzt die Konsole
ihn fort wie bisher. Setzt ihn niemand etwa eine Stunde nach der letzten Entscheidung
fort, beendet ihn das Sicherheitsnetz ohne Nachricht.
Der Worker sendet erforderliche Antworten und Abschlussmeldungen aus dauerhaften
Jobs, die in derselben Transaktion wie das Ergebnis oder der Abschluss gespeichert
werden. Nach einem Neustart oder vorübergehenden Sendefehler wird nur die Zustellung
bis zu dreimal versucht; Agent und freigegebene Werkzeuge werden nicht erneut
ausgeführt. Bot, Chat-Sitzung und Zugriffsrichtlinie werden vor dem Senden erneut
geprüft. Ein Ziel mit widerrufenem Zugriff erhält nichts. Plattformen ohne
idempotentes Senden können ein Duplikat anzeigen, wenn die Nachricht ankam, aber
ihre Bestätigung verloren ging. Ausgeschöpfte Versuche stehen unter
**Admin → Background jobs** (`deliver-channel-handback`).
### Nächste Schritte als Schaltflächen { #next-steps-as-buttons }
Auf die Antwort eines Agents mit [Nächsten Schritten](reference/capabilities.md#follow-ups),
die nächste Schritte angeboten hat, folgt eine Nachricht mit Schaltflächen, eine je
Schritt - *Mach eine Tabelle daraus*, *Mach das jeden Montag*. Ein Druck ist die
Person, die diesen Schritt sagt: Er wird als ihre nächste Nachricht in diesem Chat
beantwortet und allem unterworfen, dem eine getippte unterliegt - der Verknüpfung,
der Zugriffsrichtlinie des Bots, dem Rate Limit -, und die gedrückte Nachricht
zeigt danach den Schritt statt ihrer Schaltflächen.
Jede Nachricht wird einmal gedrückt; jeder im Raum darf es, und die Antwort gehört ihm. Eine Antwort, die für
eine Freigabe oder eine Frage angehalten hat, bietet stattdessen diese an. Die
Einrichtungskarten, die ein Agent vorschlagen kann, zeichnet nur die Konsole.
## Eine Begrüßung für ein neues Mitglied { #a-welcome-for-a-new-member }
Tritt jemand der Organisation bei - über eine Einladung, über den Beitritt zur
Standardorganisation oder über die Verzeichnissynchronisation -, sucht jeder
aktive Slack-, Mattermost- und Teams-Bot der Organisation die Person über ihre
E-Mail-Adresse und schickt ihr, wo er sie findet, eine Direktnachricht in der
Sprache ihrer Konsole: wer er ist, dass sie ihn gleich dort fragen kann, und die
Adresse der Konsole. Es ist ein Hintergrundjob mit einem Versuch; ein Bot, der sie
nicht findet, oder eine Plattform, die ablehnt, kostet sonst nichts. Slack braucht
für die Suche den Scope `users:read.email`; Teams findet sie im Verzeichnis des
Tenants.
## Reaktionen und Bewertungen { #reactions-and-ratings }
Zwei Dinge, die jede Chat-Plattform hier teilt, beide in den Einstellungen des
Bots schaltbar:
- **Eine Reaktion auf die Frage.** Der Bot reagiert, sobald eine Frage eintrifft,
die er beantworten wird, mit dem Emoji aus seinen Einstellungen - etwa `eyes` -,
damit die fragende Person sieht, dass sie gehört wurde. Slack und Mattermost
nehmen den Namen jedes Emojis; Telegram erlaubt eine feste Auswahl, und die Namen,
die es annimmt, sind `eyes`, `+1`, `ok_hand`, `fire`, `thinking_face`,
`writing_hand`, `zap` und `hourglass`.
- **Daumen unter einer Antwort.** Eine fertige Antwort trägt Daumen hoch und
runter. Ein Druck ist die Bewertung der Antwort dieses Runs durch die drückende
Person, dieselbe Bewertung, die die Konsole speichert, und erneutes Drücken
ändert sie. Ein Daumen runter fragt dann **was war falsch?** in einem Slack-Modal
oder Mattermost-Dialog; die Antwort wird als Kommentar der Bewertung gespeichert.
Telegram nimmt die Daumen ohne die Frage.
**Bewertungen in Mattermost benötigen kein AgenticOS-Konto.** Die vom Server
signierte Teilnehmeridentität ist der Autor. Jeder kann seine Stimme ändern;
die Schaltflächen bleiben für andere verfügbar, die Bestätigung ist privat.
Ein Kommentar zur negativen Bewertung gehört demselben Teilnehmer. Slack und
Telegram verlangen weiterhin eine verknüpfte Mitgliedschaft; auch die Freigabe
eines Werkzeugaufrufs erfordert sie. Kanalbewertungen liefern `user_id: null`
in der API statt einer Zuordnung zum Bot-Eigentümer. Aktualisiere die Datenbank
vor dem Start dieser Version. Ein Downgrade entfernt Kanalbewertungen und
behält Kontobewertungen.
Die Dashboard-Karte *Answer quality* zählt diese Stimmen und trennt sie nach
Oberfläche, damit Unterschiede zwischen Konsole und Mattermost sichtbar bleiben.
## Was jeder Kanal gemeinsam hat { #what-every-channel-shares }
- **Eine Nachricht, die gesendet wird, während der Agent arbeitet, ergänzt seine
Aufgabe.** Von der Person, deren Zug es ist, erreicht eine normale Nachricht im
selben Chat den laufenden Zug beim nächsten Schritt, statt auf einen eigenen zu
warten; eine, die nach dem letzten Schritt des Agents ankommt, wird als nächste
Nachricht beantwortet. `/btw` mit einer Frage fragt nebenbei: sofort beantwortet
aus dem bisherigen Thread, ohne Tools, während der Zug weiterläuft. Die Nachricht
eines Kollegen in einem Gruppenchat ist immer ein eigener Zug.
- **Das Modell wird an die jüngsten Runden erinnert, nicht an die ersten.** Ein
Kanal-Thread ist auf den Chat geschlüsselt und rollt nie über, also läuft ein
Support-Channel in Tagen am Fenster vorbei. Zweihundert Runden für einen Kanal
gegen vierzig für ein Widget, und die beiden Zahlen sind mit Absicht
verschieden: ein Widget ist eine öffentliche URL mit dem Budget eines anderen
dahinter, und ein Kanal ist ein Raum, in dem die eigenen Kollegen des Betreibers
arbeiten. So oder so begrenzt, denn ein Prompt ist kein Transkript, und die
ganze Historie eines Threads ist eine Rechnung pro Runde, die ewig wächst.
Bis #638 wurde vom falschen Ende gelesen: das Repository sortiert
Ältestes zuerst, also wurde dem Bot gesagt, wie die Unterhaltung begann, und
nichts von dem, was seither gesagt wurde — und er antwortete plausibel, aus
einer Fassung des Threads, die Hunderte Runden zuvor aufgehört hatte. Nichts
erzeugte einen Fehler, weshalb es einen Test brauchte statt einer Korrektur.
Der Offset ist jetzt `conversation_repo.get_recent_messages`, und jede
Oberfläche liest das Fenster darüber — das Widget, die Kanäle und der
Web-Chat. Drei Kopien eines `COUNT` und eines Offsets sind der Grund, warum
es zweimal falsch war, von entgegengesetzten Enden.
- **Ein Thread, in den er mittendrin geholt wird, wird zuerst gelesen.** Eine
Unterhaltung hier wird aus dem gebaut, was dieses Deployment *empfangen* hat,
also hielt ein Bot, der in einem schon laufenden Thread erwähnt wurde, nichts
von oberhalb der Erwähnung — und antwortete, als wäre der Thread leer, was er
selbstbewusst sagte. Niemand, der einen Chat beobachtet, kann einen Agent, der
nicht sehen kann, von einem unterscheiden, der gelesen und widersprochen hat.
Einmal pro Unterhaltung — auf der Session vermerkt, nicht aus ihrem Alter
gefolgert, sodass ein Thread, in dem der Bot schon antwortete, bevor es das
gab, bei seiner nächsten Runde gelesen wird statt nie — bis zu fünfzig
Nachrichten, und nur dort, wo die Plattform Threads hat. Danach gehört das
Transkript uns, und es wird nichts mehr geholt. Es kommt als ein
beschrifteter Block an — *was gesagt wurde, bevor du ankamst,
`speaker: message`, Kontext statt Anweisungen* — und nicht als Runden, denn
die Nachrichten anderer Leute als abwechselnde Historie wiederzugeben legt
dem Agent Worte in den Mund, die er nie gesagt hat.
**Das ist nicht freigabepflichtig, wo `read_channel_history` es ist**, und der
Unterschied ist der Punkt: dieses Tool liest den *Channel*, also Inhalte, in
denen der Agent nie angesprochen wurde, und ein Betreiber entscheidet pro
Bindung, ob es das darf. Der Thread, in dem der Bot angesprochen wurde, ist
die Unterhaltung, auf die jemand ihn gerichtet hat, und die zu lesen ist nicht
derselbe Akt wie den Raum zu lesen. Der Bot sieht weiterhin nur, was seine
eigene Mitgliedschaft erlaubt, denn der Aufruf geht über sein Token.
**Die darin geposteten Dateien kommen mit**, bis zu vier davon: das Transkript
allein ließ den Bot zutreffend antworten, er könne in einer Unterhaltung, deren
erste Nachricht ein Screenshot war, kein Bild sehen.
Sie werden auf demselben Weg geholt wie ein Anhang an der Live-Nachricht,
sodass ein Satz Größenlimits gilt, und sie werden dem zugeordnet, der den Bot
erwähnt hat — das ist die Person, die ihn auf den Thread gerichtet hat, und
ein nicht verknüpfter Absender bekommt keine, genau wie er keine seiner
eigenen bekommt. Vier statt fünfzig, denn fünfzig Downloads, bevor eine
Antwort beginnt, sind eine Minute Stille. Slack liest sie mit
`conversations.replies`, Mattermost mit `GET /posts/{root}/thread`; Telegram
hat keine Threads und liest keine.
Ein fehlgeschlagenes Lesen kostet den Kontext und nie die Antwort: kein
Thread, eine Plattform ohne Threads, eine Ablehnung oder ein Fehler erzeugen
alle eine Antwort ohne die Historie darüber, was schlechter ist als eine mit
und besser als keine.
- **Ein Bot antwortet als ein Agent.** Ein Bot-Nutzer ist eine einzige Identität
im Chat: derselbe Avatar, derselbe Name, welcher Agent die Antwort auch erzeugt
hat. Also bedient ein Bot genau einen Agent, und einen zweiten zu binden wird
abgelehnt — in der Auswahl des Builders, die keinen Bot anbietet, auf dem schon
der Agent von jemand anderem sitzt, und in der Datenbank, die es wahr macht.
Ein Agent geht den anderen Weg frei: ein Agent kann auf einem Slack-Bot, einem
Telegram-Bot und zwei Mattermost-Servern gleichzeitig antworten, und jede
dieser Bindungen trägt ihre eigenen Instruktionen, ihre eigenen
Channel-Abfragen und ihren eigenen Workspace-Scope.
Das hat das Routing mehrerer Agents hinter einem Bot mit `@slug` ersetzt. Es
funktionierte und las sich schlecht: jemand in einem Channel musste ein Handle
tippen, um zwischen Agents zu wählen, die er nicht sehen konnte, und eine
Nachricht, die keinen nannte, wurde mit einer Liste von Handles beantwortet
statt mit einer Antwort. Ein zweiter Bot kostet einen Betreiber zwei Minuten
und lässt den Chat sagen, mit welchem Agent er spricht, was kein Routing kann.
`@slug` wird weiterhin geparst — als Alias für den Agent hinter diesem Bot,
abgelehnt, wenn es irgendeinen anderen nennt.
- **Eine Sprachnachricht wird transkribiert, außer der Bot ist ausgeschaltet.**
Sie ist der eine Anhang, den man einem Agent nicht als Datei übergeben kann: er
liest ein PDF und sieht sich einen Screenshot an, und ein `audio/ogg`-Blob ist
eine Byte-Zahl.
Die Aufnahme wird geholt, transkribiert und **in dieselbe Runde eingewoben**,
als beschriftetes Zitat — `[Voice message, transcribed]` — statt als zweite
Nachricht geschickt zu werden. Beschriftet mit Absicht: Spracherkennung
verhört sich bei Namen, Zahlen und allem, was über Verkehrslärm gesagt wird,
also kann ein Agent, dem die Quelle genannt wurde, eine halb gehörte Zahl
einschränken und nachfragen, wo einer, dem nichts gesagt wurde, sie als
Tatsache angibt. Es ist auch das, was eine Aufnahme ein Zitat bleiben lässt
statt Anweisungen, die jemand getippt hat. Eine Sprachnotiz mit
Bildunterschrift bleibt eine Nachricht, denn das ist es, was jemand geschickt
hat.
Das Formular jedes Bots unter **Channels** bietet drei Antworten. **Inherit**,
die Voreinstellung, nimmt Provider und Modell des Agents, den der Bot bedient,
dann die der Organisation, und die Sprache des Agents, dann die der
Organisation ([die Reihenfolge](ml-services.md#which-engine-a-recording-reaches)).
**Choose** nennt das eigene Paar des Bots - einen Provider und eines seiner
Modelle, aus `app/core/catalog/speech_to_text_models.json` - und den
Vault-Schlüssel, mit dem es gesendet wird. **Off** transkribiert nie, was
immer der Agent oder die Organisation sagen. Neben der Auswahl nennt
Inherit, worauf es sich auflöst, welche der beiden Stellen es gewählt hat und
ob diese Ebene noch einen Schlüssel braucht.
**Das eigene Paar eines Bots braucht einen eigenen Schlüssel**: einen
API-Schlüssel, dessen Zweck der Provider ist, und zwar einen, der **für die
ganze Organisation sichtbar** ist, denn die Sprachnachrichten eines Bots
werden für jeden bezahlt, der ihm schreibt. Der private Schlüssel eines
Mitglieds wird beim Speichern des Bots abgelehnt, und einer, der später
privat wird, wird nicht mehr benutzt. Die Auswahl bietet nur passende
Schlüssel an, sagt, wenn ein Provider keinen hat, und verlinkt auf den Vault,
um einen anzulegen. Ein erbender Bot nimmt den Schlüssel der geerbten Ebene
zusammen mit deren Paar, nie einen eigenen. Wird der Schlüssel gelöscht,
fragt das Paar nach einem neuen, und der Bot transkribiert nichts, bis jemand
einen wählt.
Erben kostet nichts, bis jemand einen Provider einrichtet: ist weder auf dem
Agent noch auf der Organisation einer gesetzt, wird nichts transkribiert, und
das Provider-Guthaben der Organisation auszugeben bleibt die Entscheidung der
Organisation. Ein Bot, der über die CLI oder von einem Client ohne Schalter
registriert wird, erbt.
**Eine Sprachnachricht ist an das längste Diktat der Organisation gebunden**,
bevor irgendein Provider bezahlt wird: Ihre Länge wird aus der Datei gelesen,
wo der Container es sagt (Ogg, WebM, MP4, WAV, MP3), sonst so, wie die
Plattform sie gemeldet hat. Eine längere Nachricht wird mit einer Zeile
abgelehnt, die das sagt, ebenso eine, deren Länge keiner von beiden angibt -
etwa ein MP3 aus Mattermost mit mehr als einem Tag nach dem letzten Frame.
Bytes allein begrenzen sie nicht - 25 MB Audio mit niedriger Bitrate sind
Stunden -, und das Limit des Absenders zählt Nachrichten, keine Minuten. Die
Nutzung verbucht die Länge des Providers, sonst die hier gemessene.
Ein Bot, der nicht zuhören kann, sagt das, statt die Aufnahme fallen zu
lassen, denn eine Sprachnotiz, die keine Reaktion erzeugt, ist von einem
kaputten Bot nicht zu unterscheiden - und er sagt, aus welchem Grund, weil
jeweils jemand anderes ihn behebt: der Bot ist ausgeschaltet, oder niemand hat
einen Provider eingerichtet. Ein Bot ohne gebundenen Agent, oder dessen Agent
archiviert ist oder keine Version zum Ausführen hat, transkribiert gar
nichts, da die Nachricht ohnehin abgelehnt wird.
Jede an den Provider geschickte Aufnahme hinterlässt einen
[Nutzungsdatensatz](ml-services.md#what-is-recorded), der den Bot, seinen
Agent und die Person nennt, mit der das Konto des Absenders verknüpft ist -
solange sie noch Mitglied der Organisation ist -, mit der Länge der Aufnahme,
wo Telegram, Slack oder der Provider sie angibt.
!!! note "Upgrade: ein Bot, der nicht transkribiert hat, tut es weiterhin nicht"
Bevor es diesen Schalter gab, war ein Bot ohne Paar ein Bot, der nicht
transkribierte, und die API leerte das Paar, um die Transkription
abzuschalten. Die Migration `0151_bot_transcription_switch.py` schaltet
jeden solchen Bot auf **off**, sodass keiner von ihnen beim Upgrade
anfängt, Audio an einen Provider zu schicken; ein Bot mit Paar behält es.
Von da an bekommt ein Client, der das Paar leert, **inherit**, und
**off** ist `transcribe_voice_notes: false`, was das Paar ebenfalls
leert. Die Sprache der Organisation oder des Agents erreicht jetzt auch
einen Bot mit eigenem Paar.
Ein Bot mit Paar transkribierte bisher auf dem ersten Modellprofil nach
Bezeichnung, das einen Schlüssel für seinen Provider hielt. Dieselbe
Migration schreibt diesen Schlüssel als den eigenen des Bots fest, sofern
er ein organisationsweit sichtbarer API-Schlüssel für den Provider ist und
das Profil keine eigene `base_url` hat. Ein Bot ohne solches Profil
behält sein Paar ohne Schlüssel: Sein Formular sagt „Schlüssel wählen“,
und er transkribiert nichts, bis jemand das tut.
Was als Sprachnachricht zählt, wird am Typ der Datei abgelesen, und jede
Plattform typisiert ihre Dateien anders. Eine Telegram-Sprachnotiz oder
-Audiodatei zählt immer. In Slack zählt ein im Eingabefeld aufgenommener
Audioclip, welchen Typ Slack ihm auch gibt - ein Clip aus der Handy-App kann
als `video/mp4` ankommen -, und ebenso jede hochgeladene Audiodatei. Eine
Datei, der die Plattform keinen Typ gegeben hat, zählt, wenn ihre Endung
Audio bezeichnet: `.m4a`, `.mp3`, `.ogg`, `.oga`, `.opus`, `.webm` oder
`.wav`. Eine untypisierte `.mp4`, die Slack nicht als Clip markiert hat,
bleibt eine gewöhnliche Datei: Sie ist weit öfter ein Video, und sie zu
transkribieren hieße, eine Tonspur zu bezahlen.
In Mattermost zählt jede hochgeladene Audiodatei, nach Typ oder Endung. Ein
Outgoing Webhook schickt von jeder Datei nur die ID, also schlägt der Router
für einen Bot im Webhook-Modus die Datei zuerst mit
`GET /api/v4/files/{id}/info` nach; schlägt das fehl, bleibt sie ein
gewöhnlicher Anhang. Ein Slack-Clip, der ankommt, bevor Slack ihn fertig
verarbeitet hat, hat noch keine Download-URL und wird übersprungen, da der
Bot das darauf folgende Ereignis `file_shared` nicht abonniert. Das Backend
protokolliert beides und jede an die Transkription geleitete Aufnahme mit
Typ und Dateinamen - die Zeile, die man liest, wenn eine nicht gehört wurde.
Der Provider erhält die Aufnahme als `voice.<ext>`, benannt nach ihrem Typ
statt danach, wie jemand die Datei genannt hat: OpenAI und Groq wählen den
Decoder nach dem Namen und lehnen `.opus` oder einen Namen ohne Endung für
Audio ab, das sie lesen könnten. Eine Telegram-Sprachnotiz ist Ogg/Opus und
ein in der Desktop-App von Slack aufgenommener Clip WebM, und Mistral führt
keines davon als akzeptiertes Format auf, also kann ein Telegram- oder
Slack-Bot, der Mistral erreicht - gewählt oder geerbt -, beim Transkribieren
scheitern; das Formular sagt es.
Sprachnachrichten, die in einem Thread gepostet wurden, bevor der Bot ihm
beitrat, werden nicht transkribiert. Die erste Erwähnung liest die früheren
Dateien des Threads, und jede Aufnahme darunter zu transkribieren hieße, das
Audio eines ganzen Threads für eine Frage abzurechnen. Jede wird stattdessen
in der Antwort genannt: „Voice messages sent before I joined this thread are
not transcribed - send it again here."
Drei Provider sind dabei: OpenAI, Groq und Mistral, die alle OpenAIs
`POST /audio/transcriptions` bedienen. Ein Modell hinzuzufügen ist ein Eintrag
in dieser Datei. Jeder Fehlschlag — keine Zugangsdaten, eine Aufnahme über dem
Limit des Endpunkts, eine Ablehnung, ein Timeout — wird in der Antwort
gemeldet, und die Runde läuft ohne ihn weiter.
- **Zugriffsrichtlinie pro Bot** — offen, Whitelist, nur Gruppe oder
`jwt_linked`: „muss mit einem Mitglied verknüpft sein", in einem Channel genauso
wie in einer Direktnachricht, ohne eine zweite Einstellung zum Umlegen.
- **Ein Zugangsmittel kann nach der Registrierung ergänzt oder ersetzt werden.**
Der Stift in der Zeile eines Bots öffnet das: benennen Sie ihn um, fügen Sie ein
rotiertes Token ein, oder liefern Sie das Zugangsmittel nach, das bei der
Registrierung nicht zur Hand war — was bei Slack der Normalfall ist, dessen
`xapp-`-Token Minuten später auf einem anderen Bildschirm erzeugt wird.
Jedes Zugangsmittel hier ist im Ruhezustand versiegelt und wird **nie
zurückgelesen**, also beginnt jedes Feld leer, und ein leeres Feld bedeutet
*behalte, was gespeichert ist*, und nicht *lösche es*. Nur was jemand getippt
hat, wird gesendet. Was der Dialog nicht anbietet, ist die Plattform, denn sie
entscheidet, welche Zugangsdaten die Zeile trägt und wie Nachrichten sie
erreichen, und der Transport, denn in den Webhook-Modus zu wechseln ist nur
ein halber Zug — der Webhook muss immer noch bei der Plattform registriert
werden, und ein Bot, der `webhook` meldet, ohne einen zu haben, beantwortet
nichts. `channel-webhook-register` erledigt beide Hälften.
- **Verknüpfung, und wo sie erforderlich ist** — jeder Run gehört jemandem: das
Budget, das er ausgibt, was er lesen darf und der Audit-Eintrag, den er
schreibt, werden alle zugeordnet. Woher dieses *jemand* kommt, hängt davon ab,
ob der Bot privat angesprochen wird oder in einem Raum steht.
**Eine Direktnachricht fragt nach einem Konto.** Sie ist eine Unterhaltung mit
einer Person, also wird ein nicht verknüpftes Chat-Konto abgelehnt, bis es
eines benennt.
**Ein Channel beantwortet jeden darin.** Wer immer den Bot einladen konnte,
hat das Publikum gewählt, also wird ein Absender ohne verknüpftes Konto nicht
abgelehnt: die Runde läuft unter der *Bindung*, die sie eingelassen hat — der
Rolle desjenigen, der den Agent an diesen Bot gebunden hat, herabgestuft auf
`viewer`, wenn er die Organisation inzwischen verlassen hat —, und das
Chat-Konto, das sie getippt hat, wird auf dem Run vermerkt. Was das weitet,
ist real und es lohnt sich, es zu sagen: jeder, der in dem Channel sprechen
kann, kann das Budget der Organisation ausgeben und erreichen, was der Ersteller
der Bindung erreichen kann, was derselbe Handel ist, den ein öffentliches
Widget macht. Die Obergrenzen sind das Rate Limit pro Chat-Konto, die
Zugriffsrichtlinie und das monatliche Cap der Organisation. Das Rate Limit
(`rate_limit_rpm` in der Zugriffsrichtlinie des Bots, standardmäßig zehn pro
Minute) wird im gemeinsamen Redis des Deployments gezählt, sodass es über
API-Worker hinweg gilt statt einmal pro Worker.
Setzen Sie **`require_link`** in der Zugriffsrichtlinie des Bots, um auch in
Channels abzulehnen, was das alte Verhalten ist. Der Modus **`jwt_linked`**
lehnt von sich aus ab: ein Modus, der nach einem verknüpften Konto benannt
ist, verlangt eines, und früher entschied er nichts, solange nicht auch
`require_link` gesetzt war.
Verknüpfung ist auch in einem Channel wichtig, und es lohnt sich: ein
verknüpfter Absender läuft als *er selbst* statt unter der Bindung, und später
zu verknüpfen macht seine früheren Channel-Runden ihm zuordenbar — der Run
zeigt auf das Chat-Konto, und das Chat-Konto gewinnt eine Person. Nur solange
diese Person ein Mitglied ist, das sich anmelden kann: ein verknüpfter
Absender, der gegangen ist oder dessen Konto deaktiviert wurde, wird wie ein
Fremder eingelassen — unter der Bindung in einem Channel, abgelehnt in einer
Direktnachricht.
Sie ist auch das, was einen Agent *ihre* Tools erreichen lässt. Eine Bindung an
[das jeweils eigene Konto einer Person](mcp.md#whose-account-a-binding-speaks-through)
spricht mit Notion oder Jira als derjenige, der die Nachricht geschrieben hat,
in einem Channel genauso wie in einer Direktnachricht — und ein nicht
verknüpfter Absender hat kein Konto, durch das gesprochen werden könnte, also
sagt ihm der Agent, er solle zuerst `/link` benutzen.
**Ein Channel-Thread ist eine Unterhaltung mit mehreren Leuten darin**, und er
erscheint in der Unterhaltungsliste von jedem, dessen verknüpftes Chat-Konto
darin geschrieben hat — nicht nur bei dem, der zuerst sprach, und nicht bei
niemandem, was ein Thread ohne verknüpften Sprecher früher erreichte. Jede
Runde vermerkt das Konto, das sie geschrieben hat, sodass ein Raum sich wie ein
Raum liest statt wie eine Person, die mit sich selbst spricht. Später zu
verknüpfen ist das, was den früheren Thread jemandem vor Augen bringt, ohne
Nachtrag: die Runde zeigt auf das Chat-Konto, und das Konto gewinnt eine
Person.
**Die `/chat`-Liste lässt Kanal-Threads standardmäßig weg.** `GET /conversations` nimmt `hide_channels` an, standardmäßig wahr, damit sich die Seitenleiste nicht mit Threads füllt, in denen ein Bot antwortet; sie öffnen sich weiter über ihren Link, und `hide_channels=false` listet sie wieder auf.
**Sprechen ist ein Anspruch; die Plattform entscheidet, ob er noch gilt.** Der
Runden-Eintrag sagt, wer *gesprochen* hat, und vor [#641][641] war das die
ganze Prüfung — jemand, der aus dem Channel entfernt wurde, las den Thread
weiter, einschließlich allem, was nach seinem Weggang gesagt wurde. Jetzt wird
jeder Teilnahmeanspruch gegen die aktuelle Mitgliedschaft der Plattform
bestätigt (`getChatMember` bei Telegram, `conversations.members` bei Slack, die
Mitglieds-Einzelabfrage bei Mattermost), bevor die Liste den Thread zeigt und
bevor er sich öffnet, hinter einem gemeinsamen Redis-Cache von etwa einer
Minute. Die Prüfung **schlägt geschlossen fehl**: eine Plattform, die nicht
antworten kann, ein Bot, der weg ist, und ein Thread, dessen Channel nichts
mehr benennt — `/new` richtet die Session auf eine frische Unterhaltung —
lehnen allesamt die Teilnahme ab, statt dem Anspruch zu trauen. Der Eigentümer
des Threads und jeder, mit dem er ausdrücklich geteilt wurde, behalten ihren
Zugriff unabhängig davon; die Mitgliedschaftsprüfung regelt die Teilnahme und
sonst nichts.
**Und sie öffnet einen Thread, statt einen zu besitzen.** In einem Raum zu
sprechen lässt Sie ihn lesen; ihn umzubenennen, zu archivieren, zu löschen
oder eine Runde daran anzuhängen bleibt beim Eigentümer des Threads und bei
jedem, mit dem er ausdrücklich geteilt wurde. Sonst könnte eine Person, die in
einem Channel „danke" gesagt hat, das ganze Transkript des Raums löschen oder
eine Runde als der Agent schreiben, die alle lesen und die dem Modell in der
nächsten Runde als seine eigenen Worte zurückgegeben wird.
Ein Thread, dessen erster Sprecher nie ein Konto verknüpft hat, hat keinen
Eigentümer, und dort *sind* die Teilnehmer diejenigen, die ihn ändern dürfen —
dieselbe Menge, die ihn öffnen darf. Es gibt niemanden, dem das Schreiben
weggenommen würde, und die Alternative war die ganze Organisation: jedes
Mitglied konnte ein Transkript löschen, dessen Listeneintrag es nie gesehen
hatte ([#701][701]). Das Schreiben stützt sich auf dieselbe bestätigte
Teilnahme wie das Lesen: ein Anspruch, den die Plattform nicht mehr deckt,
trägt keines von beidem ([#641][641]).
[701]: https://github.com/vstorm-co/agenticos/issues/701
[641]: https://github.com/vstorm-co/agenticos/issues/641
Die Ablehnung trägt den Ausweg mit sich. Schreiben Sie dem Bot, und er
antwortet mit einer URL; öffnen Sie sie, und das Dashboard — wo Sie schon
angemeldet sind — benennt das Chat-Konto und bittet Sie zu bestätigen. Es wird
nichts getippt und kein Code kopiert. Fragen Sie jederzeit erneut, indem Sie
dem Bot `link` senden (oder `/link`, wo die Plattform einen Slash-Befehl
liefert; Mattermost tut das nicht).
Was verbunden ist und wie man es trennt, steht unter **Settings → Profile →
Chat accounts**. Das Trennen löscht den Eigentümer und behält die Zeile,
sodass die Unterhaltungen, die daran hängen, überleben - die Person schreibt
dem Bot danach weiterhin vom selben Konto aus.
**Nur in einer Direktnachricht.** Die URL ist ein Inhaber-Zugangsmittel: wer
sie öffnet, beansprucht dieses Chat-Konto. In einem Channel sagt der Bot, man
solle ihm stattdessen direkt schreiben, und prägt nichts. Ein Link hält
fünfzehn Minuten, gilt einmal, und erneut zu fragen zieht den vorigen zurück.
- **Eine Antwort, der Sie beim Entstehen zusehen können.** Der Bot postet eine
Nachricht in dem Moment, in dem Ihre Frage ankommt, und schreibt sie um, während
die Antwort erscheint — einschließlich dessen, was er unterdessen tut („Sucht im
Web…", „Zeichnet ein Diagramm…"), also genau dann, wenn die Stille früher am
längsten war, denn ein Tool-Aufruf erzeugt keinen Text, während er läuft.
Ungefähr einmal pro Sekunde bearbeitet: pro Token wären es Hunderte Schreibvorgänge
pro Sekunde gegen einen Server, der oft jemandem selbst gehört. Eine Plattform,
die eine gesendete Nachricht nicht bearbeiten kann, bekommt einfach die fertige
Antwort, wie zuvor. Bei einem Agent mit einem
[Ausgabe-Guardrail](reference/capabilities.md#guardrails) wird die Antwort
stattdessen Schritt für Schritt umgeschrieben, und jeder Schritt wird erst gepostet, nachdem
er geprüft wurde.
- **Jede Bindung trägt ihre eigenen zusätzlichen Instruktionen**, die nur auf
dieser Oberfläche hinzugefügt werden. Aktuelle Formatierungsvorgaben kommen
aus dem Deployment: Slack schreibt Links als `<url|text>`, Mattermost rendert
Überschriften und Tabellen, und Telegram weist ungeschlossene Formatierung
zurück. Eine neue Bindung speichert keine Kopie dieser Vorgaben. Eigene
Anweisungen lassen sich ergänzen, bearbeiten oder leeren, ohne Plattformregeln
auf dem Stand des Installationstags einzufrieren. Sie gestalten die Ausgabe;
der Zweck des Agents gehört zu seiner veröffentlichten Version.
- **Ein Bot antwortet, sobald er registriert ist.** Ein Polling-Bot -
Telegram-Long-Polling, Slack Socket Mode, ein Mattermost-Event-Stream - wird
über eine Verbindung erreicht, die der API-Prozess hält, und diese Verbindung
wird geöffnet, wenn die Zeile geschrieben wird, und nicht beim nächsten Neustart.
Pausieren, Löschen, das Ändern des Tokens oder der Serveradresse und das
Umschalten zwischen Polling und Webhooks wirken alle sofort, aus demselben
Grund: der Stream wird neu geöffnet, passend zu dem, was die Zeile jetzt sagt.
Er wird geöffnet, *nachdem* die Transaktion committet, sodass eine
fehlgeschlagene Registrierung keine Verbindung zurücklässt.
- **Eine Verbindung pro Bot, egal wie viele API-Worker.** Telegram erlaubt ein
`getUpdates` pro Bot, und zwei Mattermost-Sockets bekämen jedes Ereignis
doppelt. Darum hält der Worker, der die Verbindung eines Bots hält, im
gemeinsamen Redis einen Lease darauf und erneuert ihn alle 10 Sekunden; die
anderen lassen diesen Bot in Ruhe. Ein Worker, der herunterfährt, übergibt
seine Bots innerhalb von etwa 10 Sekunden an einen anderen, einer, der
abstürzt, innerhalb von etwa 40. Eine Änderung, die ein anderer Worker
bedient, öffnet die neue Verbindung sofort, und die alte schließt sich bei der
nächsten Erneuerung ihres Halters. Ist Redis nicht erreichbar, verbindet sich
wie bisher jeder Worker.
- **Ein zweiter Telegram-Poller wird bemerkt, nicht bekämpft.** Beendet Telegram
den Poll eines Bots, weil ein anderer Prozess denselben Token abfragt, schließt
ein Worker, der den Lease des Bots nicht hält, seine Kopie sofort. Der Halter
fragt weiter ab und markiert den Bot auf der Kanalseite als gestört: „ein
anderer Prozess fragt den Token dieses Bots ab“. Dieser Prozess liegt außerhalb
des Deployments, meist ein zweites Deployment oder ein Skript mit demselben
Token, und nur sein Besitzer kann ihn stoppen.
- **Channel-Kontext wird bei jedem Run automatisch ergänzt.** Runs aus Mattermost, Slack, Teams und Telegram erhalten Name, Zweck, Thema, Sichtbarkeit, gemeldete Mitgliederzahl und eine begrenzte sichtbare Mitgliederliste, auch bei bestehenden oder geleerten Bindungs-Prompts. `{channel_members}` benennt die Liste; `{member_list}` bleibt ein Alias. Weitere Platzhalter sind `{channel_name}`, `{channel_purpose}`, `{channel_topic}`, `{channel_visibility}` und `{member_count}`.
Jede Quelle wird einmal pro Run abgefragt, ohne Cache zwischen Runden: höchstens 50 sichtbare Mitglieder und 2000 Zeichen pro Wert. Telegram liefert Administratoren, nicht alle Mitglieder. Jede Quelle hat fünf Sekunden Zeit; nicht verfügbare Werte bleiben unbekannt. Werte werden abgeflacht und als nicht vertrauenswürdige Gesprächsdaten außerhalb der Systemanweisungen übergeben. Slack liefert Mitglieder-IDs ohne einzelne Profilabfragen. Der editierbare Bindungstext ergänzt diesen Laufzeitkontext.
- **Wählen Sie, ob Gruppen-Erwähnungen einen Run starten.** *Auf Gruppen-Erwähnungen antworten* ist beim Erstellen und Bearbeiten eines Bots standardmäßig aus. Direkte Aufrufe funktionieren weiterhin. Eingeschaltet reagiert der Bot auch auf Mattermost `@all`, `@here` und `@channel`, native Slack-Gruppen-Erwähnungen und strukturierte Teams-Kanal- oder Team-Erwähnungen. Identität, Zugriff und Ratenlimits gelten weiterhin. Codebeispiele und Erwähnungen anderer Personen reichen nicht aus.
Teams benötigt das aktualisierte App-Paket im Zielteam oder Chat und die Zustimmung des Besitzers zu `ChannelMessage.Read.Group` und `ChatMessage.Read.Chat`. Das Paket fordert sie nur bei eingeschaltetem Schalter an. Telegram verwendet die Text-Aliase `@all`, `@here`, `@channel` und `@everyone`; diese benachrichtigen nicht die Gruppe. Der Bot muss Gruppennachrichten erhalten; prüfen Sie den Datenschutzmodus in BotFather. Eigene Erwähnungen, Befehle und Antworten werden anhand der tatsächlichen Telegram-Bot-Identität geprüft. Discord zählt `@everyone` und `@here` nur, wenn der Absender sie verwenden darf, also wenn Discord den Raum tatsächlich benachrichtigt.
- **Auch der angeforderte Workspace-Dateiversand benötigt eine Genehmigung.** Die Freigabe von `send_channel_file` stellt das Tool bereit; das Posten der Datei wartet weiterhin auf eine ausdrückliche Bestätigung.
- **MCP-Lesezugriffe verknüpfter Absender folgen der Genehmigungsrichtlinie der Bindung; Schreibzugriffe benötigen weiterhin eine Genehmigung.** Reine Lesewerkzeuge fragen nicht allein deshalb nach, weil der Lauf in einer Chat-App stattfindet. Werkzeuge ohne `readOnlyHint: true` fragen weiterhin, auch eines, das im Builder für sich ohne Rückfrage laufen darf; eine ausdrückliche `all`-Richtlinie umfasst auch Lesezugriffe. Dies gilt für persönliche, Organisations- und Plattformverbindungen, Delegierte und fortgesetzte Aufrufe. Persönliche Verbindungen gehören weiterhin dem aktuellen Absender, niemals dem Herausgeber oder anderen Teilnehmern.
Kanalnachrichten speichern nur den Text des Absenders. Autorenbezeichnungen werden unmittelbar vor der Modellanfrage aus Kanalidentitäten aufgelöst, außerhalb der Systemanweisungen. Konsole, Kopieren, Anheften und Kurzbefehle erhalten den reinen Text. Gespeicherte Verläufe und pausierte Runs behalten opake Identitätsreferenzen; Manifeste lassen die eingefügten Kanalreferenzteile aus. Die Kontolöschung entfernt dessen Kanalidentitäten, sodass spätere Anfragen die Plattformbezeichnungen der Person nicht mehr auflösen können. Geteilte Nachrichtentexte bleiben erhalten.
Editierbare Kanalnamen, Themen, Beschreibungen und Autorenbezeichnungen durchlaufen vor dem Modell die konfigurierten Eingabeprüfungen. Bereinigte Werte ersetzen die Originale; blockierte Referenzwerte werden zurückgehalten, während eine erlaubte Frage fortgesetzt werden kann. Feste Metadatensyntax, Plattformkennungen und Chat-Typen werden nicht als Fragetext geprüft.
Autorenreferenzen werden getrennt von der aktuellen Nachricht übergeben, damit Eingabe-Guardrails die eigentliche Frage prüfen. Nicht verknüpfte Kanalteilnehmer benötigen weiterhin eine Genehmigung für jeden MCP-Aufruf, auch für Organisationslesezugriffe.
Sowohl ausdrückliche `@slug`-Aufrufe als auch Standardantworten erhalten den gespeicherten Gesprächsverlauf zusammen mit dem anfänglichen Kontext des Kanal-Threads.
Veröffentlichte Apps lassen sich aus einer anderen Unterhaltung ohne die ursprüngliche Sandbox ändern. `read_artifact` liest den gespeicherten Quelltext; `publish_artifact` mit demselben Namen und Änderungen oder neuem Inhalt aktualisiert die App. Link und Versionshistorie bleiben erhalten. Lesezugriff erteilt keine Bearbeitungsrechte; die bestehenden Berechtigungsprüfungen gelten weiterhin.
- **Der Agent kennt den Ort und das verfügbare Konto.** Laufzeitinstruktionen nennen Plattform und Deployment-Konsole, erklären den verwalteten Workspace und unterscheiden organisatorische MCP-Verbindungen von persönlichen Diensten. Persönliche Dienste benötigen eine verknüpfte Absenderidentität und deren autorisierte Dienstverbindung. Die Verknüpfung beginnt mit `link` ohne Schrägstrich in einer Direktnachricht; der private Verbindungslink gehört nicht in einen Channel. Der Agent nennt tatsächliche Tool-Ergebnisse und einen konkreten nächsten Schritt bei fehlender Anmeldung oder Funktion.
- **Eine erneut zugestellte Nachricht wird einmal beantwortet.** Jede Plattform
liefert mindestens einmal: die Webhook-Routen antworten mit 200, bevor
irgendetwas getan wird, sodass ein langsamer Handler nie eine Wiederholung
auslöst, aber ein 200, der auf der Leitung verloren geht — ein Proxy verwirft
ihn, der Pod startet neu —, wurde nie empfangen, und die Wiederzustellung, die
folgt, ist eine gültige, signierte, brandneue Anfrage mit derselben Nachricht.
Die erste Zustellung beansprucht die Nachricht in Redis (ein atomares `SET NX`,
geschlüsselt darauf, wie die Plattform die Nachricht nennt, innerhalb ihres
Chats) an der Stelle, an der sich jeder eingehende Pfad kreuzt — die drei
Webhook-Routen und die drei Polling-Streams gleichermaßen —, sodass die
Wiederholung quittiert und verworfen wird, welcher API-Worker sie auch empfängt.
Ein Anspruch hält fünfzehn Minuten, was das Wiederholungsfenster jeder Plattform
überdauert.
Der Anspruch wird beim Empfang genommen, sodass ein Run, den der Prozess nicht
beenden konnte, ihn zurückgibt: eine Wiederzustellung nach einem abgebrochenen
Run, oder eine, die ein neu startender Pod verworfen hat, wird beantwortet
statt für ein Duplikat gehalten. Das zählt am meisten für die Polling-Streams,
die eine Nachricht erneut lesen, an der der Prozess gestorben ist. Ein Fehler,
den der Router selbst abfängt, ist das nicht: er entschuldigt sich einmal beim
Absender und behält den Anspruch, sodass die Wiederzustellung der Plattform
keinen Fehlschlag erneut ausführt, der nur wieder scheitern würde.
Die Garantie degradiert offen, nie geschlossen. Eine Nachricht, die ohne
Plattform-Nachrichten-ID ankommt, und ein Redis, das nicht erreicht werden
kann, werden beide verarbeitet statt abgelehnt — eine doppelte Antwort ist der
seltenere, billigere Fehlschlag als eine verlorene Frage — und jedes schreibt
eine Warnung, dass die Garantie für diese Zustellung aus war. Nichts wird
allein aufgrund des Wiederholungs-Headers einer Plattform abgelehnt: Slacks
`x-slack-retry-num` sagt, dass eine Wiederzustellung stattfindet, nicht, dass
der erste Versuch weit genug kam, um irgendetwas zu tun, und `reason=http_error`
bedeutet, dass er es ausdrücklich nicht tat. Der Header wird protokolliert; der
Anspruch entscheidet.
- **Rate Limits** pro Chat, auf dem Bot - wer mit ihm reden darf und wie oft,
gehört dem Betreiber, anders als alles darüber, das dem Autor des Agents gehört.
- **Diagramme werden als Bilder gerendert**, wo die Plattform das unterstützt, und
fallen auf eine Texttabelle zurück, wo sie es nicht tut.
- **Was eine Runde gekostet hat**, gesagt oder nur aufgezeichnet — siehe unten.
- **Dateien, in beide Richtungen** — siehe unten.
- **Wer sich einen Workspace teilt, pro Oberfläche.** Der Spec eines Agents setzt
den Standard; jede Bindung darf ihn überschreiben, denn ein Web-Chat und ein
Slack-Channel sind nicht dieselbe Frage des Teilens.
### Was jede Oberfläche aufzeichnet { #what-each-surface-records }
Jede Oberfläche erreicht denselben Runner, also bekommt jeder Run seine Zeile —
seine Kosten, seinen Status, seine Token und das Budget, das gegen ihn durchgesetzt
wurde. Er bekommt auch sein **Transkript**: die Frage, die Antwort und jeden
Tool-Aufruf mit den Argumenten, mit denen er gemacht wurde, und dem, was zurückkam.
Das zählt, weil die Detailansicht eines Runs aus diesen Zeilen gelesen wird — was
nichts geschrieben hat, kann keine Seite zeigen.
Für alles außer dem Web-Chat wird das Transkript vom Runner geschrieben, nicht von
der Oberfläche. Früher war es die Aufgabe der Oberfläche, und vier von ihnen taten
es nicht: das Widget, eine Erwähnung, die API und jeder wieder aufgenommene Run
zeichneten überhaupt nichts auf, sodass einer Organisation eine Antwort in Rechnung
gestellt wurde, ohne dass eine Zeile sagte, was gefragt worden war. Etwas, woran
jede Oberfläche denken muss, ist etwas, woran die nächste Oberfläche nicht denken
wird.
Der Web-Chat schreibt weiterhin sein eigenes, denn er hat Events anzuhängen und ein
Socket, auf dem er antwortet — und er schreibt bei beiden Enden. **Eine Runde, die
nicht zu Ende geht, wird so weit aufgezeichnet, wie sie kam**, aus demselben Text,
der dem Client gestreamt wurde, sodass gespeichert ist, was ihr Leser tatsächlich
gesehen hat.
| Oberfläche | Was `messages` und `tool_calls` erreicht |
|---|---|
| Web-Chat, Run beendet | Alles — Prompt, Reasoning, Tool-Argumente und -Ergebnisse, Modell und Version, und die Reihenfolge, in der all das geschah |
| Web-Chat, Run unterbrochen | Dasselbe, so weit er kam. Ein Run, der fehlgeschlagen ist, sein Budget erreicht hat, gestoppt wurde oder sein Socket verloren hat, behält die schon gestreamten Worte, zugeordnet zu der Version, die sie erzeugt hat, ohne dass eine Kostenzahl dafür erfunden wird — die Run-Zeile ist der Ort, an dem die Abrechnung lebt |
| Der Standard-Agent eines Kanal-Bots | Alles außer dem Reasoning, das nur ein gestreamter Run offenlegt |
| `@mention` in einem Channel | Dasselbe, mit dem Handle aus dem aufgezeichneten Prompt entfernt |
| Eingebettetes Widget | Dasselbe. Der Besucher ist anonym; der Run und die Runden gehören dem Eigentümer des Widgets |
| HTTP-API | Dasselbe, wenn der Aufruf eine `conversation_id` trägt. Nichts ohne eine — es gibt keinen Thread, in den eine Runde geschrieben werden könnte, und die Run-Zeile ist weiterhin der Beleg, dass es passiert ist |
| A2A | Dasselbe. Jede Aufgabe wird unter einem Gespräch abgelegt, einem neuen oder dem, das ihre `contextId` nennt |
| Ein nach einer Freigabe wieder aufgenommener Run | Seine Fortsetzung — die Antwort und die Aufrufe, die er gemacht hat, und die Aufrufe auch dann, wenn es keine Antwort gibt, was eine Fortsetzung hat, die bei einem zweiten freigabepflichtigen Aufruf erneut parkt. Keine Nutzerrunde: er setzt bei dem Aufruf an, bei dem er stehen blieb, und eine Frage zu erfinden hieße, jemandem Worte in den Mund zu legen |
**Aufgezeichnet wird, was die Person geschrieben hat, und nicht der Prompt, der
darum herum zusammengesetzt wurde.**
Jede Oberfläche baut etwas Größeres, bevor das Modell es sieht:
`AttachmentRouter` hängt eine Beschreibung zu jeder Datei an, und ein eingebettetes
Widget stellt die Platzierungsnotiz des Betreibers voran.
Das aufzuzeichnen setzte die eigene Beschreibung der Plattform als jemandes Worte
in das Transkript. Eine in Mattermost gepostete Datei las sich zurück als
`co tu widzisz`, gefolgt von
`--- Attached file: … (/uploads/…, 43 KB, image)`, und die Eröffnungsrunde jeder
Widget-Unterhaltung las sich wie ein Besucher, der die Seite aufsagt, auf der er
war.
**Die Datei selbst ist eine Zeile an dieser Runde**, und das ist es, was das
Dashboard als Karte rendert — genauso wie einen dort gemachten Upload.
Eines wird bewusst nicht aufgezeichnet: die **Zustellhinweise** einer
Kanal-Antwort — *diese Datei war zu groß zum Senden* — bleiben aus dem Transkript
heraus, denn sie handeln davon, was die Antwort nicht tragen konnte, und nicht
davon, was der Agent gesagt hat.
### Wie eine Runde im Web-Chat aussieht { #what-a-turn-looks-like-in-web-chat }
**Die Arbeit ist eine Erzählung, kein Stapel Karten.** Jeder Tool-Aufruf ist eine
Zeile — *Wrote test1.md*, *Searched for TODO in app.py*, *Ran pytest -q*,
*Linear · Create issue* —, geschrieben in der Zeitform, in der sie wahr ist:
Präsens, während der Aufruf läuft, Vergangenheit, sobald er
fertig ist. Die Zeile benennt das *Subjekt* statt der Funktion, denn `write_file`
ist nicht das, was irgendjemand lesen will. Jede Zeile öffnet sich zu dem, was der
Aufruf tatsächlich erzeugt hat, und die rohen Argumente und die rohe Ausgabe
bleiben einen Klick weiter drinnen für denjenigen, der einen davon debuggt.
Aufeinanderfolgende Aufrufe hängen an einer Leiste, und **nur die letzte Zeile
bleibt sichtbar**. Frühere falten sich zu „4 earlier steps" zusammen, was sagt,
dass Arbeit passiert ist, ohne die Antwort vom Bildschirm zu schieben.
Drei Arten von Runs werden nie gefaltet: einer, der einen Fehlschlag enthält,
einer, der einen zur Freigabe geparkten Aufruf enthält, und einer, der einen
Schritt enthält, dessen Ergebnis *die* Antwort ist — was heute ein Diagramm
bedeutet.
Die ersten beiden sind die Zeile in der Runde, die um etwas bittet. Die dritte ist
da, weil eine Runde, die drei Diagramme gezeichnet hatte, zwei davon wegfaltete,
und drei Diagramme sind drei Antworten statt einer mit zwei Fußnoten.
Welche Tools als diese Art zählen, ist `opensOnSight` in `lib/tool-catalog.ts`,
dieselbe Zeile, die der Schritt liest, um zu entscheiden, ob er sich selbst
öffnet, sodass die Leiste und der Schritt nicht widersprechen können. Nichts
markiert einen Schritt, der einfach funktioniert hat, also bedeutet eine Markierung
das, was sie sagt.
Sobald die Runde geantwortet hat, **faltet sich die Arbeit über der Antwort in eine
Zeile** — *Worked for 12s · 3 steps* —, hinter der das Nachdenken, die Aufrufe und
die kurze Erzählung dazwischen liegen, einen Klick entfernt. Die Dauer ist die des
Runs, von seinem Start bis zu seinem Ende, sie schließt also das Warten auf eine
Freigabe ein. Solange die Runde noch arbeitet, steht an derselben Stelle *Working
for 5s…*, über das ein Licht wandert.
Was nie gefaltet wird, bleibt auf der Seite:
die drei Arten von Läufen oben, eine Frage, die der Agent gestellt hat, was die
Person mitten in der Runde ergänzt hat, und ein Abschnitt, der lang genug ist, um
eine eigene Antwort zu sein.
**Was sich selbst öffnet, folgt dem, was jemand beobachtet, außer wenn das
Ergebnis der Punkt ist.**
Ein Aufruf, der fertig wird, während die Runde streamt, öffnet sich auf der Stelle
— ausgeführter Code oder eine geschriebene Datei ist die Antwort und keine Fußnote
dazu.
Eine *wieder geöffnete* Unterhaltung zeigt eine Zeile pro vergangenem Aufruf und
hält genau einen offen: den letzten Aufruf der jüngsten Runde, die **ein Tool
benutzt hat**, denn das ist das Ergebnis, für das der Leser zurückgekommen ist.
Die jüngste *Runde* ist der falsche Anker und war die erste Art, wie das
geschrieben wurde: ein Agent, der eine Datei schreibt und dann in Prosa darüber
antwortet, beendet das Transkript mit Text, und die Datei, die er gerade
geschrieben hatte, war weggefaltet. Jeden fertigen Aufruf beim Mounten zu öffnen
machte aus einem wieder geöffneten Chat eine Wand; keinen zu öffnen verbarg das,
worum gebeten worden war.
Ein Diagramm ist an beiden Enden die Ausnahme. Es öffnet sich, wo immer es sitzt
und wie auch immer die Runde gelesen wird, denn ein Bild, das niemand sehen kann,
ist keine Antwort.
### Dieselbe Runde, live und wieder geöffnet { #the-same-turn-watched-and-reopened }
**Eine Runde ist eine Nachricht, und ihre Reihenfolge wird aufgezeichnet statt
erraten.** Beide Hälften davon waren einmal falsch, und zusammen machten sie aus
dem Live-Transkript und dem neu geladenen zwei verschiedene Dokumente.
Eine mehrschrittige Runde macht eine Modellanfrage pro Tool-Durchgang, und der
Client öffnete früher bei jeder eine Nachricht — also traf eine Runde, die drei
Diagramme zeichnete, als vier Blasen ein, jede mit eigenem Avatar. Eine Runde ist
eine `messages`-Zeile, sodass nur eine dieser Blasen je dem zugeordnet werden
konnte, was gespeichert war; der Rest behielt eine temporäre ID, trug keine Kosten
und keine Bewertung und verschwand beim Neuladen.
Und die Zeile sagte, was eine Runde enthielt, ohne zu sagen, wann. `content`,
`thinking` und `tool_calls` sind drei Behälter, also musste ein Client eine
Reihenfolge rekonstruieren, und die einzige, die er rekonstruieren konnte, war
Reasoning, dann jedes Tool, dann die Antwort. Eine Runde, die die Diagramme
einführte, sie zeichnete und sie dann zusammenfasste, hat zwei Textblöcke und eine
Spalte, um sie hineinzulegen: die Einführung wurde beim Speichern verworfen, und
die Zusammenfassung erschien wieder über der Arbeit, die sie beschrieb.
Also hält `messages.parts` die Abfolge so, wie sie gestreamt wurde —
`{"type": "text"|"thinking", "text": …}` und `{"type": "tool", "tool_call_id": …}`,
in Reihenfolge —, und beide Oberflächen rendern dasselbe Array, statt zufällig
übereinzustimmen. Die Argumente und das Ergebnis eines Tools bleiben in
`tool_calls`; die Zeitleiste benennt den Aufruf, statt ihn zu kopieren.
Es ist null bei einer Runde aus einem einzelnen Teil, wo es keine Abfolge zu
bewahren gibt, und bei jeder Assistenten-Runde, die geschrieben wurde, bevor es das
gab. Diese Zeilen sind weiterhin lesbar — der Text steht in den Spalten, in denen
er immer stand —, aber ihre Reihenfolge wurde nie aufgezeichnet und kann nicht
wiederhergestellt werden, also fällt ein Client, der null findet, darauf zurück,
eine zu rekonstruieren. Dieser Rückfall ist geraten, und er wird nur für sie
behalten.
**Ein Schreibvorgang endet in der Datei, nicht in einem Satz darüber.**
`write_file` antwortet „Wrote 1 lines to /workspace/test1.md"; was das Transkript
zeigt, ist eine Karte, die die Datei benennt, mit *Open* — demselben Viewer, den
der Workspaces-Bildschirm benutzt — und *Download*. Der Pfad wird gegen die eigene
Auflistung der Unterhaltung aufgelöst statt aus den Argumenten übernommen, denn ein
Tool, das mit `test1.md` aufgerufen wird, meldet `/workspace/test1.md`, und der
Workspace speichert vielleicht das eine oder das andere; ohne Treffer wird die
Karte ohne Bedienelemente gezeichnet, die fehlschlagen würden.
**Ein MCP-Aufruf wird nach seinem Server benannt.** Nichts an einem Tool-Aufruf
zeichnet auf, woher er kam — die einzige Spur ist das Präfix, das das Backend den
Tools einer Verbindung voranstellt, und das ist der Name der Verbindung —, also
gleicht das Frontend dieses Präfix gegen die Server ab, die der Aufrufer sehen
kann, und zeigt das eigene Logo des Servers neben dem Schritt. Ein Fehltreffer
liest sich als der vermenschlichte Tool-Name, und so las es sich vorher auch.
**Eine Delegation ist ein Panel, keine Pause.**
Wenn der Agent Arbeit an
[einen Delegierten oder einen Spezialisten](concepts.md#delegate-vs-inline-specialist)
übergibt, ist diese Delegation die ganze Unterhaltung eines zweiten Agents, die
innerhalb einer Runde des ersten stattfindet. Sich selbst überlassen ist es ein
Tool-Aufruf namens `task`, der dreißig Sekunden lang still wird.
Also streamt sie in ein eigenes Panel: welcher Spezialist arbeitet, sein Text und
sein Reasoning, während sie entstehen, seine *eigenen* Tool-Aufrufe — die eine
Collection erreichen können, die der Elternteil nicht einmal sehen kann — und beim
Schließen sein Status, seine Token und sein Anteil an den Kosten der Runde.
Jeder Frame trägt die Task-ID der Delegation und ihre Tiefe. Ein Fan-out von drei
sind drei Panels, und drei Spezialisten in einen Absatz zu verschränken ist
schlimmer, als gar nicht zu streamen. Ein eröffnender Frame trägt außerdem die
Task-ID der Delegation, *innerhalb* derer er gemacht wurde, sodass ein Spezialist,
der weiter delegiert, unter dem richtigen Panel verschachtelt wird und nicht unter
dem, das zuletzt begonnen hat.
Der Text eines Kindes wird **nie** in die Antwort des Elternteils gefaltet. Das
würde dem Elternteil Worte in den Mund legen, die sein eigenes Modell nie erzeugt
hat, und die Unterhaltung wird mit ihnen persistiert.
**Ein freigegebener Aufruf ist nicht das Ende der Runde, und der Rest davon wird
auch gezeichnet.**
Freigeben setzt den Run über HTTP fort, also trifft nichts von der Fortsetzung auf
dem Socket dieser Unterhaltung ein. Ihre Schritte kommen in der Antwort der
Wiederaufnahme selbst zurück und werden als eine weitere Assistenten-Runde
angehängt: die Aufrufe, die sie gemacht hat, dann das, was sie gesagt hat.
Ohne sie war die zweite Hälfte einer Runde unsichtbar, und ein Run, der zweimal
parkte, war die schlimmste Ausprägung davon — einen Befehl freigeben, zusehen, wie
nichts passiert, und gebeten werden, einen zweiten Befehl freizugeben, ohne dass
ein Schritt auf dem Bildschirm über den ersten Rechenschaft ablegt.
Der neu geparkte Aufruf wird in dieser Runde als *wartet auf eine Person*
gezeichnet, und das ist auch der Schritt, auf den die nächste Entscheidung
zurückgeschrieben wird.
**Ein Run ist eine Runde auf dem Bildschirm, wie viele Nachrichten es auch
gebraucht hat.**
Ein Run, der parkt, schreibt, was er bis dahin getan hat, und jede Fortsetzung
wird geschrieben, während sie passiert, statt in die Nachricht davor
zurückgefaltet zu werden — eine Runde umzuschreiben, die jemand schon gelesen hat,
ist schlimmer, als an sie anzuhängen.
Also kann ein Run drei Assistenten-Zeilen hinterlassen, und drei Avatare und drei
Agent-Namen die Seite hinunter zu zeichnen liest sich wie drei Agents, die eine
Frage beantworten.
`MessageList` gruppiert *aufeinanderfolgende* Assistenten-Nachrichten mit
derselben `run_id` zu einer Runde: der Avatar und der Name einmal, oben.
„Aufeinanderfolgend" ist Teil der Regel — eine Person, die zwischen zwei Segmenten
spricht, bedeutet, dass die Runde wirklich neu beginnt — und eine Nachricht ohne
aufgezeichneten Run gruppiert nie, denn fehlend bedeutet „nicht aufgezeichnet" und
nicht „derselbe Run".
Live trifft die Run-ID auf dem `tool_approval_required`-Frame ein, dem einzigen
Frame, der sie benennt, und der einzigen Runde, die sie braucht. Beim Neuladen
kommt sie von der gespeicherten Nachricht.
**Die Zeit und die Kosten stehen unter dem Ende der Runde**, einmal, wie viele
Nachrichten es auch gebraucht hat. Ein Run meldet, was er ausgegeben hat, wenn er
*parkt*, also wird die Zahl auf dem ersten Segment aufgezeichnet — dort gezeichnet
saß sie auf halber Höhe der Antwort, mit nichts unter deren Ende. Das letzte
Segment zeigt die Gesamtsumme des Runs: jede Zahl ist zu diesem Zeitpunkt
kumulativ, also ersetzt die spätere die frühere, statt zu ihr addiert zu werden,
und die Fortsetzung nimmt ihre Zahlen aus der Antwort der Wiederaufnahme selbst.
**Was der freigegebene Aufruf zurückgegeben hat, wird auf dem Schritt
aufgezeichnet, der freigegeben wurde.** Die Zeile wird offen geschrieben, wenn der
Run parkt — er ist noch nicht gelaufen —, und die Wiederaufnahme, die ihn
schließlich ausführt, erzeugt die *Rückgabe* ohne den Aufruf, zu dem sie gehört,
denn dieser Aufruf wurde von der vorigen Ausführung gemacht. Also begleicht sie die
bestehende Zeile, statt einen neuen Schritt zu schreiben: die Alternative ist
derselbe Befehl zweimal in einer Runde, und die Alternative *dazu* war, dass der
eine Aufruf, den jemand bewusst geprüft hat, der eine Aufruf ist, der sich auf
nichts öffnet.
**Ein wiedergegebener Schritt animiert nie.**
Ein Tool-Aufruf wird als laufend gespeichert, bis etwas sein Ergebnis vermerkt, und
nicht jedes Ende vermerkt eines: eine Freigabe, die abläuft, führt nichts aus, also
wurde der Schritt, an dem sie parkte, offen geschrieben und blieb so.
Zurückgelesen pulsierte er im Präsens unter einer Unterhaltung, die Tage zuvor
geendet hatte, und versprach ein Ergebnis, das nichts liefern würde.
Also schließt der Durchlauf, der eine Freigabe ablaufen lässt, jetzt auch den
Schritt — das eine Ende, das den Aufruf nie ausgeführt hat — und ein
wiedergegebener Aufruf, der noch als laufend markiert ist, wird als
**unvollendet** gerendert: Vergangenheit, kein Spinner, kein Ergebnis.
Kein Fehler und kein Erfolg. Das Ergebnis, das niemand aufgeschrieben hat.
**Und das Panel gehört seiner Unterhaltung, nicht dem Tab.** Einen anderen Thread
zu öffnen nimmt das Freigabe-Panel und jede offene Frage vom Bildschirm, so wie es
schon die Delegations-Panels nimmt. Dort belassen war die Freigabe nicht nur
veraltet, sondern handlungsfähig: *Approve* entschied den Aufruf weiterhin, unter
dem Transkript eines anderen Agents, und der Schritt, den es begleicht, steht in
Nachrichten, die nicht mehr geladen sind — also änderte sich nichts auf dem
Bildschirm, um zu sagen, dass es passiert war. Es zu leeren verliert nichts, denn
die Freigabe-Warteschlange hält dieselbe Zeile. Der eine Übergang, der kein
Wechsel ist, ist eine erste Runde, die mitten im Stream ihre eigene
Unterhaltungs-ID erfährt, und das Panel überlebt ihn.
Ein Delegierter kann ebenfalls für eine Person anhalten — ein freigabepflichtiges
Tool innerhalb eines Spezialisten parkt die ganze Runde in der
Freigabe-Warteschlange.
Das Panel schließt sich dann in einen Zustand *wartet auf eine Person*, statt sich
so lange auf „arbeitet" zu drehen, wie der Freigebende braucht, und die Delegation
behält die Task-ID, unter der sie geparkt hat, sodass ihre Identität die
Wiederaufnahme überlebt, statt dass ein zweites Panel neben dem ersten erscheint.
Die Wiederaufnahme selbst läuft über HTTP (`POST /runs/{id}/resume`), was keine
Delegations-Frames trägt. Also wird das wartende Panel auf das Ergebnis des wieder
aufgenommenen Runs gesetzt — abgeschlossen, fehlgeschlagen oder abgebrochen — aus
dieser Antwort. Eine Wiederaufnahme, die bei einer frischen Entscheidung erneut
parkt, lässt es wartend.
Die Antwort des Assistenten steht **nicht** in einer Blase; nur die Nachricht der
Person. Eine Antwort ist Prosa mit Überschriften, Code und Tabellen darin, und eine
abgerundete Füllung darum kämpft gegen jedes einzelne davon.
**Jedes Wort auf jedem dieser Bildschirme kommt aus
`frontend/messages/en.json`.** Englisch ist die Ausgangssprache; `pl.json`
und `de.json` enthalten nur, was tatsächlich übersetzt wurde - `src/i18n.ts` legt
Englisch unter jedes Locale, sodass eine fehlende Übersetzung Englisch rendert
statt des Schlüssels. `make lint` führt `frontend/scripts/check-i18n.ts` aus, das in beide
Richtungen fehlschlägt: bei Text, der in einer Komponente stehen geblieben ist, und
bei einem Schlüssel, den eine Komponente liest und den der Katalog nicht hält.
### Eine Delegation auf einer Oberfläche, die keine zeigen kann { #a-delegation-on-a-surface-that-cannot-show-one }
Jede andere Oberfläche — Slack, Telegram, Mattermost, das eingebettete Widget, die
REST-API — bekommt überhaupt keine Delegations-Frames. Die Delegation läuft
trotzdem und wird trotzdem aufgezeichnet; sie wird nur nicht erzählt, dieselbe
Anordnung, die `ask_user` hat.
Dieser Standard ist tragend statt bequem, und er ist das eine, was man wissen muss,
bevor man eine Oberfläche hinzufügt, die die Panels haben möchte.
!!! danger "Einen Handler an eine Delegation zu hängen ändert den Transport, nicht nur die Beobachtbarkeit"
Die Bibliothek treibt jedes Kind durch `iter()` und öffnet eine **gestreamte**
Anfrage dafür.
Also funktioniert ein Delegierter, dessen Modell oder Provider nicht streamen
kann, von der API aus perfekt und hört in dem Moment auf zu funktionieren, in
dem jemand das Chat-Fenster öffnet — dieselbe veröffentlichte Version,
derselbe Agent, scheiternd auf einer Oberfläche.
Deshalb wird ein Handler nur dort angehängt, wo eine Senke existiert, statt
bedingungslos zugunsten der einen Oberfläche, die sie zeichnet.
`tests/test_subagents_library_contract.py` hält diese Eigenschaft der Bibliothek
fest, sodass ein Release, das auf eine einfache Anfrage zurückzufallen beginnt, rot
wird und es sagt.
### Dateien { #files }
Wenn jemand eine Tabelle auf einen Bot zog, wurde sie früher verworfen.
`IncomingMessage` hatte kein Anhangsfeld, also parste kein Adapter einen, und der
Agent antwortete über ein Dokument, das er nie erhalten hatte.
Jetzt erreicht eine Nachricht mit einer Datei — mit oder ohne Bildunterschrift —
den Agent auf demselben Weg wie ein Web-Upload und wird **auf dieselbe Weise
zurückgelesen**: die Datei ist eine Zeile an der Runde, mit der sie ankam, sodass
der Thread in `/chat` eine Karte zeigt statt der Beschreibung, die dem Modell
darüber gegeben wurde.
Ein Upload ohne Bildunterschrift ist trotzdem eine Runde, und seine Nachricht
benennt, was ankam — `Attached image: photo.jpg` —, statt leer über der Karte zu
sitzen, denn eine leere Nutzernachricht liest sich, als hätte jemand nichts
geschickt.
**Auf jedem Transport, denn jeder Adapter hat genau einen Parser.**
Jede Plattform hat zwei Wege hinein — einen Webhook und einen Stream oder
Long-Polling —, und der zweite baute früher seine eigene normalisierte Nachricht.
Telegrams Polling-Schleife las Text und sonst nichts; der Mattermost-Outgoing-Webhook
las überhaupt keine `file_ids`.
Beide bringen ihr Update jetzt zurück in die Form, die die Plattform sendet, und
übergeben es demselben `parse_incoming`, sodass **einmal** entschieden wird, was als
Nachricht zählt. Es war zweimal entschieden worden, und die Kopien waren sich über
Dateien uneinig — was auf den Pfaden am meisten zählte, die ein selbst gehostetes
Deployment tatsächlich betreibt.
Was jedem Transport *übergeben* wird, unterscheidet sich weiterhin, und das liegt
an der Plattform und nicht an uns: Telegrams Polling-Schleife abonniert nur neue
Nachrichten, sodass eine Bearbeitung den Webhook-Empfänger erreicht und nie den
Poller.
**Eingehend** ist der Web-Upload-Pfad, anders erreicht. Die Bytes kommen von einer
Plattform statt von einem Browser und gehen dann durch genau das, was ein
Web-Upload bekommt: die MIME-Allow-Liste, `MAX_UPLOAD_SIZE`, den Parser, das
Storage und eine `ChatFile`-Zeile. Ein Bot ist der freizügigste Rand, den diese
Plattform hat — jeder in einem Channel kann eine Datei darauf ablegen —, also darf
er nicht auch noch der nachsichtige sein. Von dort folgt die Datei dem Routing in
[Dateiverarbeitung](file-processing.md): inline eingefügt für einen Agent ohne
Workspace, geschrieben nach `/uploads` mit einer Referenz für einen, der einen hat.
Die Größe wird mit Absicht zweimal geprüft: gegen das, was die Plattform
*behauptet*, bevor irgendetwas geholt wird, denn ein Gigabyte herunterzuladen, um
es dann abzulehnen, ist der Angriff, und gegen die Bytes danach, denn eine
Behauptung ist keine Messung.
**Eine Datei, auf der die Runde lief, gehört zu dieser Runde.** Ihre
`ChatFile`-Zeile wird mit der Nutzernachricht verknüpft, die das Transkript des
Runs schreibt, genau wie ein Web-Upload mit der Nachricht verknüpft ist, die jemand
getippt hat — sodass ein Transkript eines Kanal-Threads die Tabelle neben der Frage
zeigt, zu der sie gestellt wurde. Es zählt hier mehr, als es sich liest:
`chat_files` trägt keine Organisation, sodass eine Zeile ohne Nachricht allein über
den Absender begrenzt ist, erreichbar über `GET /files/{id}` durch ihren Eigentümer
und durch sonst nichts. Jede Kanal-Runde hinterließ früher eine auf diese Weise,
denn das Verknüpfen erledigte die eine Oberfläche, die ihr eigenes Transkript
schreibt.
Was die Verknüpfung weitet, sind die *Metadaten*, nicht die Bytes. Die Unterhaltung
eines Channels gehört demjenigen, der zuerst darin gesprochen hat, sodass in einem
geteilten Channel die Datei eines Kollegen nun in einem Transkript erscheint, das
andere Mitglieder lesen können — als Name, Typ und Größe. Sie herunterzuladen
beantwortet weiterhin nur ihr Eigentümer, was die richtige Hälfte ist, um sie
privat zu halten, und die Hälfte, über die ein Leser Bescheid wissen muss: der Chip
ist da, die Bytes gehören ihm nicht.
Eine Datei zu holen braucht auf jeder Plattform eine zweite authentifizierte
Anfrage, weshalb ein Anhang als Handle ankommt statt als Inhalt:
| | |
|---|---|
| Slack | Die private URL auf dem Event, geholt mit dem Bot-Token. Slack antwortet mit **200 und einer Anmeldeseite** statt mit 401, wenn das Token eine Datei nicht lesen kann, deshalb wird der Content-Type geprüft — sonst würde eine Anmeldeseite als die Tabelle des Nutzers gespeichert |
| Telegram | `getFile` löst eine `file_id` zu einem Pfad auf, der abläuft, dann die Datei-API. Ein Foto kommt in mehreren Größen an; die größte ist die, die behalten wird |
| Mattermost | `/files/{id}` auf dem eigenen Server dieses Bots. Ein Bot, dessen Server nicht vermerkt ist, sagt das, statt zu raten, an welchen Firmenserver er ein Token schicken soll |
| Teams | Eine Datei in einem persönlichen Chat trägt eine Download-URL, die Teams bereits autorisiert hat; ein in eine Nachricht eingefügtes Bild wird mit dem Token des Bots vom Bot Connector geholt, und das Token geht nirgendwo sonst hin |
**Aufnahmen werden noch nicht unterstützt.** Telegram legt jede Medienart in ein
eigenes Feld, sodass eine Sprachnotiz ohne jeden Text ankommt — und bis zu dieser
Änderung parste sie als nichts und verschwand ohne Logzeile. Sie wird jetzt
gelesen, abgelehnt, und die Ablehnung sagt, was tatsächlich wahr ist: die Aufnahme
kam an, und nichts hier kann sie bisher anhören. Transkription ist
[#54](https://github.com/vstorm-co/agenticos/issues/54); wenn sie landet, kommt
Audio auf die Allow-Liste, und diese Ablehnung fällt weg.
Eine Datei, die abgelehnt wird — nicht unterstützter Typ, eine Aufnahme, zu groß,
ein fehlgeschlagener Download —, wird **in der Antwort benannt**. Eine schlechte
Datei unter dreien verliert nicht die anderen beiden oder die Frage, die mit ihnen
kam, und ein Bot, der einen Anhang stillschweigend ignoriert, sieht genauso aus wie
ein Bot, der ihn gelesen hat.
**Eine Runde, die abgelehnt wird, bevor sie läuft, gibt ihre Dateien zurück.**
Die Bytes werden geholt und gespeichert, bevor der Agent aufgelöst wird, sodass
eine Ablehnung an der Stelle des Runs — kein Agent auf diesem Bot verfügbar, ein
Absender, dessen Chat-Konto niemandem gehört — früher die Zeilen und die Dateien
zurückließ, ohne eine Nachricht, die sie je verknüpfen würde. `chat_files` trägt
keine Organisation, sodass eine nicht verknüpfte Zeile allein über `user_id`
begrenzt ist und nichts sie einsammelt.
Beide werden jetzt gelöscht, bevor die Ablehnung gesendet wird, und die Ablehnung
wird gesendet, ob das gelungen ist oder nicht
([#661](https://github.com/vstorm-co/agenticos/issues/661)).
Eine Runde, die tatsächlich lief, behält ihre Dateien — sie haben sie gespeist, und
der Run steht im Transkript.
**Ausgehend** werden Dateien nur über `send_channel_file` gesendet, wenn der Nutzer die Datei erhalten oder herunterladen möchte. Erstellen, Lesen oder Bearbeiten hängt keine Datei an. Gewähren Sie das Tool pro Bot-Bindung unter *Wo dieser Agent verfügbar ist*. Bestehende Bindungen behalten ihre Auswahl; aktivieren Sie diese Aktion nach dem Upgrade ausdrücklich. Das Tool hat Seiteneffekte und folgt der üblichen Freigaberichtlinie. Es sendet nur die benannte Workspace-Datei, verweigert interne Pfade und Dateien über 8 MB und erlaubt höchstens drei Versuche pro Runde. Plattformfehler werden gemeldet statt als Zustellung ausgegeben. Teams kann die Annahme im persönlichen Chat verlangen. Diagramme und angeforderte Visualisierungen behalten ihre separate Bildzustellung.
Jede Datei trägt den Typ, den ihr Name nahelegt, statt eines flachen
`application/octet-stream`, sodass ein Diagramm, das ein Agent geschrieben hat, auf
den Plattformen, die das Feld lesen, als Bild ankommt statt als Blob, den jemand
herunterladen muss, um ihn zu identifizieren.
Gedeckelt bei 3 Dateien und 8 MB je Datei, unter dem eigenen Limit jeder Plattform,
sodass die Ablehnung unsere ist und erklärt werden kann, statt als undurchsichtiger
API-Fehler anzukommen. Alles über der Obergrenze wird in der Antwort benannt und
bleibt im Workspace.
Ein Diagramm bleibt von all dem getrennt. Es ist auf diesen Plattformen ein *Foto*,
inline gerendert, was der ganze Sinn der `charts`-Capability ist — es in die
Anhangsliste zu falten würde jedes Diagramm als Download ankommen lassen.
### Sagen, was eine Runde gekostet hat { #saying-what-a-turn-cost }
Ein Bot, der aufhört zu antworten, weil seine Organisation ihr monatliches Cap
erreicht hat, sieht kaputt aus. Der einzige Unterschied zwischen „kaputt" und
„Budget aufgebraucht" ist, dass jemand es vorher gesagt hat, deshalb kann ein Bot
melden, was eine Runde ausgegeben hat: Token, Kosten, wie viel vom Monat weg ist
und wie voll der Workspace dahinter ist.
Im Web-Chat sitzen dieselben zwei Zahlen unter dem Eingabefeld, und sie kommen von
verschiedenen Stellen, weil sie Verschiedenes messen.
**Die Kosten** sind die neueste gemessene Antwort *in der Unterhaltung auf dem
Bildschirm*. Sie werden aus dem Transkript gelesen, sodass sie da sind, wenn ein
Thread wieder geöffnet wird, und nicht erst nach der nächsten Nachricht — und
gefiltert nach Unterhaltungs-ID, denn der Store hält für den Moment zwischen dem
Klick und dem Eintreffen des Abrufs noch die Nachrichten des vorigen Threads. Er
meldete jene unter der neuen Unterhaltung, bis er es tat.
**Der Füllstand** ist der Workspace, wie er jetzt ist. Eine Live-Runde meldet ihn —
der residente Speicher eines Containers kann nur von seinem Host kommen — und eine
wieder geöffnete Unterhaltung liest ihn aus der Workspace-Auflistung, die die
Obergrenze trägt, gegen die ein gespeicherter Workspace voll läuft. Ohne das
erschien „workspace 0% full" erst, nachdem jemand eine Nachricht geschickt hatte,
in dem einen Moment, in dem es niemand braucht.
Gewählt **pro Bindung**, im Builder unter *Where this agent is available* - neben
den zusätzlichen Instruktionen und den Channel-Abfragen, denn ob eine Antwort eine
Kostenzeile trägt, ist Teil dessen, was dieser Agent auf dieser Oberfläche sagt. Es
saß auf dem Bot, bis ein Bot einen Agent bediente, wo es die Einstellung eines
Betreibers in einer Tabelle aus Servern und Token war, ohne dass sonst etwas über
den Agent in der Nähe stand.
| Modus | |
|---|---|
| `log only` | Aufgezeichnet und nicht gesagt. Ungesagt ist nicht ungemessen — „der Bot ist still geworden" ist eine Frage, die jemand Tage später stellt |
| `near a limit` | Gesagt, sobald das Budget oder der Workspace eine Schwelle überschreitet (standardmäßig 80 %). **Der Standard** |
| `every n messages` | Gesagt in jeder n-ten Runde *dieses Chats*, nicht des Bots |
| `every reply` | In jeder Runde gesagt |
`near a limit` ist der Standard statt `log only`, denn auf Stille als Standard zu
setzen würde jeden schon registrierten Bot genau in dem Zustand lassen, den es zu
verhindern gilt. Und statt `every reply`, denn eine Fußzeile unter jeder Nachricht
in einem lebhaften Channel ist die andere Art, eine Warnung nutzlos zu machen.
Der Workspace zählt genauso wie das Geld. Ein gespeicherter Workspace, der voll
läuft, beginnt *Schreibvorgänge abzulehnen*, was der Agent mitten in einer
Tätigkeit als Tool-Fehler meldet — ein Bot, der nur das Budget beobachtet, würde
beim anderen Limit still werden, ohne dass etwas gesagt wird.
Das Messen kostet bei einem Container etwas: sein Speicher ist ein Roundtrip zum
Host pro Sandbox. Also fragt `log only` nie, und jeder andere Modus fragt nach einer
Session, statt sie alle aufzulisten.
In `/chat` gibt es kein Lärmargument, also werden die Zahlen immer gesendet — der
Client zeichnet sie unter dem Eingabefeld und entscheidet, was er zeigt. Drei
Dinge, die er zeigt und eine Kanal-Fußzeile nicht:
- **Das eigene Cap des Agents zuerst**, und das der Organisation erst ab 80 %. Das
der Organisation stoppt jeden Agent auf einmal und gehört jemand anderem; das
eigene des Agents ist das, was derjenige, der hinschaut, anheben kann.
- **Eingabe und Ausgabe getrennt**, unter jeder Antwort ebenso wie unter dem
Eingabefeld. Sie sind um eine Größenordnung unterschiedlich bepreist, sodass eine
Summe nicht sagen kann, ob eine Runde wegen eines langen Kontexts oder einer
langen Antwort teuer war — und die Leiste beschreibt immer nur die *letzte*
Runde, was in einer langen Unterhaltung verbirgt, welche Antwort das Geld
gekostet hat. Nur Live-Runden: die Nutzung wird gemessen, wenn ein Run endet, und
nicht pro Nachricht gespeichert, sodass eine neu geladene Unterhaltung keine
zeigt.
- **Die Dateien selbst**, in einem Panel neben dem Transkript, das
`GET /conversations/{id}/workspace` liest. Es liest neu, wenn eine Runde endet,
statt nach einem Timer, und es fehlt ganz — nicht leer — bei einem Agent, der
keine Dateien hält, was die meisten sind. Es benennt, wessen Dateien das sind,
denn unter dem Scope `agent` wird ein Workspace geteilt, und eine Datei zu finden,
die man nie erstellt hat, liest sich wie ein Leck, bis etwas auf dem Bildschirm es
erklärt. Eine Datei ist eine Kachel, und eine zu öffnen öffnet denselben Viewer,
den der Workspaces-Bildschirm benutzt — ein Bild, ein PDF, ein Player für Audio
und Video, Markdown als Vorschau
oder Quelltext, und immer ein Download —, der `…/workspace/file` für Text und
`…/workspace/raw` für Bytes liest. Bewusst über die *Unterhaltung* statt über die
Workspace-ID: das ist es, was diese Dateien für jemanden erreichbar hält, mit dem
der Chat geteilt wurde.
### Überschreiben, wer sich den Workspace teilt { #overriding-who-shares-the-workspace }
Bei Slack wird `thread_ts` in die Chat-ID gefaltet — also *ist* ein Thread eine
Unterhaltung, und ein Agent, dessen Spec `conversation` sagt, bekommt einen
Workspace pro Thread. In einem lebhaften Channel sind das fünfzig Container und ein
`429` für die einundfünfzigste Person, die antwortet. Die Bindung kann stattdessen
`channel` sagen, und jeder Thread in diesem Channel teilt sich einen.
Die Auswahl ist dieselbe wie die des Specs (`run`, `conversation`, `channel`,
`user`, `agent`), dazu „as the agent says", was der Standard ist und nichts
speichert. Das Bedienelement sitzt an der Bindung im Builder, und es erscheint nur
bei einem Agent, der überhaupt Dateien hält.
Der Scope `user` ist das, was einen Workspace *über* Oberflächen hinweg trägt: eine
Person, die im Web-Chat beginnt und in Slack weitermacht, ist eine
`ChannelIdentity`, die mit einem Konto verknüpft ist, also findet sie dieselben
Dateien. `conversation` und `channel` tun das bewusst nicht — die benennen einen
Ort, und ein Ort folgt niemandem auf eine andere Plattform.
### Was der Agent über den Channel nachschlagen darf { #what-the-agent-may-look-up-about-the-channel }
Ein Bot, der in `~support` antwortet, kennt die Wörter, die jemand getippt hat, und
sonst nichts. Er weiß nicht, dass der Channel `~support` heißt, wer darin ist,
wofür er eingerichtet wurde oder was vor zehn Minuten darin gesagt wurde — also
sind *„wen soll ich zur Abrechnung fragen?"* und *„fasse zusammen, was wir oben
entschieden haben"* Fragen, die er nur durch Raten beantworten kann.
Vier Tools ändern das, und jedes wird **pro Bindung** gewährt, unter
*Where this agent is available*:
| Tool | Beantwortet | Slack | Telegram | Mattermost | Teams |
|---|---|:-:|:-:|:-:|:-:|
| `get_channel_info` | Name, Zweck, Thema, Größe | ✅ | ✅ | ✅ | ✅ |
| `list_channel_members` | Wer hier ist | ✅ | nur Admins | ✅ | ✅ |
| `search_channels` | Welche anderen Channels es gibt | ✅ | — | ✅ | — |
| `read_channel_history` | Aktuelle Channel-Nachrichten oder dieser Thread mit `scope="thread"` | ✅ | — | ✅ | — |
| `send_channel_file` | Angeforderte Workspace-Datei senden | ✅ | ✅ | ✅ | ✅ |
Verbinden Sie das Konto mit einer direkten Nachricht an den Bot: nur `link` ohne Schrägstrich. Öffnen Sie den privaten Verbindungslink nach der Anmeldung bei AgenticOS und kehren Sie zum Channel zurück. Benötigen frühere Thread-Dateien eine Kontoverbindung, erklärt der Bot dies ohne Download und versucht es nach der Verbindung erneut. Channel-Bilder verwenden die doppelte CSS-Auflösung; sehr breite horizontale Mermaid-Flussdiagramme erhalten nur für das PNG eine vertikale Anordnung.
Über `generate_image` angeforderte Bilder werden auf allen vier Chatplattformen an Antworten angehängt, zusätzlich zu Diagrammen. Gespeicherte Bilder werden innerhalb der Organisation des Laufs gelesen, auf 8 MiB und 16 Megapixel begrenzt und in PNG konvertiert; fehlende, ungültige oder zu große Bilder führen zu einem Zustellhinweis. Teams passt Inline-Bilder zusätzlich an seine eigenen Grenzen an. Eine gewöhnliche Workspace-Datei wird durch ihre Erstellung weiterhin nicht gesendet: Dafür ist `send_channel_file` erforderlich. Der Thread-Kontext behält die ursprünglichen Zeitstempel bei; relative Zeitangaben beziehen sich darauf und nicht auf den Zeitpunkt, an dem der Agent dem Thread beitritt.
Angehängt werden nur Dateien, die von den eigenen Tools des Laufs als erzeugt erfasst wurden. Ein Toolname oder Ergebnistext berechtigt nicht zum Zugriff auf Medien anderer Gespräche. Das Ablehnen der Bilderzeugung löst keinen Hinweis auf ein fehlendes Bild aus. Bestehende Threads aktualisieren den Text einmal, ohne frühere Anhänge erneut herunterzuladen.
Pro Bindung statt pro Agent, denn eine Organisation kann einen Agent an zwei
Mattermost-Server und drei Slack-Workspaces binden — und *„darf er lesen, was in
diesem Channel gesagt wurde"* hat auf dem internen und auf dem kundenseitigen eine
andere Antwort. Ein Schalter in der Toolbox des Agents hätte eine Antwort für alle
fünf, weshalb es keinen solchen Schalter gibt: das Veröffentlichen lehnt einen Spec
ab, der `channel_tools` trägt, und der Run setzt die Bindung aus der Zeile
zusammen, die die Nachricht eingelassen hat, genauso wie er den Prompt dieser
Bindung anhängt.
Nichts wird standardmäßig gewährt. Was eine Plattform nicht beantworten kann, wird
nicht angeboten: Telegram gibt einem Bot kein Verzeichnis von Chats zum Durchsuchen
und keine Möglichkeit, Nachrichten zu lesen, die ihm nicht geschickt wurden, und
`getChatAdministrators` ist alles, was es auflisten darf — also ist eine
Telegram-Mitgliederliste eine Liste von Administratoren und sagt das auch.
Drei Dinge, die es vor dem Gewähren zu wissen lohnt:
- **Die Mitgliedschaft des Bots ist die ganze Berechtigungsgrenze.** Jeder Aufruf
geht über das eigene Token des Bots, also sieht der Agent genau das, was der Bot
sieht. Es gibt keine Allow-Liste von uns, die mit der der Plattform aus dem Tritt
geraten könnte.
- **Das Modell benennt keine Channel- oder Thread-ID.** `read_channel_history` verwendet standardmäßig `scope="channel"` für aktuelle Nachrichten dieses Channels; `scope="thread"` liest nur den gebundenen Thread. Kein Modus erreicht einen anderen Channel. Das Ergebnis ist ein begrenztes aktuelles Fenster, kein Beleg dafür, dass ältere Nachrichten fehlen. Die automatische Kontextergänzung bleibt auf den Thread begrenzt.
- **`read_channel_history` ist das eine, das eine Freigabe verdient.** Es ist ein
Lesevorgang, also fragt es nicht standardmäßig nach, aber es setzt die Nachrichten
anderer Leute in ein Run-Transkript, das jemand Wochen später liest. Ein
`tool_approval`-Override an der Bindung ist, wie Sie es nachfragen lassen.
Der automatische Channel-Kontext enthält Name, Zweck und eine begrenzte sichtbare Mitgliederliste, als Daten statt Instruktionen markiert. Die pro Bindung gewährten Tools erlauben weitere Abfragen und explizite Dateizustellung; das Entziehen eines Tools entfernt nicht den automatischen Kontext.
## Ein Bot, dessen Nachrichten einen Workflow starten { #a-bot-whose-messages-start-a-workflow }
Ein Workflow mit einem Trigger **Channel message** übernimmt die Nachrichten eines
Bots an Stelle seines Agenten. Ihn erreichen nur die Nachrichten, die der Agent
beantwortet hätte, und davon nur die, die zum Filter des Triggers passen: Eine
Nachricht, die einen anderen Agenten per `@handle` nennt, geht weiterhin dorthin,
und eine, die der Filter auslässt, beantwortet der Agent des Bots wie bisher. Der
Workflow antwortet mit seinem eigenen Schritt **Send a message** im Thread der
Nachricht. Siehe [Über eine Kanalnachricht](workflows.md#from-a-channel-message).
## Auswählen { #choosing }
```mermaid
flowchart TD
A{a site of your own?} -->|no| L{a link will do?}
L -->|yes| H[a hosted page]
L -->|no| T[Slack, Teams, Telegram or Mattermost]
A -->|yes| U{your own interface?}
U -->|yes| WS[the raw WebSocket]
U -->|no| P{visitors signed in to your product?}
P -->|yes| J["the widget, <code>jwt</code> mode"]
P -->|no| PB["the widget, <code>public</code> mode"]
A -->|another system entirely| API["the REST API"]
```
- Eigene Website, keine Konten → **Widget, `public`-Modus**.
- Innerhalb Ihres Produkts, pro Nutzer → **Widget, `jwt`-Modus**.
- Ganz eigene Oberfläche → **WebSocket**.
- Keine eigene Website, und ein Link genügt → **eine gehostete Seite**.
- Wo das Team ohnehin spricht → **Slack, Teams, Telegram oder Mattermost**.
- Ein ganz anderes System → die REST-API (`POST /api/v1/agents/{id}/run`).
!!! success "Die ersten vier sind ein Objekt"
Ein Widget, ein Socket-Client und eine gehostete Seite sind drei Wege, dasselbe
Embed zu erreichen, mit einem Satz von Ablehnungen dazwischen — sodass „wer mit
diesem Agent sprechen darf" genau eine Antwort hat, durch welchen der drei
jemand auch ankommt.
## Zusammenfassung { #recap }
- **Ein Runner hinter jeder Oberfläche.** Der Web-Chat, eine gehostete Seite, ein
Widget, die API, Slack, Teams, Telegram und Mattermost erreichen alle denselben Code,
sodass Governance nichts ist, woran ein Aufrufer vorbeirouten kann.
- Ein Bot antwortet als **ein Agent**, und eine Erwähnung läuft als **der
Absender** — nie als der Bot.
- Was ein **Fremder** darf, darf er in einem bestimmten Takt: die öffentlichen
Oberflächen tragen Limits pro Aufrufer und pro Adresse, gezählt in Redis.
- Aufgezeichnet wird, **was die Person geschrieben hat**, und nicht der Prompt, der
darum herum zusammengesetzt wurde.
- Eine **Delegation ist ein Panel**, keine Pause — und einen Handler daran zu
hängen ändert den Transport, also wird er nur dort angehängt, wo eine Senke
existiert.
Eine Kanalnachricht, die nur die Bot-Erwähnung oder Leerraum enthält, wird nach dem Entfernen der Erwähnung ignoriert, bevor die Zustellung beansprucht oder ein Lauf gestartet wird. So erhält die Modell-API keine leere Nutzernachricht, deren Weglassen die vorherige Assistentenantwort als unzulässiges Prefill erscheinen lässt. Nachrichten mit ausschließlich Anhängen durchlaufen weiterhin die Anhangverarbeitung.
## Aktuelle Kanalvorgaben {#current-channel-defaults}
Formatierung und Gesprächsregeln kommen bei jedem Lauf aus dem aktuellen Deployment. Der Bindungs-Prompt speichert nur eigene Ergänzungen. Upgrades entfernen exakt erkannte Kopien früherer Standardanweisungen und erhalten den umgebenden eigenen Text; bearbeiteter oder unbekannter Text bleibt unverändert. Das Leeren des eigenen Prompts erhält Plattformvorgaben und Kanalkontext. Eine Umschreibung alter Prompt-Zeichenfolgen zur Laufzeit ist nicht erforderlich.
Ein Downgrade stellt die gespeicherten Kanalvorgaben der vorherigen Version wieder her und erhält eigene Ergänzungen, auch bei nach dem Upgrade angelegten Bindungen. Andere Oberflächen bleiben unverändert.
## Benachrichtigungen über verknüpfte Kanäle { #linked-notifications }
Unter **Einstellungen → Benachrichtigungen** ein privates Ziel pro Plattform
und Organisation wählen: Slack, Telegram, Mattermost, Teams, Discord, SMS und iMessage verwenden
den bestehenden verknüpften Bot. E-Mail und Push behalten ihre Einrichtung.
Ereigniseinstellungen und Routine-Auswahl werden beim Versand zusammen mit
Ressourcenrechten und aktuellem Fallstatus geprüft. Die Kontoverknüpfung allein
aktiviert keinen Versand.
Slack erhält Klartextblöcke mit einem Link zum bestehenden Fall. Der Link nutzt
die normale Konsolenautorisierung. Bestätigen oder Lösen des Falls stoppt wartende
externe Hinweise über alle Kanäle. Der Versandstatus bezeichnet Plattformannahme;
der Fall behält seinen eigenen Bestätigungsstatus.
Ein bestätigter [Slack-HTTP-429](https://docs.slack.dev/apis/web-api/rate-limits/)
erlaubt begrenzte Wiederholung nach `Retry-After`. Der Client deaktiviert
[SDK-Wiederholungen](https://docs.slack.dev/tools/python-slack-sdk/web/), damit
Timeouts und unbekannte Ergebnisse sichtbare Fehler bleiben statt doppelte
Sendungen auszulösen. Die [Benachrichtigungsrichtlinie](governance.md) beschreibt
Widerruf, eingeschränkte Genehmigungshinweise und Migrationen.
## iMessage über einen Firmen-Mac { #imessage }
Ein Firmen-Mac mit Nachrichten und [BlueBubbles](https://docs.bluebubbles.app/server/developer-guides/rest-api-and-webhooks) stellt den Transport bereit. Er muss angemeldet und erreichbar bleiben. Gewähren Sie BlueBubbles die benötigten macOS-Berechtigungen und aktivieren Sie die REST API. Registrieren Sie unter **Kanäle** einen **iMessage**-Bot mit der HTTP-Adresse der Bridge und ihrem Serverpasswort als Bot-Token. Das Passwort liegt verschlüsselt im Organisationstresor; die Adresse darf keine Zugangsdaten, Abfrageparameter oder Fragmente enthalten.
Binden Sie den veröffentlichten Agenten, öffnen Sie die Kanalbearbeitung und wählen Sie nach dem Speichern **iMessage-Webhook registrieren**. Ein Wechsel von Adresse oder Passwort widerruft das bisherige Callback-Geheimnis; registrieren Sie den Webhook erneut. AgenticOS registriert ausschließlich `new-message` unter `/api/v1/imessage/{bot_id}/webhook`. Ein separat erzeugtes Callback-Geheimnis authentifiziert den Aufruf per HTTP Basic. Verwenden Sie HTTPS über nicht vertrauenswürdige Netze. Für private AgenticOS-Adressen muss der BlueBubbles-Administrator API-Webhooks zu lokalen Adressen ausdrücklich erlauben. Das Entfernen betrifft nur Callbacks dieses Bots auf dieser Bereitstellung.
Text und Dateien verwenden BlueBubbles' AppleScript-Transport. Optionale Tapback-Bestätigungen benötigen die Private API. Verknüpfte private Absender erhalten eine eigene Kanalunterhaltung. Die Verknüpfung gewährt dem Bot oder der Bridge keinen Zugriff auf frühere private Konsolenverläufe; Gruppen behalten eigene Unterhaltungen und benötigen die übliche Agentenerwähnung. Genehmigungen verwenden vorhandene nummerierte Fragen: Antworten Sie mit der angezeigten Zahl. Eigene Echos, Tapbacks und Systemereignisse starten keinen neuen Durchlauf. Anhänge werden nur anhand ihrer GUID von der konfigurierten Bridge abgerufen und unterliegen dem Uploadlimit.
Wählen Sie die verknüpfte private Unterhaltung unter **Einstellungen → Benachrichtigungen**. Gruppen sind keine privaten Benachrichtigungsziele. Die Annahme durch die Bridge bestätigt die Übergabe, nicht Apples Zustellung oder die Bestätigung des Empfängers. Eine unerreichbare Bridge erzeugt einen sichtbaren Fehler; ungewisse Übertragungen werden nicht automatisch wiederholt.
[Apple Messages for Business](https://www.apple.com/legal/privacy/data/en/messages-for-business/) ist ein eigener Geschäftsdienst. Die [Einrichtung beim Anbieter](https://learn.microsoft.com/en-us/dynamics365/customer-service/administer/configure-apple-messages-for-business-channel) benötigt ein Messages-for-Business-Konto und einen Messaging-Anbieter; dieser Mac-Adapter richtet diese Integration nicht ein.
SMS-Webhookformulare haben vor der Signaturprüfung ein Limit von 64 KiB und zehn Sekunden Lesezeit. Nummerierte SMS- und iMessage-Antworten beanspruchen die Providernachrichten-ID vor der Verarbeitung, damit eine doppelte Zustellung eine Auswahl nicht zweimal umschaltet.
Eingehende E-Mails werden vor der Signaturprüfung als begrenzter Stream mit höchstens 25 MiB gelesen, auch ohne verlässlichen Content-Length.
Vor der dauerhaften E-Mail-Annahme gelten je Organisation höchstens 4.096 aufbewahrte Zustellaufträge und 256 MiB Nachrichtentext, einschließlich abgeschlossener und fehlgeschlagener Zustellungen. Die Annahme ist workerübergreifend serialisiert; doppelte Message-IDs bleiben idempotent. Bei voller Quote folgt HTTP 429, bevor weiterer Inhalt gespeichert wird. Aufbewahrungsbereinigung oder Löschen gespeicherter Aufträge gibt Kapazität frei.