Agent Embed Protocol
The embed SDK and the script-tag loader both speak the same HTTP protocol: two calls to hold a conversation, and an optional third to let the agent read the page it is on. This page documents that protocol, for when you are not using either — a native app, a server-side proxy, a framework we do not ship a package for, or a debugging session where you need to see what the component is actually sending.
If you just want an agent on your site, use Embed an Agent on Your Own Site instead. This page is the layer underneath it.
Base URL
https://agents.opvs.aiThis is the OPVS agent runtime, not the SpiderPublish API. It is a different host from https://spideriq.ai/api/v1 and takes a different credential.
Authentication is by origin, not by key
There is no API key in this protocol.
You call POST /v1/embed/session from the browser with the page's own origin. The runtime checks that origin against the agent flow's allowed list and, if it matches, mints a short-lived token. That token authorises the turn calls that follow.
This is why the snippet you paste into a page is safe to paste: it carries a flow id and nothing else. It is also why a correct-looking embed can mount and never speak — the origin was never allowed.
Get a flow id with spideriq agent list. There is no dashboard screen that creates an agent flow; flows come from the agent_flow_create MCP tool or a bearer-authenticated POST. See The CLI.
POST /v1/embed/session
Opens a conversation and returns the token its turns are authorised by.
Parameters
Name | Type | Required | Description |
|---|---|---|---|
| string (uuid) | yes | The agent flow's id. Must be the full UUID — a shortened prefix returns |
| string | yes | Scheme and host the embed runs on. Must already be on the flow's allowed list. |
Example
const session = await fetch("https://agents.opvs.ai/v1/embed/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
flow_id: "732f8fd0-4cad-43b0-a7e6-7631b0d30bc4",
origin: "https://get-vayapin.com",
}),
});
if (session.status === 403) throw new Error("origin not on the agent's allowed list");
const { token, turn_url, expires_in } = await session.json();
console.log(expires_in); // → 900Response — 200 OK.
Field | Description |
|---|---|
| A JWT, valid for |
| Seconds. Currently |
| Where to POST turns. Read it from the response rather than hard-coding it. |
| Where to upload page text, if page reading is available for this flow. Absent or |
| The agent's public persona — name, role title, tagline, avatar — for rendering a header before the first reply. |
Errors
Status | When | Resolution |
|---|---|---|
|
|
|
| no flow with that id | check |
| usually a truncated | send the full UUID. A short id fails this way on every request, including ones that should be |
POST /v1/embed/turn
Sends one user message and streams the reply back as Server-Sent Events.
Parameters
Name | Type | Required | Description |
|---|---|---|---|
| string | yes | From the session response. |
| string | yes | Your own id for this turn, used to correlate frames with the message you sent. |
| string | yes | The user's message. |
| string | no | The page the visitor is on. Recorded, and used as the pointer to any page text you uploaded — but on its own it does not give the agent the page's content. See |
Example
const turn = await fetch(turn_url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, turn_id: "turn-1", input: "What do you do?" }),
});
if (turn.status === 401) throw new Error("session expired, open a new one");
const reader = turn.body.getReader();
const decoder = new TextDecoder();
let reply = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n")) {
if (!line.trim()) continue;
const frame = JSON.parse(line.replace(/^data: /, ""));
if (frame.type === "text-delta") reply += frame.delta;
}
}
console.log(reply); // → "VayaPin turns any location into a precise, shareable smart address."Response — 200 OK, Content-Type: text/event-stream. The body is a stream of JSON frames, not a single document.
Frame | Meaning |
|---|---|
| The turn was accepted and the agent is working. |
| The reply is beginning. |
| One chunk of reply text, in |
| The reply text is complete. |
| The turn is over. Stop reading. |
Concatenate every text-delta's delta to assemble the reply. A short answer is typically four to six deltas.
Errors
Status | When | Resolution |
|---|---|---|
| the token expired | open a new session; tokens last |
| the token was minted for a different origin | re-open the session from the page that will use it |
| unknown | read |
POST /v1/embed/context
Uploads the text of the page the visitor is on, so the agent can answer questions about it.
This call is what page reading is. page_url on a turn records where the visitor is; it never carries what is on the page. Until you POST the page here, the agent has nothing to read, and it will say so rather than guess — which is the correct behaviour, not a fault.
Both halves of the opt-in belong to the host page, which is why this works on a site you host yourself and does nothing on a page SpiderPublish serves for you. Page-Grounding covers the SDK attribute that drives it.
Where to send it. Read context_url from the session response. Do not compose the URL — a session that returns no context_url has page reading switched off at the runtime, and posting anyway will not turn it on.
Parameters
Name | Type | Required | Description |
|---|---|---|---|
| string | yes | The session token, exactly as on a turn. No new credential. It is the auth field and nothing else — never repeat it inside |
| string | yes | The page the markdown was captured from. Its origin must be on the flow's allowed list, matched exactly. Also half of the dedup key. |
| string | yes | The page's visible text as Markdown. Budget it to 8 KB UTF-8 before sending. |
Example
// `context_url` came from the session response, alongside `token` and `turn_url`.
const res = await fetch(context_url, {
method: "POST",
headers: { "Content-Type": "application/json" },
keepalive: true,
body: JSON.stringify({
token,
url: window.location.href,
markdown: "# Pricing\n\nTeam is $49 per seat per month, billed annually.",
}),
});
if (res.status === 413) throw new Error("page text over 32 KB — budget it to 8 KB");
console.log(res.status); // → 204Response — 204 No Content, with no body.
The page is held against the session only: transient, expiring with it, and never persisted. The agent does not receive it as part of a turn. OPVS adds a one-line hint that a page is available, and the agent pulls the body through a scoped get_page_context() tool — so it can read the page you uploaded and nothing else. There is no arbitrary fetch in this path.
Errors
Status | When | Resolution |
|---|---|---|
|
| the same allow list the session call uses — |
|
| budget to 8 KB before sending. Between 8 and 32 KB the server truncates silently rather than rejecting, so an over-budget page loses its tail without an error. |
Treat any non-204 as "this page was not delivered" and retry on the next page change rather than immediately — the upload is optional, and a failed one degrades the answer instead of breaking the conversation.
Send it once per page change, not once per turn
The upload is keyed on url plus a hash of the markdown. Re-sending the same page with the same content is wasted work and the SDK skips it. Re-send when, and only when, either changes.
On a single-page app that means hooking navigation rather than render: pushState, replaceState, popstate and hashchange each mean a new page, while a re-render at the same URL with the same content does not. Wait for the new route to paint before capturing — the SDK debounces 250 ms, which also coalesces the pushState + popstate burst a router fires as one navigation.
Send visible text, and strip it first
You are choosing what the agent may read, so the responsibility for what leaves the page is yours. Capture from a copy, not the live DOM. The SDK removes, before anything is sent: every [data-private] subtree, every <input type="password">, the values of all form fields, anything hidden by the hidden attribute / aria-hidden="true" / inline display: none or visibility: hidden, and <script>, <style>, <noscript>, <template>, <svg>, <canvas>, <iframe>, <object> and <embed>. Match that if you are building the capture yourself.
Handling the token
The token is a bearer credential for the life of the conversation. Treat it as one.
Keep it in a variable, not in the DOM, not in a data attribute, and not in
localStorage. The official SDK holds it in a closure for exactly this reason.Do not put it in your bundle or your page source. It is minted per visitor, per conversation.
Do not log it.
It is short-lived and origin-bound, so the blast radius of a leak is small. It is not nothing.
Related
Embed an Agent on Your Own Site — the SDK and script-tag paths, which implement this protocol for you.
Page-Grounding — what page reading needs, and where it applies.
The CLI — hiring an agent, listing flow ids, allowing an origin.