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.ai

This 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

flow_id

string (uuid)

yes

The agent flow's id. Must be the full UUID — a shortened prefix returns 500, not 404.

origin

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); // → 900

Response200 OK.

Field

Description

token

A JWT, valid for expires_in seconds. Send it on every turn. Keep it in memory only.

expires_in

Seconds. Currently 900. Re-open a session when it lapses; do not hard-code the number.

turn_url

Where to POST turns. Read it from the response rather than hard-coding it.

context_url

Where to upload page text, if page reading is available for this flow. Absent or null means it is not — see POST /v1/embed/context.

agent

The agent's public persona — name, role title, tagline, avatar — for rendering a header before the first reply.

Errors

Status

When

Resolution

403

origin is not on the flow's allowed list

spideriq agent allow-origin <flow_id> https://your-site.com. The most common cause of an agent that mounts and stays silent.

404

no flow with that id

check spideriq agent list

500

usually a truncated flow_id

send the full UUID. A short id fails this way on every request, including ones that should be 404, so a run of 500s reads like an outage when it is a malformed id.

POST /v1/embed/turn

Sends one user message and streams the reply back as Server-Sent Events.

Parameters

Name

Type

Required

Description

token

string

yes

From the session response.

turn_id

string

yes

Your own id for this turn, used to correlate frames with the message you sent.

input

string

yes

The user's message.

page_url

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 POST /v1/embed/context.

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."

Response200 OK, Content-Type: text/event-stream. The body is a stream of JSON frames, not a single document.

Frame

Meaning

start

The turn was accepted and the agent is working.

text-start

The reply is beginning.

text-delta

One chunk of reply text, in delta. Repeats.

text-end

The reply text is complete.

finish

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

401

the token expired

open a new session; tokens last expires_in seconds

403

the token was minted for a different origin

re-open the session from the page that will use it

404

unknown turn_url or flow

read turn_url from the session response rather than hard-coding it

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

token

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 markdown.

url

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.

markdown

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); // → 204

Response204 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

403

url's origin is not on the flow's allowed list

the same allow list the session call uses — spideriq agent allow-origin <flow_id> https://your-site.com. Send the page's real URL; a placeholder will be rejected here even though the session succeeded.

413

markdown is over 32 KB

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

Publish