# Putting an agent where people already are An agent that only answers inside this dashboard is a demo. The same published agent answers in eight places, and every one of them runs the *same frozen version* through the same budget, the same approval gate and the same tenant checks — the surface changes, the agent does not. | Where | What it needs | Who the visitor is | |---|---|---| | **Dashboard** | nothing | a signed-in member | | **Website widget** | a ` ``` That is the whole integration. The script has no dependencies, no build step and no framework — it runs on a page that already loads React, jQuery or nothing at all. ### 3. (Optional) tell it about the visitor A widget can **declare variables** - a name, whether it is required, and a line saying what it is for - and the page supplies them: ```html ``` The snippet the Builder hands you already carries that line, with your own keys in it, once you have declared any. They are appended to the agent's instructions as a marked block of data, under a line saying they are information about the visitor rather than instructions, and that they cannot be verified. That last part is true of every one of them, including on a `jwt` widget: the widget reads `window.AgenticOSContext`, and a token authenticates *who the visitor is* rather than *what the page said about them*. So nothing here may decide what the agent is allowed to do. Three rules follow from that: - **A key nobody declared is dropped.** The page is something a visitor can edit; without a declaration, any key they invented would become a line inside an agent's instructions. - **A missing required value omits its line and is logged.** `required` is a promise between an integrator and themselves - enforcing it would cost a visitor their answer over somebody else's deployment mistake. - **Sent once per conversation**, ahead of the first question, and read from every frame rather than at connect time: a single-page application learns who somebody is without reconnecting. ### 4. (Optional) tell it who the visitor is For a widget inside your own logged-in product, set a token **before** the script loads. Your backend signs it; we verify it and never see your user database: ```html ``` Minting one, in any language that can sign a 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` is required. It identifies the visitor for rate limiting, and a token without one is refused — otherwise a single leaked token becomes the whole widget's budget. - **`iat` is required and must be within the last 12 hours** — it is not checked only when present. A token with no `iat`, or a stale one, is refused, so one that leaks out of a browser cannot work forever. An `exp` you set is honoured too, but only to **shorten** that window (an expired token is rejected); it cannot extend a token past the 12-hour ceiling. - Mint it per page load, server-side. !!! danger "Never ship the signing secret to a browser" It signs *any* visitor. The token it mints is the thing the browser is allowed to hold, and only for the twelve hours `iat` gives it. --- ## The raw WebSocket The widget is a client of a documented protocol, not a black box. If you want your own UI — a mobile app, a kiosk, a component in your design system — talk to the same socket: ``` wss://your-api.example.com/api/v1/embed/PUBLIC_KEY/ws[?token=SIGNED_JWT] ``` **You do not have to assemble that yourself.** Publish one from the Builder — the agent → **Availability** → *Raw WebSocket* — and its row prints the URL, built from the deployment's own base URL, with a copy button. A **widget** prints the same thing beside its script tag, because a widget is a client of this protocol: moving to an interface of your own is a step rather than a rewrite. The `?token=` is not printed there: in `jwt` mode the token is minted per visitor by your backend, and a real one on a dashboard screen is a working credential somebody can read over a shoulder. !!! bug "The first thing that goes wrong: a missing `Origin`" The handshake must carry one on the embed's allow-list. **A browser sends it for you; a client of your own sends nothing unless you set it** - a mobile app, a kiosk, a server-side relay. What it looks like when it does not is `4003` in the table below, not an error message. ```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 you send** ```json { "type": "message", "text": "Do you ship to Poland?" } ``` That is the whole inbound vocabulary, plus `context` (what the page says about the visitor) and `file_ids` (what they attached, on a page that takes files). Every `message` frame counts toward a per-connection limit, including empty or malformed messages. Valid messages also spend the existing visitor allowance; malformed input cannot exhaust a bucket shared with other anonymous visitors. A frame may name at most three file IDs; oversized lists are refused before parsing. Invalid IDs in a bounded list are dropped and recorded as one aggregate log entry without their values. **It deliberately does not include the three fields the dashboard's own frame carries:** the agent, the model profile and the environment. A frame that could choose a model is a visitor choosing one on the operator's bill, and one that could choose an agent is a visitor talking to something nobody published on this key. All three come off the embed row. An unknown field is **ignored rather than refused**. A client cached in somebody's browser may be older than this server, and closing the socket over it would take the conversation with it. **Frames you receive** !!! note "This is the dashboard's own frame vocabulary, not a second one" `/chat` and this socket drive one loop (`app/services/run_stream.py`), so an answer arrives here a word at a time the same way it does there. A hosted page used to show one lump of text after thirty seconds of nothing, and that was the loop rather than the transport. Every frame carries `{ "type": …, "data": { … } }`. | `type` | `data` | Meaning | |---|---|---| | `ready` | `visitor` | Connected. `visitor: true` when a token identified the person. | | `history` | `messages` | What was said in the thread this visitor is resuming — **a hosted page whose visitor is anonymous**, which is the only connection that carries a continuity key: a widget's conversation lasts as long as its socket, and a `jwt` visitor is named by their token. Each entry is `role`, `text` and `at`, so a replayed turn keeps the time under it. | | `model_request_start` | — | The agent has gone to the model. Show an indicator. | | `part_start` | `index`, `part_type`; `tool_name`, `tool_call_id` for a `ToolCallPart` | A block of the answer is starting. A tool call names itself here, so a client can show it while its arguments stream. Sent only for a block this surface will actually carry — a page showing no reasoning does not announce a `ThinkingPart`, since the announcement alone says the agent reasoned. | | `text_delta` | `index`, `content` | Words of the answer. Append them. | | `thinking_delta` | `index`, `content` | The model's reasoning. **Only if the operator turned it on.** | | `call_tools_start` | — | The agent is about to use tools. | | `tool_call` | `tool_call_id`, `tool_name`, `args` | A step. `args` only when the operator shows results. | | `tool_call_delta` | `index`, `args_delta` | A call's arguments as they stream. | | `tool_result` | `tool_call_id`, `content` | What the step returned. | | `sources` | `names` | The documents a knowledge search returned, by name. **Only on a widget whose operator turned sources on**, and never the passages. | | `final_result_start` | `tool_name` | The answer is being produced by an output tool. | | `final_result` | `output` | What the run ended with. Empty on a turn that parked. | | `complete` | — | The turn is over. It carries **no usage**: what a run cost is the operator's business, not the visitor's. | | `error` | `message` | Something the visitor should see: rate limit, budget reached, a refusal, a turn that produced nothing. | | `compaction_started`, `compaction_finished`, `compaction_impossible` | per frame | The agent is tidying its own notes because the thread outgrew the model's window. Always sent, whatever the operator's switches: a summary takes tens of seconds, and a surface that streams and says nothing simply stops for the length of it ([#936](https://github.com/vstorm-co/agenticos/issues/936)). | Some dashboard frames never reach a public socket, and they are refusals rather than settings. **`user_prompt_processed`** carries the prompt *as assembled* — the placement note and the supplied block above what the visitor typed — which is the operator's text and not the visitor's to read back. **`ask_user` and `tool_approval_required` have nobody here to answer them, but they fail differently.** A visitor cannot approve a side effect on somebody else's organization, so `tool_approval_required` **parks** the run exactly as it does on a channel, and the turn ends with `error` saying a person has to decide — unlike a channel, without the `/runs` link, because the reader there is a member who can open it and here they are a stranger holding a link. `ask_user` does **not** park: `AgentDeps.ask_user` is `None` on this surface, so the tool *refuses* when the model calls it (`app/agents/ask_user.py`) and the model carries on to answer without the input — a degraded answer, not a parked run. **A client ignores what it does not draw**, and `widget.js` is the worked example: it reads `model_request_start`, `text_delta`, `final_result`, `complete` and `error` for the answer, `tool_call` and `tool_result` to show each step as a chip (*Searching the knowledge base*), and `sources` for the documents an answer named. It ignores the reasoning on purpose. It wears the agent's face, renders the answer as Markdown into nodes rather than HTML, follows the visitor's light or dark setting, and fills a phone's screen. **Close codes** | Code | Meaning | |---|---| | `4003` | Refused. The origin is not allowed, the token failed, or the widget is paused. Do not retry — the answer will not change. | | `4029` | Too many connections from this address in the last minute. Back off and retry. | | `1011` | This client was not reading. A frame took longer than 30 seconds to reach it, so the server stopped writing rather than hold the turn's database session and the open provider stream once per frame. Reconnect; a hosted page resumes its thread. | The refusal is deliberately one code with one message. A page that is not on the allow-list learns that it is not allowed and nothing about whether a token would have helped. `4029` is separate from it for the opposite reason: "not allowed" and "allowed but too fast" ask a client for opposite things — stop for ever, and try again later — so a client that cannot tell them apart either hammers a refusal or abandons a limit. How many connections an address gets is `RATE_LIMIT_EMBED_PER_MINUTE`; how many *messages* a visitor gets once connected is the widget's own rate limit, set in the Builder. A minimal 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` is assigned rather than appended: it is what the run *ended* with, and a provider that streamed no deltas leaves it as the only copy of the answer. --- ## What each surface offers, and why the differences are differences Three surfaces run the same agent through the same runner: the dashboard's chat, this socket, and the public API. They do **not** offer the same run, and until [#936](https://github.com/vstorm-co/agenticos/issues/936) nothing said which of the differences were decisions. They are now. Every "no" below has a reason, and a reason is either "this would be wrong here" or "this is not built yet" — never silence. The last column is Teams (#1985), standing for the chat platforms beside them. | | `/chat` (dashboard) | Raw WebSocket | The public API | Teams | |---|---|---|---|---| | Streaming | yes | yes, filtered by what the operator shows | **no** — the POST is the non-streaming path; an SSE variant is a separate question | a message rewritten as the answer arrives | | Attachments | yes | yes (`file_ids`) **on a hosted page** — the upload endpoint resolves the key through `find_page`, so a widget or a raw socket has no route that would produce an id | yes (`file_ids`), and they route the way they do everywhere else | yes, from a personal chat; a file goes back through Teams' consent card | | Conversation continuity | yes | yes (`continuity_key`), on a hosted page with an anonymous visitor | yes (`conversation_id`) | a chat, or a channel thread | | `environment_id` | yes | **no, deliberately** — see below | yes | **no** — the binding decides, as on every chat platform | | Model override | yes | **no, deliberately**: a public surface must not let its visitor choose what they are spending | **no**: the request carries no `model_profile_id`. Which version runs is still the caller's through `environment_id`, and an environment can pin one that was never the default | **no**, as on every chat platform | | `ask_user` | yes | **no, not yet** — see below | **no, correctly**: nobody is waiting on an HTTP request to answer a question | yes, as an Adaptive Card | | Compaction notice | yes | yes | n/a — nothing is streaming to tell | no — the answer arrives when it is done | | Delegation frames | yes | **no, deliberately**: attaching a sink makes the library open a *streamed* request per child, so a delegate whose provider cannot stream breaks the moment somebody watches it | n/a — nothing is streaming to draw them in | **no** — the answer arrives when it is done, its steps undrawn | | Approvals: parks the run | yes | yes | yes | yes | | Approvals: a path to the decision | yes (`/runs`) | one sentence, deliberately: a stranger holding a link is not shown a URL into somebody's console | yes — the result carries `parked`, naming what is waiting and the approval to post to | yes — an Adaptive Card, decided by the member who presses it | | `@`-mentions | yes (`mentions`) | **no, not yet**: a widget has no menu to pick from, and a visitor's mention would need the same as-them check | **no, not yet**: a script names what to use in the message itself | **no**, as on every chat platform | ### `environment_id` on the socket, and why it is the publisher's The API has it because the caller is the organization: exercising a dev environment before promoting it is their own work on their own agent. A socket's caller is whoever holds the embed key, and on a widget that is a stranger's browser. A frame that chose the environment would let the visitor pick which *version* of the agent answers them — including one the organization has not promoted, which is the opposite of what publishing a version means. So it is declined in that shape. If a first-party integration needs to run a named environment, the place for it is the embed's own configuration, where the publisher chooses once and every visitor gets the same answer. ### `ask_user` on the socket, and what it would need `ask_user`'s module docstring says the pause and resume live "in the WebSocket session", and it means the *chat's* session. This socket is equally live, with a person equally sitting there — so a delegate that would ask is told nobody could be reached, which is the right answer for a scheduled run and the wrong one here. It is not closed here because the missing half is not the server's. The frame vocabulary has no `ask_user` and no `ask_user_response`, and the widget has no form to render one — so attaching the callback would park runs on a question nothing can answer, which is worse than declining. What it needs: the two frames, the widget's form, and a decision about a *hosted page*, where the person being asked is a stranger and the question may carry anything the agent chose to say. ### Approvals on the socket: decided, and deliberately not widened A parked run answers a visitor with "That needs somebody to approve it before it can run", and that stays. The raw socket is sold as an interface you build yourself — a mobile app, a kiosk — and a first-party client with a signed-in user could in principle be handed the parked call and a way to decide it. It is not, because the embed key does not identify a *member*: it identifies a deployment's published surface, and the person on the other end may be anybody the integration let in. Deciding an approval is a member's act, gated on `approvals:decide`, and it belongs on a surface that knows who is asking. ## A hosted page The shortest integration there is: **send somebody a link.** No site of your own, no `