WS /ws — the frame protocol
For developers, in code: the streaming conversation the widget speaks — every client and server frame with its fields, the error codes, the ordering guarantee, and the page-actions bridge.
WS /ws is how the widget talks. One socket per page; the widget key rides every frame; the
handshake’s Origin is checked together with the key on each frame; an authentication failure sends an error frame and closes
the socket with code 1008.
Client → server
The message frame — no type. The fields of POST /message
plus widget_key:
{
"widget_key": "wk_…",
"agent_code": "default",
"widget_code": "default",
"message": "Take me to pricing",
"conversation_id": "…",
"page_url": "https://…",
"page_lang": "en",
"session_id": "…",
"user_token": "…",
"spoken": false,
"visitor": {
"referrer": "…",
"utm_source": "…",
"browser_language": "en-GB",
"timezone": "Europe/Kyiv"
},
"page": {
"readable": true,
"actionable": true,
"counts": { "input": 4, "button": 2 },
"submit_policy": "confirm"
}
}
page says what this turn may do on the screen: readable binds the screen-reading tool,
actionable binds do_page_action, submit_policy echoes the owner’s setting. Without page
no page tools are bound and no bridge frames are sent.
| Frame | Fields | Notes |
|---|---|---|
proactive_tick |
widget_key, type, agent_code, page_url, page_lang, user_token?, shown_ago_ms? |
starts no conversation; always answered with exactly one proactive_teaser or proactive_none |
voice_timing |
widget_key, type, message_id, done_ms, first_audio_requested_ms, first_audio_played_ms |
telemetry for a heard answer; no reply; a malformed frame is dropped silently |
page_elements |
type, req_id, elements[] |
the reply to page_elements_request — the manifest |
page_done |
type, req_id, results[], added, removed, addedTotal, removedTotal, pressed, stoppedAt, plannedSteps, rows |
the reply to page_do — per-step results plus what the batch put on the screen or removed |
page_scrolled |
type, req_id, ok, reason?, moved?, at_top?, at_bottom?, section?, next?, handled_by_host? |
the reply to page_scroll / page_scroll_by |
Server → client
| Frame | Fields | When |
|---|---|---|
start |
conversation_id |
the turn began; the id to keep |
lang |
language |
before the first token; re-sent if the answer’s language turns out different |
token |
text |
a piece of the answer, as it streams |
reasoning |
text |
the model’s reasoning, only when the owner turned extended thinking on |
speakable |
— | advisory: the tokens that follow may be spoken while they stream |
step |
role: thinking · answer |
thinking means the text just streamed was a tool preamble to retract, not the answer |
done |
message_id, answer, sources[], actions[], nav_suggestions[], confirm_strings?, metadata |
the turn ended; confirm_strings (confirm.accept/decline/press/dontPress, in the answer’s language) only when an action is held with confirm: true |
limit_reached |
conversation_id, message |
the monthly cap; sent after start instead of an answer |
error |
code, message |
see below; the socket stays open except on an auth code |
proactive_teaser |
text (≤ 300), action: { kind: "navigate", url, route } · { kind: "ask", text } · null, codes[] |
a finding to show |
proactive_none |
— | nothing to say |
page_elements_request |
req_id, reason?: "prefetch" |
scan the screen and reply with page_elements |
page_do |
req_id, steps[{ op, target, value?, checked? }] |
apply the steps and reply with page_done; target is a manifest id, $new[.n], or list[n].field; value may be a { lang: text } map |
page_scroll |
req_id, target |
scroll to a named place |
page_scroll_by |
req_id, direction: down · up · top · bottom |
scroll the page |
Error codes
code |
When | Socket |
|---|---|---|
bad_request |
not a JSON object, or the message failed validation | open |
unauthorized · forbidden |
the key or the origin | closed 1008 |
no_agent · not_found |
no agent on the widget; unknown agent or conversation | open |
rate_limited |
the account’s per-minute limit, or this visitor’s per-minute or daily one; no credit spent | open |
widget_off |
the widget the frame names is switched off, or every widget of the site is | open |
agent_error |
generation failed | open |
busy |
the message was deferred past the replay cap and not run — send it again | open |
declined |
the model declined to answer this message; message is empty, nothing is stored |
open |
Ordering
Every message and every tick gets exactly one answer, in arrival order. A frame typed while a
page-actions turn is running is deferred, not dropped, and replayed once the turn ends; past
a depth of two further messages get busy. The widget’s composer is never disabled, which is why
this rule exists.
Timeouts the widget must respect
A bridge request (page_elements_request, page_do, page_scroll*) is waited for 5 s; the
turn-start prefetch (reason: "prefetch") 1.5 s — an unanswered one means no screen this turn,
never «the screen is empty». At most 40 bridge round trips per turn and 40 steps per
page_do.
A minimal client
const ws = new WebSocket(`${agentBase.replace(/^http/, "ws")}/ws`);
ws.onopen = () => ws.send(JSON.stringify({ widget_key, agent_code: "default", message: "Hi" }));
ws.onmessage = ({ data }) => {
const f = JSON.parse(data);
if (f.type === "token") append(f.text);
if (f.type === "done") finish(f.answer, f.actions, f.nav_suggestions);
if (f.type === "page_elements_request")
ws.send(JSON.stringify({ type: "page_elements", req_id: f.req_id, elements: [] }));
};
A client that does not implement the bridge should not send page; a client that receives a
bridge frame it cannot answer should reply with an empty result rather than stay silent.