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