Skip to content
Console

Build your own chat widget

Replace Espejo's chat widget with a support chat UI of your own. Use createChatClient on top of our backend, bring your own model with espejo.agent, or speak the HTTP protocol directly.

Our chat widget is one way to put the support agent on your site. When it does not fit your design, your framework or your product, you can build the chat yourself and still use as much of Espejo as you want. This guide covers three levels, from the least work to the most.

What you doWhat Espejo does
1. Your UI on our backend (createChatClient, recommended)The interface: bubbles, input, confirmation, errors.The provider and its key, the tool loop, the actions on the page, guides, tickets, the daily cap and the conversation.
2. Your UI and your own model (window.espejo.agent)The interface, the model, its key and the tool loop, on your server.The page context and the actions on the page.
3. The HTTP protocol directlyThe interface, the stream reader and the action loop.The same backend as level 1.

Level 1 is what our widget runs on. You get the same behavior (retries, the queue, confirmation, identity) and only draw the interface. Most of this guide is about it.

Level 2 does not use Espejo’s chat backend at all: your code reads the page with getContext(), offers the tools with tools() to your model and runs what it asks for with act(), with a policy and a confirmation handler. It is documented in full in Path 1: your own chat and your own agent.

Level 3 is for when you cannot or do not want to load our script for the chat. You still need espejo.agent in the page to run the actions. See The HTTP protocol.

Levels 1 and 3 need the support agent configured and on in the console (the Support agent tab), exactly like the widget.

Load espejo.chat.js without data-key. Without a key it mounts nothing: no button, no panel. It leaves window.EspejoChat with createChatClient, installPresence, installChatWidget and identify, plus chat, the mounted widget, which stays null without a key:

<script src="https://app.espejo.dev/sdk/espejo.chat.js"></script>
<script>
const { createChatClient } = window.EspejoChat;
</script>

Load it without async or defer if an inline script right after it uses window.EspejoChat, or wait for the load event. The bundle brings its own copy of the espejo.agent layer, so it runs the actions on its own. It works with or without espejo.js: when the recorder is on the page, the agent also sends the visitor’s recent actions and errors.

EspejoChat.identify belongs to the mounted widget. With your own UI, set the identity on your client instead (see Identity and tickets).

A complete chat in plain HTML and JavaScript: it streams the answer, shows each action while it runs, asks before acting and shows errors.

<section id="support">
<ol id="log"></ol>
<form id="form">
<input id="input" autocomplete="off" placeholder="Ask anything" />
<button>Send</button>
</form>
</section>
<script src="https://app.espejo.dev/sdk/espejo.chat.js"></script>
<script>
const log = document.getElementById("log");
const rows = new Map();
let bubble = null;
// Always textContent: the answer is text, never HTML.
function line(kind, text) {
const li = document.createElement("li");
li.className = kind;
li.textContent = text;
log.append(li);
li.scrollIntoView({ block: "end" });
return li;
}
function errorText({ code, retryAfter }) {
if (code === "rate_limited") return `Too many messages. Try again in ${retryAfter ?? 60} seconds.`;
if (code === "daily_cap") return "The assistant reached today's limit. Try again tomorrow.";
if (code === "no_agent") return "Support chat is not available on this site.";
if (code === "network") return "The connection dropped. Send your message again.";
return "Something went wrong. Try again.";
}
const chat = window.EspejoChat.createChatClient({
key: "pk_live_...",
// Click, fill and navigate wait for this answer. Use your own dialog.
onConfirm: ({ label }) => window.confirm(`Let the assistant ${label}?`),
onEvent(event) {
switch (event.type) {
case "text":
bubble ??= line("assistant", "");
bubble.textContent += event.delta;
break;
case "tool_start":
bubble = null; // text after an action goes in a new bubble
rows.set(event.call.id, line("action", `${event.label}...`));
break;
case "tool": {
const row = rows.get(event.call.id) ?? line("action", "");
row.textContent = event.result.ok ? `${event.label}: done` : `${event.label}: ${event.result.error}`;
break;
}
case "done":
bubble = null;
break;
case "error":
bubble = null;
line("error", errorText(event));
break;
}
},
});
document.getElementById("form").addEventListener("submit", (e) => {
e.preventDefault();
const input = document.getElementById("input");
const text = input.value.trim();
if (!text) return;
line("visitor", text);
input.value = "";
chat.send(text); // never rejects
});
</script>

label is a short description in English (click "Save", navigate to https://example.com/billing). To write your own text, in any language, use target, value and destination, which come in the confirmation request and in the tool_start and tool events.

OptionDefaultMeaning
keynoneThe project’s public key (pk_...). Required.
baseUrlhttps://app.espejo.devBase URL of the API.
onConfirmnoneCalled before click, fill and navigate with { action, label, target?, value?, destination? }. Return true (or a promise of true) to run the action. Without it, those actions come back as confirmation_required and do not run.
onEventnoneReceives every event below. An exception thrown in it does not stop the chat.
agentone of its ownAn agent you already built, for example window.espejo.agent. Its policy is left as is. Without it, the client builds one that allows the five actions: the console decides which ones the model is offered.
allowthe five actionsNarrows the actions of the agent the client builds. Ignored with agent.
storagesessionStorageWhere the conversation id is kept. null keeps nothing, so every page load starts a new conversation.
identitynone{ email, hash }, the visitor’s verified identity.
MemberWhat it does
send(message)Sends a message right away, cutting short the turn in progress, and pauses the queue. Resolves when the client is free again. Never rejects.
enqueue(message)Sends right away when the client is free and the queue is not paused. Otherwise adds the message to the queue. Returns false when the message is blank or 5 are already waiting.
removeQueued(id)Drops a message from the queue.
editQueued(id, message)Replaces the text of a queued message. A blank text drops it.
sendQueuedNow(id)Cuts the current turn short and sends that queued message first. The rest of the queue follows.
clearQueue()Empties the queue.
resumeQueue()Takes the queue out of pause. If the client is free, it sends the first message.
reset()Cuts the current turn short, empties the queue and starts a new conversation.
abort()Cuts the current turn short, keeps the conversation and pauses the queue.
identify(identity)Changes the identity, or removes it with null. With another person it does a reset().
destroy()Cuts the current turn short, empties the queue and detaches from the agent.
getConfig()The console settings for the widget. See below.
conversationIdThe id of the current conversation.
busytrue while a turn is running.
queueThe waiting messages, in order, as { id, text }[].
pausedtrue while the queue is paused. Always false when it is empty.

The queue moves only when a turn ends with done, and pauses after an error, after abort() and after a direct send(). It lives in memory: a reload drops it. The full rules are in createChatClient: our backend, your UI.

typeFieldsWhen
textdeltaA piece of the answer. Append it to the current bubble.
tool_startcall, label, target?, value?, destination?An action is about to run, before the confirmation.
toolcall, label, target?, value?, destination?, resultThe same action after it ran or was declined. Match it with tool_start by call.id. result is { ok, error?, snapshot? }, where snapshot is the page after the action.
doneconversationIdThe assistant finished answering the message.
errorcode, message, retryAfter?The turn stopped. retryAfter (seconds) only comes with rate_limited.
queuequeue, pausedThe queue changed.
dequeuedid, messageA queued message is going out. Show it in the conversation here.

call is { id, name, input }, where name is highlight, scroll_to, click, fill or navigate. result.error is a short code such as declined, confirmation_required, stale_ref, not_allowed or cross_origin.

Each turn that runs to the end emits exactly one done or one error. A blank message is not sent, and a turn cut short by abort(), reset(), destroy(), a newer send() or sendQueuedNow() emits nothing more, not even the tool event of an action that was waiting for confirmation.

  • It trims the message and cuts it at 4,000 characters, the API’s limit. A blank message is not sent.
  • It reads the page before each request and trims the context to the API’s limits.
  • It runs the actions. When a turn ends with pending actions, it runs each one in the page (asking through onConfirm when needed) and sends the results back, until the assistant stops asking. It stops after 25 rounds in one message with iteration_limit.
  • It retries once on its own when the conversation expired (conversation_expired) or belongs to another identity (identity_changed): it starts a new conversation and sends the message again. When the conversation is busy with another request (conversation_busy, for example from another tab), it waits 1.5 seconds and retries once in the same conversation. Only if the retry fails too do you get the error event.
  • It keeps the conversation id in sessionStorage, so a reload in the same tab continues the same conversation. Only the id is kept, never the messages: after a reload your UI starts empty while the assistant still remembers. If you prefer a clean start, call reset() on load or pass storage: null.
  • It attaches the last recording. When espejo.js is on the page and the visitor made a report, the request carries its session_id, so a ticket the assistant opens can link the replay.
codeCauseWhat to show
no_agentThe project has no agent on (404).Hide your chat, or say it is not available.
forbiddenInvalid or revoked key, origin not allowed, or account deactivated (401 or 403).A generic error. Check the key and the allowed origins.
bad_requestThe API rejected the request (400, 413 or any other 4xx not listed here).A generic error.
rate_limitedMore than 30 requests per minute from one IP to the project (429).Ask to wait retryAfter seconds.
daily_capThe project reached its messages per day (429).Say the assistant is back tomorrow (midnight UTC).
conversation_limitThe conversation is too long (409).Offer a new conversation and call reset().
conversation_expiredThe retry with a new conversation also failed (409).Offer a new conversation.
conversation_busyAnother request was still answering after the retry (409).Ask to try again in a moment.
identity_changedThe retry with a new conversation also failed (409).Offer a new conversation.
conflictAny other 409.Call reset() and try again.
unavailableThe agent is misconfigured or the service is down (5xx).Try again later.
networkThe request failed or the stream closed before the end.Ask to send the message again.
provider_auth, provider_rate_limited, provider_error, timeout, iteration_limit, internalThe turn failed after the answer started. See Stream errors.A generic error. iteration_limit: ask to narrow the request.

message is readable English text from the API or the client. It never includes the provider’s response.

The owner sets the assistant’s name, icon, theme and sections in the console’s Widget tab. getConfig() reads them (GET /v1/agent/config) so your UI can follow them too:

const config = await chat.getConfig();
// { enabled, allowed_actions, theme, name, icon, sections }
  • enabled: whether the assistant answers. With false, do not show the chat.
  • allowed_actions: the actions the owner allowed, among highlight, scroll_to, click, fill and navigate.
  • theme: auto, light or dark. auto means “adapt to the page”.
  • name: the assistant’s name (up to 40 characters), or null for your own default. Always insert it as text.
  • icon: chat, sparkles, headset, bot, help, the https:// URL of an image, or null for the default.
  • sections: the sections to show, in the owner’s order, among home, support (the chat), tickets and guides. support is only there while enabled is true. tickets needs a visitor with a verified identity too: hide it when you have none.

It never rejects. When the project has nothing to show it resolves to { enabled: false, allowed_actions: [], theme: "auto", name: null, icon: null, sections: [] }, and to null when it could not be read (network, origin not allowed, server down). The browser can keep the answer for 60 seconds and the API for 30, so a change in the console can take up to about 90 seconds to reach your UI.

Our widget shows a notice with a Stop button, a colored border and a cursor while the assistant acts. You get the same with installPresence. It needs the agent that runs the actions, so load espejo.agent.js as well and give that agent to the client:

<script src="https://app.espejo.dev/sdk/espejo.agent.js"></script>
<script src="https://app.espejo.dev/sdk/espejo.chat.js"></script>
<script>
const agent = window.espejo.agent;
// Its default policy only allows highlight and scroll_to.
agent.policy({ allow: ["highlight", "scroll_to", "click", "fill", "navigate"] });
const chat = window.EspejoChat.createChatClient({
key: "pk_live_...",
agent,
onConfirm: ({ label }) => window.confirm(`Let the assistant ${label}?`),
onEvent(event) {
if (event.type === "done" || event.type === "error") presence?.end();
// ...render the rest as in the minimal chat
},
});
const presence = window.EspejoChat.installPresence(agent, {
name: "Ana",
onStop: () => chat.abort(),
});
function sendMessage(text) {
presence?.start();
chat.send(text);
}
</script>

Call start() at the beginning of each turn: after Stop, every action resolves as declined until you do. The options (lang, text, name, theme, onStop, idleMs) are in Show what the agent is doing.

With the visitor’s verified identity, the assistant can look up and open that person’s tickets, and your UI can list them. Your server computes the hash as hex(HMAC-SHA256(identity secret, email)), with the email trimmed and in lowercase. How to get the secret and sign on your server, with Node, Python and PHP, is in Verified identity for tickets. The secret never goes to the browser.

If you already know who the visitor is when the page loads, pass the identity in identity when you create the client. That way a reload keeps the conversation saved for that person:

const chat = window.EspejoChat.createChatClient({
key: "pk_live_...",
identity: { email: "[email protected]", hash }, // the hash comes from your backend
});

Use identify() for sign in and sign out during the session. It is not the same as passing identity at creation: a call that changes the identity starts a new conversation, so calling it right after creating the client drops the conversation saved for that person.

// After sign in: the hash comes from your backend.
chat.identify({ email: "[email protected]", hash });
// After sign out.
chat.identify(null);
  • Without an identity, or with one that does not verify, the chat works the same, without tickets. An email or hash with anything that is not ASCII counts as no identity.
  • Changing to another person, or to null, starts a new conversation. The same email and hash again change nothing.
  • The identity lives in memory only. Set it again on every page load.
  • Each request carries it in the x-espejo-user-email and x-espejo-user-hash headers.

The client does not include a reader for guides or tickets. Your UI calls the same public endpoints our widget uses, from the browser, with the public key.

GET /v1/guides, GET /v1/guides/search?q= and GET /v1/guides/{id}, with the x-espejo-key header. The shapes, the search syntax, the errors and the cache are in Help guides: In the widget. Two things to keep in mind when you render them yourself:

  • render in the list says how the owner wants guides opened: native inside your UI (render markdown), or link at the guide’s own url in a new tab.
  • markdown is your owner’s content, but render it as untrusted: our widget only activates https:// links and images and shows any HTML as text.

GET /v1/tickets?status=all|open|resolved (without status, all), with the x-espejo-key header and the two identity headers. Here the key only goes in the header, never in ?key=.

const res = await fetch("https://app.espejo.dev/v1/tickets?status=open", {
headers: {
"x-espejo-key": "pk_live_...",
"x-espejo-user-email": "[email protected]",
"x-espejo-user-hash": hash,
},
credentials: "omit",
});
const { tickets, waiting } = await res.json();

The shape of each ticket, what status and waiting mean, the limits and the errors are in Tickets in the widget and in the agent and HTTP errors of GET /v1/tickets. A 401 means the email and hash do not verify: hide your tickets view until you get another identity. Show only the Tickets view when sections includes tickets.

This is the contract createChatClient speaks. Use it when you want to write the client yourself. You still need window.espejo.agent in the page to read it and run the actions (load espejo.agent.js).

Every public endpoint (/v1/agent/config, /v1/agent/chat, /v1/guides, /v1/tickets) checks the same things first:

  • The x-espejo-key header with the public key. Without it the answer is 403 Missing x-espejo-key header. With an unknown or revoked key, it is 403 Invalid ingest key.
  • The account must be active. Otherwise, 403 This account is deactivated. Recording is off.
  • The Origin must be allowed. A project with no allowed origins accepts any. With a list, a request from another origin, or with no Origin at all, gets 403 Origin <origin> is not allowed for this project., or Origin (none) is not allowed for this project. without the header. Browsers always send Origin on these requests. A server usually does not, which is why these endpoints are meant to be called from the visitor’s browser.

Send the requests without cookies (credentials: "omit").

Headers: x-espejo-key. You can also add the key as ?key=, next to the header and never instead of it: the browser cache keys on the URL, so two projects on the same site do not share an entry. When both are sent they must match, or the answer is 403.

{
"enabled": true,
"allowed_actions": ["highlight", "scroll_to", "click"],
"theme": "auto",
"name": "Ana",
"icon": "sparkles",
"sticky_container_id": "main-content",
"sections": ["home", "support", "guides"]
}

If you omit sticky_container_id, the SDK looks for <main> and common roots such as #__next or #root; for a generic root containing <main>, it uses that main content. An explicit ID is honored as written, including root. Docking temporarily reduces that element width and places the fixed widget beside it as a sibling, without wrapping or moving app nodes; undocking restores its width. Choose an element that can safely shrink; without a suitable element, the widget stays floating. When a modal dialog (aria-modal="true") opens, the panel moves behind it and reappears when it closes, keeping its position and mode. Manually minimizing it also keeps the dock attachment.

The fields are the ones described in getConfig(). It answers 404 when the project has nothing to show (no agent on, and no tickets or guides). A 200 carries Cache-Control: private, max-age=60, and any other answer carries no-store. At most 120 requests per minute from one IP to one project.

Headers:

HeaderValue
content-typeapplication/json
x-espejo-keyThe public key.
accepttext/event-stream
x-espejo-user-email, x-espejo-user-hashOptional. The verified identity.

Body:

{
"conversation_id": "0f8b1c1e-5a0e-4c52-9d0b-7f1e8f3a2b64",
"message": "Where do I change my plan?",
"context": {
"page": "espejo-snapshot/1 ...",
"url": "https://example.com/settings",
"recent": ["click button \"Billing\""],
"errors": []
},
"session_id": "3f2a9c0d4b6e8f1a2c3d4e5f6a7b8c9d"
}
FieldRules
conversation_idA UUID your client generates (crypto.randomUUID()) and reuses for the whole conversation. Required.
messageWhat the visitor wrote. Not blank, up to 4,000 characters.
tool_resultsThe results of the pending actions. See The action loop.
contextWhat the visitor sees now: page (the snapshot), url, recent and errors, from agent.getContext(). Send it with every request, results included.
session_idOptional. The session_id of the last report espejo.js made in this page (window.espejo.lastReportId, 32 hex characters). Anything else is ignored.

Send exactly one of message or tool_results. Both, or neither, is a 400. The context is cut to the limits in AGENT_CHAT_LIMITS, and the whole body cannot exceed 769,528 bytes (413).

Errors that can be decided before answering (key, origin, agent off, rate limits, invalid body, conversation state) arrive as a normal HTTP status with a JSON body. Otherwise the answer is 200 with content-type: text/event-stream, and each event is:

event: <name>
data: <json>
EventdataMeaning
text{ "delta": "..." }A piece of the answer. There can be many.
tool_call{ "id": "...", "name": "click", "input": { "ref": "e12" } }An action for the page. There can be several in one turn.
done{ "conversation_id": "...", "awaiting_tools": true }The turn ended. With awaiting_tools: true, the server waits for the results.
error{ "code": "timeout", "message": "..." }The turn failed. The codes are in Stream errors.

The stream ends with exactly one done or one error. A stream that closes without either is a dropped connection: send the message again.

The five page actions (highlight, scroll_to, click, fill, navigate) run in the browser. Only the ones the owner allowed in the console ever reach it. Everything else (reading the page again, searching the guides, looking up and opening tickets) is resolved on Espejo’s server and never arrives as a tool_call.

When a turn ends with awaiting_tools: true:

  1. Run every tool_call of that turn, in order, with the same agent that produced the context: agent.act({ ...call.input, type: call.name }). The ref values point at that agent’s snapshot.
  2. Send another POST in the same conversation with tool_results instead of message, and a fresh context. One result per pending action, with its exact id: { "id": "...", "ok": true }, plus error (up to 1,000 characters) and snapshot (up to 24,000) when act() returned them. At most 8 results per request.
  3. Read the new stream. Repeat while it ends with awaiting_tools: true.

A missing result, a repeated id or an id that was not requested is a 400. Results when nothing is pending are a 409. If the visitor writes a new message instead, the pending actions are closed as not executed and the conversation goes on from the new message.

window.espejo.agent allows only highlight and scroll_to by default, and asks before click, fill and navigate. Widen its policy with agent.policy({ allow: config.allowed_actions }) and answer the confirmation with agent.on("action_confirm", handler). Without a handler those actions return confirmation_required: send that back as the result. See The policy.

Do not send the history: Espejo keeps it by conversation_id. A conversation expires 24 hours after its last request. A new message in an expired conversation starts it over, empty, with the same id. tool_results in an expired one gets 409 conversation_expired. After 60 requests (results included) or 256 KB stored, the conversation answers 409 conversation_limit: start a new one with a new UUID. Only one request at a time per conversation: a second one gets 409 conversation_busy.

The first request with a valid identity ties it to the conversation. Later requests with another identity, or with none, get 409 identity_changed.

EventSource only does GET, so read the POST answer with fetch and a stream reader:

const API = "https://app.espejo.dev";
const KEY = "pk_live_...";
const agent = window.espejo.agent; // from espejo.agent.js
let conversationId = crypto.randomUUID();
function context() {
const c = agent.getContext();
return {
page: c.page.slice(0, 24000),
url: c.url.slice(0, 2048),
recent: c.recent.slice(-30).map((l) => l.slice(0, 500)),
errors: c.errors.slice(-20).map((l) => l.slice(0, 500)),
};
}
async function post(body, onText) {
const res = await fetch(`${API}/v1/agent/chat`, {
method: "POST",
headers: { "content-type": "application/json", "x-espejo-key": KEY, accept: "text/event-stream" },
credentials: "omit",
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw Object.assign(new Error(err.message ?? `HTTP ${res.status}`), { status: res.status, code: err.code });
}
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
const calls = [];
let end = null;
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
let cut;
while ((cut = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, cut);
buffer = buffer.slice(cut + 2);
let name = "message";
const data = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) name = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
if (!data.length) continue;
const payload = JSON.parse(data.join("\n"));
if (name === "text") onText(payload.delta);
else if (name === "tool_call") calls.push(payload);
else if (name === "done") end = payload;
else if (name === "error") throw Object.assign(new Error(payload.message), { code: payload.code });
}
}
if (!end) throw new Error("The connection closed before the answer finished.");
return { calls, awaiting: end.awaiting_tools };
}
async function ask(message, onText) {
let body = { conversation_id: conversationId, message, context: context() };
for (let round = 0; round < 25; round++) {
const { calls, awaiting } = await post(body, onText);
if (!awaiting || !calls.length) return;
const tool_results = [];
for (const call of calls) {
const r = await agent.act({ ...call.input, type: call.name });
tool_results.push({
id: call.id,
ok: r.ok,
...(r.error ? { error: r.error.slice(0, 1000) } : {}),
...(r.snapshot ? { snapshot: r.snapshot.slice(0, 24000) } : {}),
});
}
body = { conversation_id: conversationId, tool_results, context: context() };
}
}

A real client also handles the 409 codes (a new conversationId for conversation_expired, conversation_limit and identity_changed, a short wait for conversation_busy) and retry_after on a 429. That is what createChatClient already does.

The full tables are in Errors and limits. What matters most when you write the client:

  • HTTP errors come before the stream, as JSON with message and, for some, code: 409 with conversation_expired, conversation_limit, conversation_busy or identity_changed, and 429 with daily_cap. A 429 without code is the rate limit and carries retry_after in seconds.
  • Rate limits: 30 chat requests per minute from one IP to one project (requests with tool_results count too), and 120 per minute for GET /v1/agent/config.
  • Daily cap: only requests with message count toward the project’s messages per day.
  • One stream per request: there is no way to resume a stream that dropped. Send the turn again.
  • Reading a conversation’s history. There is no endpoint that returns the messages of a conversation. If you want your UI to show them after a reload, keep them on your side.
  • Resuming a stream. A stream that drops cannot be picked up where it stopped. Send the message again.
  • Tickets outside the assistant. There is no endpoint for the visitor to open or answer a ticket from your UI. The visitor can ask the assistant to open one, when the owner allowed it.
  • Calling the chat from a server. When the project restricts its allowed origins, a request without an allowed Origin is refused. The chat is meant to run in the visitor’s browser.
  • Server side rendering. The client and the agent need a browser: they read the page and run actions on it. Create the client on the client side, after the page loads.