--- source_sha: "026fa67fb1f0" --- # Postawić agenta tam, gdzie ludzie już są { #putting-an-agent-where-people-already-are } Agent, który odpowiada wyłącznie w tym dashboardzie, to demo. Ten sam opublikowany agent odpowiada w ośmiu miejscach, a każde z nich uruchamia *tę samą zamrożoną wersję* przez ten sam budżet, tę samą bramkę zatwierdzania i te same kontrole tenanta — zmienia się powierzchnia, nie agent. | Gdzie | Czego potrzebuje | Kim jest odwiedzający | |---|---|---| | **Dashboard** | niczego | zalogowany członek | | **Widget na stronie** | tag ` ``` To cała integracja. Skrypt nie ma zależności, kroku budowania ani frameworka — działa na stronie, która ładuje już React, jQuery albo nic w ogóle. ### 3. (Opcjonalnie) powiedz mu o odwiedzającym { #3-optional-tell-it-about-the-visitor } Widget może **zadeklarować zmienne** — nazwę, to, czy jest wymagana, i linijkę mówiącą, do czego służy — a strona je dostarcza: ```html ``` Snippet, który wręcza ci Builder, niesie już tę linijkę, z twoimi własnymi kluczami w środku, gdy tylko jakieś zadeklarujesz. Są doklejane do instrukcji agenta jako oznaczony blok danych, pod linijką mówiącą, że to informacje o odwiedzającym, a nie instrukcje, i że nie da się ich zweryfikować. To ostatnie jest prawdą o każdej z nich, także na widgecie `jwt`: widget czyta `window.AgenticOSContext`, a token uwierzytelnia *kim odwiedzający jest*, a nie *co strona o nim powiedziała*. Więc nic tutaj nie może decydować o tym, co agentowi wolno. Wynikają z tego trzy zasady: - **Klucz, którego nikt nie zadeklarował, jest odrzucany.** Stronę odwiedzający może edytować; bez deklaracji każdy wymyślony przez niego klucz stałby się linijką wewnątrz instrukcji agenta. - **Brakująca wymagana wartość pomija swoją linijkę i trafia do logu.** `required` to obietnica integratora złożona samemu sobie — egzekwowanie jej kosztowałoby odwiedzającego odpowiedź przez cudzy błąd we wdrożeniu. - **Wysyłane raz na rozmowę**, przed pierwszym pytaniem, i odczytywane z każdej ramki, a nie w chwili połączenia: aplikacja single-page dowiaduje się, kim ktoś jest, bez ponownego łączenia. ### 4. (Opcjonalnie) powiedz mu, kim jest odwiedzający { #4-optional-tell-it-who-the-visitor-is } Dla widgetu wewnątrz twojego własnego produktu z logowaniem ustaw token **zanim** załaduje się skrypt. Twój backend go podpisuje; my go weryfikujemy i nigdy nie widzimy twojej bazy użytkowników: ```html ``` Wybicie takiego tokena, w dowolnym języku, który potrafi podpisać JWT: ```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` jest wymagane. Identyfikuje odwiedzającego na potrzeby rate limitingu, a token bez niego jest odrzucany — w przeciwnym razie jeden wyciekły token staje się budżetem całego widgetu. - **`iat` jest wymagane i musi mieścić się w ostatnich 12 godzinach** — nie jest sprawdzane tylko wtedy, gdy jest obecne. Token bez `iat` albo z przeterminowanym jest odrzucany, więc taki, który wycieknie z przeglądarki, nie może działać wiecznie. Ustawione przez ciebie `exp` też jest respektowane, ale wyłącznie po to, by to okno **skrócić** (token wygasły zostaje odrzucony); nie może przedłużyć tokena ponad sufit dwunastu godzin. - Wybijaj go przy każdym załadowaniu strony, po stronie serwera. !!! danger "Nigdy nie wysyłaj sekretu podpisującego do przeglądarki" Podpisuje on *dowolnego* odwiedzającego. Token, który wybija, jest tym, co przeglądarce wolno trzymać, i tylko przez te dwanaście godzin, które daje mu `iat`. --- ## Surowy WebSocket { #the-raw-websocket } Widget jest klientem udokumentowanego protokołu, a nie czarną skrzynką. Jeśli chcesz własnego UI — aplikacji mobilnej, kiosku, komponentu w swoim design systemie — rozmawiaj z tym samym socketem: ``` wss://your-api.example.com/api/v1/embed/PUBLIC_KEY/ws[?token=SIGNED_JWT] ``` **Nie musisz składać go samodzielnie.** Opublikuj taki socket z Buildera — agent → **Availability** → *Raw WebSocket* — a jego wiersz wypisze URL, zbudowany z własnego bazowego URL-a deploymentu, z przyciskiem kopiowania. **Widget** wypisuje to samo obok swojego tagu skryptu, bo widget jest klientem tego protokołu: przejście na własny interfejs to krok, a nie przepisywanie od nowa. `?token=` nie jest tam wypisywany: w trybie `jwt` token wybija dla każdego odwiedzającego twój backend, a prawdziwy token na ekranie dashboardu to działające poświadczenie, które ktoś może odczytać przez ramię. !!! bug "Pierwsza rzecz, która idzie nie tak: brakujący `Origin`" Handshake musi go nieść, i to z listy dozwolonych embeda. **Przeglądarka wysyła go za ciebie; twój własny klient nie wysyła nic, dopóki go nie ustawisz** — aplikacja mobilna, kiosk, przekaźnik po stronie serwera. To, jak to wygląda, gdy go nie wyśle, to `4003` w tabeli niżej, a nie komunikat o błędzie. ```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 ``` **Ramki, które wysyłasz** ```json { "type": "message", "text": "Do you ship to Poland?" } ``` To całe słownictwo przychodzące, plus `context` (co strona mówi o odwiedzającym) i `file_ids` (co załączył, na stronie, która przyjmuje pliki). Każda ramka `message` wlicza się do limitu połączenia, także pusta lub niepoprawna. Poprawne wiadomości zużywają dodatkowo dotychczasowy limit odwiedzającego; błędne dane nie wyczerpują puli współdzielonej z innymi anonimowymi odwiedzającymi. Ramka może zawierać najwyżej trzy identyfikatory plików; dłuższa lista jest odrzucana przed parsowaniem. Niepoprawne identyfikatory w krótkiej liście są pomijane i zapisywane jako jeden zbiorczy log bez ich wartości. **Celowo nie obejmuje trzech pól, które niesie własna ramka dashboardu:** agenta, profilu modelu i środowiska. Ramka, która mogłaby wybrać model, to odwiedzający wybierający go na rachunek operatora, a taka, która mogłaby wybrać agenta, to odwiedzający rozmawiający z czymś, czego nikt nie opublikował na tym kluczu. Wszystkie trzy biorą się z wiersza embeda. Nieznane pole jest **ignorowane, a nie odrzucane**. Klient zbuforowany w czyjejś przeglądarce może być starszy niż ten serwer, a zamknięcie z tego powodu socketu zabrałoby ze sobą rozmowę. **Ramki, które odbierasz** !!! note "To jest własne słownictwo ramek dashboardu, a nie drugie takie" `/chat` i ten socket napędzają jedną pętlę (`app/services/run_stream.py`), więc odpowiedź przychodzi tutaj słowo po słowie tak samo jak tam. Hostowana strona pokazywała kiedyś jedną bryłę tekstu po trzydziestu sekundach niczego i to była pętla, a nie transport. Każda ramka niesie `{ "type": …, "data": { … } }`. | `type` | `data` | Znaczenie | |---|---|---| | `ready` | `visitor` | Połączono. `visitor: true`, gdy token zidentyfikował osobę. | | `history` | `messages` | Co zostało powiedziane w wątku, który ten odwiedzający wznawia — **hostowana strona z anonimowym odwiedzającym**, bo tylko takie połączenie niesie klucz ciągłości: rozmowa widżetu trwa tyle, co jego socket, a odwiedzającego `jwt` nazywa już jego token. Każdy wpis to `role`, `text` i `at`, więc odtworzona tura zachowuje pod sobą swój czas. | | `model_request_start` | — | Agent poszedł do modelu. Pokaż wskaźnik. | | `part_start` | `index`, `part_type`; `tool_name`, `tool_call_id` dla `ToolCallPart` | Zaczyna się blok odpowiedzi. Wywołanie narzędzia przedstawia się już tutaj, żeby klient mógł je pokazać, zanim spłyną jego argumenty. Wysyłane tylko dla bloku, który ta powierzchnia faktycznie poniesie — strona niepokazująca rozumowania nie zapowiada `ThinkingPart`, bo sama zapowiedź mówi, że agent rozumował. | | `text_delta` | `index`, `content` | Słowa odpowiedzi. Doklejaj je. | | `thinking_delta` | `index`, `content` | Rozumowanie modelu. **Tylko jeśli operator to włączył.** | | `call_tools_start` | — | Agent zaraz użyje narzędzi. | | `tool_call` | `tool_call_id`, `tool_name`, `args` | Krok. `args` tylko wtedy, gdy operator pokazuje wyniki. | | `tool_call_delta` | `index`, `args_delta` | Argumenty wywołania w miarę, jak płyną strumieniem. | | `tool_result` | `tool_call_id`, `content` | Co zwrócił ten krok. | | `sources` | `names` | Dokumenty zwrócone przez wyszukiwanie w bazie wiedzy, po nazwie. **Tylko w widgecie, którego operator włączył źródła**, i nigdy fragmenty. | | `final_result_start` | `tool_name` | Odpowiedź jest produkowana przez narzędzie wyjściowe. | | `final_result` | `output` | Czym run się zakończył. Puste w turze, która zaparkowała. | | `complete` | — | Tura się skończyła. Nie niesie **żadnego usage**: ile run kosztował, to sprawa operatora, nie odwiedzającego. | | `error` | `message` | Coś, co odwiedzający powinien zobaczyć: rate limit, osiągnięty budżet, odmowę, turę, która nic nie wyprodukowała. | | `compaction_started`, `compaction_finished`, `compaction_impossible` | zależnie od ramki | Agent porządkuje własne notatki, bo wątek przerósł okno modelu. Wysyłane zawsze, niezależnie od przełączników operatora: podsumowanie trwa dziesiątki sekund, a powierzchnia, która streamuje i nic nie mówi, po prostu zatrzymuje się na ten czas ([#936](https://github.com/vstorm-co/agenticos/issues/936)). | Niektóre ramki dashboardu nigdy nie docierają do publicznego socketu i są odmowami, a nie ustawieniami. **`user_prompt_processed`** niesie prompt *w postaci złożonej* — notatkę o umiejscowieniu i dostarczony blok ponad tym, co wpisał odwiedzający — czyli tekst operatora, a nie coś, co odwiedzający ma odczytywać z powrotem. **`ask_user` i `tool_approval_required` nie mają tu nikogo, kto by na nie odpowiedział, ale zawodzą inaczej.** Odwiedzający nie może zatwierdzić efektu ubocznego w cudzej organizacji, więc `tool_approval_required` **parkuje** run dokładnie tak, jak robi to na kanale, a tura kończy się ramką `error` mówiącą, że musi zdecydować człowiek — inaczej niż na kanale, bez linku do `/runs`, bo tam czytelnikiem jest członek, który może go otworzyć, a tutaj obcy trzymający link. `ask_user` **nie** parkuje: `AgentDeps.ask_user` jest na tej powierzchni `None`, więc narzędzie *odmawia*, gdy model je wywoła (`app/agents/ask_user.py`), a model idzie dalej i odpowiada bez tego wejścia — odpowiedź zdegradowana, a nie zaparkowany run. **Klient ignoruje to, czego nie rysuje**, a `widget.js` jest tego rozpracowanym przykładem: czyta `model_request_start`, `text_delta`, `final_result`, `complete` i `error` dla odpowiedzi, `tool_call` i `tool_result`, by pokazać każdy krok jako etykietę (*Przeszukuję bazę wiedzy*), oraz `sources` dla dokumentów, które odpowiedź wymieniła. Rozumowanie ignoruje celowo. Nosi twarz agenta, renderuje odpowiedź jako Markdown do węzłów, a nie do HTML, dopasowuje się do jasnego lub ciemnego motywu odwiedzającego i na telefonie zajmuje cały ekran. **Kody zamknięcia** | Kod | Znaczenie | |---|---| | `4003` | Odmowa. Origin jest niedozwolony, token nie przeszedł albo widget jest zapauzowany. Nie ponawiaj — odpowiedź się nie zmieni. | | `4029` | Za dużo połączeń z tego adresu w ostatniej minucie. Odczekaj i spróbuj ponownie. | | `1011` | Ten klient nie czytał. Ramka potrzebowała ponad 30 sekund, by do niego dotrzeć, więc serwer przestał pisać, zamiast trzymać sesję bazodanową tury i otwarty strumień providera raz na ramkę. Połącz się ponownie; hostowana strona wznawia swój wątek. | Odmowa to celowo jeden kod z jednym komunikatem. Strona, której nie ma na liście dozwolonych, dowiaduje się, że jest niedozwolona, i niczego o tym, czy token by pomógł. `4029` jest od niej oddzielony z odwrotnego powodu: „niedozwolone" i „dozwolone, ale za szybko" proszą klienta o przeciwne rzeczy — przestań na zawsze i spróbuj ponownie później — więc klient, który ich nie rozróżnia, albo młóci w odmowę, albo porzuca limit. Ile połączeń dostaje adres, mówi `RATE_LIMIT_EMBED_PER_MINUTE`; ile *wiadomości* dostaje odwiedzający po połączeniu, mówi własny rate limit widgetu, ustawiany w Builderze. Minimalny klient: ```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` jest przypisywany, a nie doklejany: to jest to, czym run się *zakończył*, a provider, który nie przysłał strumieniem żadnych delt, zostawia go jako jedyną kopię odpowiedzi. --- ## Co oferuje każda powierzchnia i dlaczego różnice są różnicami { #what-each-surface-offers-and-why-the-differences-are-differences } Trzy powierzchnie uruchamiają tego samego agenta przez ten sam runner: czat w dashboardzie, ten socket i publiczne API. **Nie** oferują tego samego uruchomienia, a do [#936](https://github.com/vstorm-co/agenticos/issues/936) nic nie mówiło, które z tych różnic były decyzjami. Teraz mówi. Każde „nie” poniżej ma powód, a powód to albo „tutaj byłoby to złe”, albo „to jeszcze nie jest zbudowane” — nigdy milczenie. Ostatnia kolumna to Teams (#1985), reprezentujący stojące obok platformy czatu. | | `/chat` (dashboard) | Surowy WebSocket | Publiczne API | Teams | |---|---|---|---|---| | Streaming | tak | tak, filtrowany tym, co pokazuje operator | **nie** — POST jest ścieżką bez streamingu; wariant SSE to osobne pytanie | wiadomość przepisywana w miarę, jak przychodzi odpowiedź | | Załączniki | tak | tak (`file_ids`) **na hostowanej stronie** — endpoint wgrywania rozwiązuje klucz przez `find_page`, więc widżet ani surowy socket nie mają ścieżki, która wytworzyłaby id | tak (`file_ids`), i trafiają tam, gdzie wszędzie indziej | tak, z czatu osobistego; plik wraca przez kartę zgody Teams | | Ciągłość rozmowy | tak | tak (`continuity_key`), na hostowanej stronie z anonimowym odwiedzającym | tak (`conversation_id`) | czat albo wątek na kanale | | `environment_id` | tak | **nie, celowo** — patrz niżej | tak | **nie** — decyduje powiązanie, jak na każdej platformie czatu | | Nadpisanie modelu | tak | **nie, celowo**: publiczna powierzchnia nie może pozwolić odwiedzającemu wybrać, co wydaje | **nie**: żądanie nie niesie `model_profile_id`. Która wersja się uruchomi, wciąż zależy od wywołującego przez `environment_id`, a środowisko może przypiąć wersję, która nigdy nie była domyślna | **nie**, jak na każdej platformie czatu | | `ask_user` | tak | **nie, jeszcze nie** — patrz niżej | **nie, słusznie**: nikt nie czeka na odpowiedź, trzymając otwarty request HTTP | tak, jako karta Adaptive Card | | Powiadomienie o kompakcji | tak | tak | nie dotyczy — nic nie streamuje, żeby powiedzieć | nie — odpowiedź przychodzi, gdy jest gotowa | | Ramki delegacji | tak | **nie, celowo**: podpięcie sinka każe bibliotece otworzyć *streamowany* request na dziecko, więc delegat, którego dostawca nie potrafi streamować, psuje się w momencie, w którym ktoś patrzy | nie dotyczy — nic nie streamuje, w czym by je rysować | **nie** — odpowiedź przychodzi, gdy jest gotowa, bez rysowania kroków | | Zatwierdzenia: parkują uruchomienie | tak | tak | tak | tak | | Zatwierdzenia: droga do decyzji | tak (`/runs`) | jedno zdanie, celowo: obcemu trzymającemu link nie pokazuje się URL-a do czyjejś konsoli | tak — wynik niesie `parked`, nazywając to, co czeka, i zatwierdzenie, do którego wysłać decyzję | tak — karta Adaptive Card, rozstrzygana jako członek, który ją nacisnął | | Wzmianki `@` | tak (`mentions`) | **nie, jeszcze nie**: widget nie ma menu do wyboru, a wzmianka gościa wymagałaby tej samej kontroli w jego imieniu | **nie, jeszcze nie**: skrypt nazywa, czego użyć, w samej wiadomości | **nie**, jak na każdej platformie czatu | ### `environment_id` na sockecie i dlaczego należy do publikującego { #environment_id-on-the-socket-and-why-it-is-the-publishers } API je ma, bo wołającym jest organizacja: przećwiczenie środowiska deweloperskiego przed promocją to jej własna praca na jej własnym agencie. Wołającym socketa jest ten, kto trzyma klucz embeda, a na widgecie jest to przeglądarka obcej osoby. Ramka wybierająca środowisko pozwoliłaby odwiedzającemu wybrać, która *wersja* agenta mu odpowiada — łącznie z tą, której organizacja nie wypromowała, co jest przeciwieństwem tego, co znaczy publikowanie wersji. Więc w tym kształcie jest odrzucona. Jeśli integracja first-party potrzebuje uruchomić nazwane środowisko, miejscem na to jest konfiguracja samego embeda, gdzie publikujący wybiera raz, a każdy odwiedzający dostaje tę samą odpowiedź. ### `ask_user` na sockecie i czego by potrzebował { #ask_user-on-the-socket-and-what-it-would-need } Docstring modułu `ask_user` mówi, że pauza i wznowienie żyją „w sesji WebSocket”, i ma na myśli sesję *czatu*. Ten socket jest równie żywy, z człowiekiem równie siedzącym po drugiej stronie — więc delegat, który by zapytał, dostaje informację, że nikogo nie dało się złapać, co jest właściwą odpowiedzią dla uruchomienia z harmonogramu i niewłaściwą tutaj. Nie jest to tu domknięte, bo brakująca połowa nie należy do serwera. Słownik ramek nie ma `ask_user` ani `ask_user_response`, a widget nie ma formularza, żeby je wyrenderować — więc podpięcie callbacku parkowałoby uruchomienia na pytaniu, na które nic nie odpowie, co jest gorsze niż odmowa. Czego to wymaga: dwóch ramek, formularza w widgecie i decyzji o *hostowanej stronie*, gdzie pytany jest obcym, a pytanie może nieść cokolwiek, co agent postanowił powiedzieć. ### Zatwierdzenia na sockecie: zdecydowane i celowo nieposzerzone { #approvals-on-the-socket-decided-and-deliberately-not-widened } Zaparkowane uruchomienie odpowiada odwiedzającemu „To wymaga czyjegoś zatwierdzenia, zanim będzie mogło się wykonać”, i tak zostaje. Surowy socket jest sprzedawany jako interfejs, który budujesz sam — aplikacja mobilna, kiosk — i klientowi first-party z zalogowanym użytkownikiem można by w zasadzie wręczyć zaparkowane wywołanie i sposób na jego rozstrzygnięcie. Nie jest, bo klucz embeda nie identyfikuje *członka*: identyfikuje opublikowaną powierzchnię wdrożenia, a osobą po drugiej stronie może być ktokolwiek, kogo integracja wpuściła. Rozstrzygnięcie zatwierdzenia to akt członka, bramkowany przez `approvals:decide`, i należy do powierzchni, która wie, kto pyta. ## Hostowana strona { #a-hosted-page } Najkrótsza integracja, jaka istnieje: **wyślij komuś link.** Żadnej własnej strony, żadnego tagu `