Ir al contenido
Consola

Arma tu propio widget de chat

Reemplaza el widget de chat de Espejo por una interfaz de soporte propia. Usa createChatClient sobre nuestro backend, trae tu propio modelo con espejo.agent o habla el protocolo HTTP directamente.

Nuestro widget de chat es una forma de poner el agente de soporte en tu sitio. Cuando no encaja con tu diseño, tu framework o tu producto, puedes armar el chat tú mismo y usar tanto de Espejo como quieras. Esta guía cubre tres niveles, del que menos trabajo pide al que más.

Qué haces túQué hace Espejo
1. Tu interfaz sobre nuestro backend (createChatClient, el recomendado)La interfaz: burbujas, campo de texto, confirmación, errores.El proveedor y su clave, el loop de tools, las acciones en la página, guías, tickets, el techo diario y la conversación.
2. Tu interfaz y tu propio modelo (window.espejo.agent)La interfaz, el modelo, su clave y el loop de tools, en tu servidor.El contexto de la página y las acciones en la página.
3. El protocolo HTTP directoLa interfaz, el lector del stream y el loop de acciones.El mismo backend que el nivel 1.

El nivel 1 es sobre lo que corre nuestro widget. Tienes el mismo comportamiento (reintentos, cola, confirmación, identidad) y solo dibujas la interfaz. Casi toda esta guía trata de él.

El nivel 2 no usa el backend de chat de Espejo: tu código lee la página con getContext(), le ofrece las tools a tu modelo con tools() y ejecuta lo que pide con act(), con una política y un handler de confirmación. Está documentado entero en Camino 1: tu propio chat y tu propio agente.

El nivel 3 es para cuando no puedes o no quieres cargar nuestro script para el chat. Igual necesitas espejo.agent en la página para ejecutar las acciones. Ver El protocolo HTTP.

Los niveles 1 y 3 necesitan el agente de soporte configurado y encendido en la consola (la pestaña Support agent), igual que el widget.

Carga espejo.chat.js sin data-key. Sin clave no monta nada: ni botón ni panel. Deja window.EspejoChat con createChatClient, installPresence, installChatWidget e identify, más chat, el widget montado, que queda en null sin clave:

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

Cárgalo sin async ni defer si un script inline justo después usa window.EspejoChat, o espera al evento load. El bundle trae su propia copia de la capa espejo.agent, así que ejecuta las acciones solo. Funciona con o sin espejo.js: si el grabador está en la página, el agente manda además las acciones y los errores recientes del visitante.

EspejoChat.identify es del widget montado. Con tu propia interfaz, pon la identidad en tu cliente (ver Identidad y tickets).

Un chat completo en HTML y JavaScript sin dependencias: muestra la respuesta a medida que llega, muestra cada acción mientras corre, pregunta antes de actuar y muestra los errores.

<section id="support">
<ol id="log"></ol>
<form id="form">
<input id="input" autocomplete="off" placeholder="Pregunta lo que quieras" />
<button>Enviar</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;
// Siempre textContent: la respuesta es texto, nunca 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 `Demasiados mensajes. Prueba de nuevo en ${retryAfter ?? 60} segundos.`;
if (code === "daily_cap") return "El asistente llegó al límite de hoy. Prueba mañana.";
if (code === "no_agent") return "El chat de soporte no está disponible en este sitio.";
if (code === "network") return "Se cortó la conexión. Envía tu mensaje de nuevo.";
return "Algo salió mal. Prueba de nuevo.";
}
const chat = window.EspejoChat.createChatClient({
key: "pk_live_...",
// Click, fill y navigate esperan esta respuesta. Usa tu propio diálogo.
onConfirm: ({ label }) => window.confirm(`¿Dejas que el asistente haga ${label}?`),
onEvent(event) {
switch (event.type) {
case "text":
bubble ??= line("assistant", "");
bubble.textContent += event.delta;
break;
case "tool_start":
bubble = null; // el texto después de una acción va en otra burbuja
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}: listo` : `${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); // nunca rechaza
});
</script>

label es una descripción corta en inglés (click "Save", navigate to https://example.com/billing). Para escribir tu propio texto, en cualquier idioma, usa target, value y destination, que vienen en el pedido de confirmación y en los eventos tool_start y tool.

OpciónDefaultSignificado
keyningunoLa clave pública del proyecto (pk_...). Obligatoria.
baseUrlhttps://app.espejo.devURL base de la API.
onConfirmningunoSe llama antes de click, fill y navigate con { action, label, target?, value?, destination? }. Devuelve true (o una promesa de true) para ejecutar la acción. Sin esto, esas acciones vuelven como confirmation_required y no se ejecutan.
onEventningunoRecibe cada evento de la tabla de abajo. Una excepción adentro no corta el chat.
agentuno propioUn agente que ya armaste, por ejemplo window.espejo.agent. Su política queda como está. Sin esto, el cliente arma uno que permite las cinco acciones: cuáles se le ofrecen al modelo lo decide la consola.
allowlas cinco accionesRecorta las acciones del agente que arma el cliente. Se ignora con agent.
storagesessionStorageDónde se guarda el id de la conversación. null no guarda nada, así que cada carga de página arranca una conversación nueva.
identityninguno{ email, hash }, la identidad verificada del visitante.
MiembroQué hace
send(message)Manda un mensaje en el acto, corta el turno en curso y pone la cola en pausa. Resuelve cuando el cliente vuelve a quedar libre. Nunca rechaza.
enqueue(message)Manda en el acto si el cliente está libre y la cola no está en pausa. Si no, deja el mensaje en la cola. Devuelve false si el mensaje está en blanco o ya esperan 5.
removeQueued(id)Saca un mensaje de la cola.
editQueued(id, message)Reemplaza el texto de un mensaje de la cola. Un texto en blanco lo saca.
sendQueuedNow(id)Corta el turno en curso y manda primero ese mensaje de la cola. El resto de la cola sigue detrás.
clearQueue()Vacía la cola.
resumeQueue()Saca la cola de pausa. Si el cliente está libre, manda el primer mensaje.
reset()Corta el turno en curso, vacía la cola y arranca una conversación nueva.
abort()Corta el turno en curso, conserva la conversación y pone la cola en pausa.
identify(identity)Cambia la identidad, o la saca con null. Con otra persona hace un reset().
destroy()Corta el turno en curso, vacía la cola y se suelta del agente.
getConfig()La configuración del widget en la consola. Ver más abajo.
conversationIdEl id de la conversación actual.
busytrue mientras corre un turno.
queueLos mensajes que esperan, en orden, como { id, text }[].
pausedtrue mientras la cola está en pausa. Siempre false con la cola vacía.

La cola solo avanza cuando un turno termina con done, y se pone en pausa después de un error, de abort() y de un send() directo. Vive en memoria: una recarga la pierde. Las reglas completas están en createChatClient: nuestro backend, tu interfaz.

typeCamposCuándo
textdeltaUn pedazo de la respuesta. Agrégalo a la burbuja actual.
tool_startcall, label, target?, value?, destination?Una acción está por correr, antes de la confirmación.
toolcall, label, target?, value?, destination?, resultLa misma acción después de correr o de ser rechazada. Se empareja con tool_start por call.id. result es { ok, error?, snapshot? }, donde snapshot es la página después de la acción.
doneconversationIdEl asistente terminó de contestar el mensaje.
errorcode, message, retryAfter?El turno se cortó. retryAfter (segundos) solo viene con rate_limited.
queuequeue, pausedLa cola cambió.
dequeuedid, messageUn mensaje de la cola está saliendo. Muéstralo en la conversación aquí.

call es { id, name, input }, donde name es highlight, scroll_to, click, fill o navigate. result.error es un código corto como declined, confirmation_required, stale_ref, not_allowed o cross_origin.

Cada turno que llega al final emite exactamente un done o un error. Un mensaje en blanco no se envía, y un turno cortado por abort(), reset(), destroy(), un send() posterior o sendQueuedNow() no emite nada más, ni siquiera el evento tool de una acción que esperaba confirmación.

  • Recorta el mensaje y lo corta en 4.000 caracteres, el límite de la API. Un mensaje en blanco no se envía.
  • Lee la página antes de cada pedido y recorta el contexto a los límites de la API.
  • Ejecuta las acciones. Cuando un turno termina con acciones pendientes, ejecuta cada una en la página (preguntando con onConfirm cuando hace falta) y manda los resultados, hasta que el asistente deja de pedir. Se detiene a las 25 rondas en un mismo mensaje con iteration_limit.
  • Reintenta una vez por su cuenta cuando la conversación venció (conversation_expired) o es de otra identidad (identity_changed): arranca una conversación nueva y vuelve a mandar el mensaje. Cuando la conversación está ocupada con otro pedido (conversation_busy, por ejemplo desde otra pestaña), espera 1,5 segundos y reintenta una vez en la misma conversación. Solo si el reintento también falla recibes el evento error.
  • Guarda el id de la conversación en sessionStorage, así una recarga en la misma pestaña sigue la misma conversación. Solo se guarda el id, nunca los mensajes: después de una recarga tu interfaz arranca vacía y el asistente igual recuerda. Si prefieres arrancar de cero, llama a reset() al cargar o pasa storage: null.
  • Adjunta la última grabación. Cuando espejo.js está en la página y el visitante hizo un reporte, el pedido lleva su session_id, así un ticket que abra el asistente puede enlazar el replay.
codeCausaQué mostrar
no_agentEl proyecto no tiene un agente encendido (404).Oculta tu chat, o di que no está disponible.
forbiddenClave inválida o revocada, origen no permitido o cuenta desactivada (401 o 403).Un error genérico. Revisa la clave y los orígenes permitidos.
bad_requestLa API rechazó el pedido (400, 413 o cualquier otro 4xx que no esté en esta tabla).Un error genérico.
rate_limitedMás de 30 pedidos por minuto desde una IP al proyecto (429).Pide esperar retryAfter segundos.
daily_capEl proyecto llegó a sus mensajes por día (429).Di que el asistente vuelve mañana (medianoche UTC).
conversation_limitLa conversación es demasiado larga (409).Ofrece una conversación nueva y llama a reset().
conversation_expiredEl reintento con una conversación nueva también falló (409).Ofrece una conversación nueva.
conversation_busyOtro pedido seguía contestando después del reintento (409).Pide probar de nuevo en un momento.
identity_changedEl reintento con una conversación nueva también falló (409).Ofrece una conversación nueva.
conflictCualquier otro 409.Llama a reset() y prueba de nuevo.
unavailableEl agente está mal configurado o el servicio está caído (5xx).Prueba más tarde.
networkEl pedido falló o el stream se cerró antes del final.Pide enviar el mensaje de nuevo.
provider_auth, provider_rate_limited, provider_error, timeout, iteration_limit, internalEl turno falló después de que empezó la respuesta. Ver Errores del stream.Un error genérico. iteration_limit: pide acotar el pedido.

message es un texto legible en inglés que viene de la API o del cliente. Nunca incluye la respuesta del proveedor.

Sigue la configuración de la consola: getConfig()

Sección titulada «Sigue la configuración de la consola: getConfig()»

El dueño elige el nombre, el icono, el tema y las secciones del asistente en la pestaña Widget de la consola. getConfig() los lee (GET /v1/agent/config) para que tu interfaz también los respete:

const config = await chat.getConfig();
// { enabled, allowed_actions, theme, name, icon, sections }
  • enabled: si el asistente contesta. Con false, no muestres el chat.
  • allowed_actions: las acciones que permitió el dueño, entre highlight, scroll_to, click, fill y navigate.
  • theme: auto, light o dark. auto significa “adaptarse a la página”.
  • name: el nombre del asistente (hasta 40 caracteres), o null para tu nombre por defecto. Insértalo siempre como texto.
  • icon: chat, sparkles, headset, bot, help, la URL https:// de una imagen, o null para el de fábrica.
  • sections: las secciones a mostrar, en el orden del dueño, entre home, support (el chat), tickets y guides. support solo aparece mientras enabled es true. tickets además necesita un visitante con identidad verificada: ocúltala si no la tienes.

Nunca rechaza. Cuando el proyecto no tiene nada que mostrar resuelve a { enabled: false, allowed_actions: [], theme: "auto", name: null, icon: null, sections: [] }, y a null cuando no se pudo leer (red, origen no permitido, servidor caído). El navegador puede guardar la respuesta 60 segundos y la API 30, así que un cambio en la consola puede tardar hasta unos 90 segundos en llegar a tu interfaz.

Muéstrale al visitante lo que hace el asistente

Sección titulada «Muéstrale al visitante lo que hace el asistente»

Nuestro widget muestra un aviso con un botón Detener, un borde de color y un cursor mientras el asistente actúa. Lo mismo lo tienes con installPresence. Necesita el agente que ejecuta las acciones, así que carga también espejo.agent.js y dale ese agente al cliente:

<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;
// Su política por defecto solo permite highlight y scroll_to.
agent.policy({ allow: ["highlight", "scroll_to", "click", "fill", "navigate"] });
const chat = window.EspejoChat.createChatClient({
key: "pk_live_...",
agent,
onConfirm: ({ label }) => window.confirm(`¿Dejas que el asistente haga ${label}?`),
onEvent(event) {
if (event.type === "done" || event.type === "error") presence?.end();
// ...el resto se pinta como en el chat mínimo
},
});
const presence = window.EspejoChat.installPresence(agent, {
lang: "es",
name: "Ana",
onStop: () => chat.abort(),
});
function sendMessage(text) {
presence?.start();
chat.send(text);
}
</script>

Llama a start() al principio de cada turno: después de Detener, cada acción resuelve como declined hasta que lo hagas. Las opciones (lang, text, name, theme, onStop, idleMs) están en Mostrar lo que hace el agente.

Con la identidad verificada del visitante, el asistente puede consultar y abrir los tickets de esa persona, y tu interfaz puede listarlos. Tu servidor calcula el hash como hex(HMAC-SHA256(secreto de identidad, email)), con el correo recortado y en minúsculas. Cómo obtener el secreto y firmar en tu servidor, con Node, Python y PHP, está en Identidad verificada para tickets. El secreto nunca va al navegador.

Si ya sabes quién es el visitante cuando carga la página, pasa la identidad en identity al crear el cliente. Así una recarga conserva la conversación guardada de esa persona:

const chat = window.EspejoChat.createChatClient({
key: "pk_live_...",
identity: { email: "[email protected]", hash }, // el hash viene de tu backend
});

Usa identify() para iniciar y cerrar sesión durante la visita. No es lo mismo que pasar identity al crear el cliente: una llamada que cambia la identidad arranca una conversación nueva, así que llamarla justo después de crear el cliente descarta la conversación guardada de esa persona.

// Después de iniciar sesión: el hash viene de tu backend.
chat.identify({ email: "[email protected]", hash });
// Después de cerrar sesión.
chat.identify(null);
  • Sin identidad, o con una que no verifica, el chat anda igual, sin tickets. Un correo o un hash con algo que no sea ASCII cuenta como sin identidad.
  • Cambiar a otra persona, o a null, arranca una conversación nueva. El mismo correo y el mismo hash otra vez no cambian nada.
  • La identidad vive solo en memoria. Vuelve a ponerla en cada carga de página.
  • Cada pedido la lleva en los headers x-espejo-user-email y x-espejo-user-hash.

El cliente no trae un lector de guías ni de tickets. Tu interfaz llama a los mismos endpoints públicos que usa nuestro widget, desde el navegador, con la clave pública.

GET /v1/guides, GET /v1/guides/search?q= y GET /v1/guides/{id}, con el header x-espejo-key. Las formas de respuesta, la sintaxis de búsqueda, los errores y el cache están en Guías de ayuda: En el widget. Dos cosas a tener en cuenta cuando las muestras tú:

  • render en la lista dice cómo quiere el dueño que se abran las guías: native dentro de tu interfaz (muestra markdown), o link en la url propia de la guía, en una pestaña nueva.
  • markdown es contenido del dueño, pero muéstralo como no confiable: nuestro widget solo activa links e imágenes https:// y muestra cualquier HTML como texto.

GET /v1/tickets?status=all|open|resolved (sin status, all), con el header x-espejo-key y los dos headers de identidad. Aquí la clave va solo en el header, nunca en ?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();

La forma de cada ticket, qué significan status y waiting, los límites y los errores están en Tickets en el widget y en el agente y Errores HTTP de GET /v1/tickets. Un 401 significa que el correo y el hash no verifican: oculta tu vista de tickets hasta tener otra identidad. Muestra la vista de Tickets solo cuando sections incluye tickets.

Este es el contrato que habla createChatClient. Úsalo cuando quieras escribir el cliente tú mismo. Igual necesitas window.espejo.agent en la página para leerla y ejecutar las acciones (carga espejo.agent.js).

Todos los endpoints públicos (/v1/agent/config, /v1/agent/chat, /v1/guides, /v1/tickets) revisan lo mismo primero:

  • El header x-espejo-key con la clave pública. Sin él la respuesta es 403 Missing x-espejo-key header. Con una clave desconocida o revocada, es 403 Invalid ingest key.
  • La cuenta tiene que estar activa. Si no, 403 This account is deactivated. Recording is off.
  • El Origin tiene que estar permitido. Un proyecto sin orígenes permitidos acepta cualquiera. Con una lista, un pedido desde otro origen, o sin Origin, recibe 403 Origin <origin> is not allowed for this project., o Origin (none) is not allowed for this project. cuando falta el header. Los navegadores siempre mandan Origin en estos pedidos. Un servidor normalmente no, y por eso estos endpoints están pensados para llamarse desde el navegador del visitante.

Manda los pedidos sin cookies (credentials: "omit").

Headers: x-espejo-key. También puedes agregar la clave como ?key=, junto al header y nunca en su lugar: el cache del navegador se indexa por URL, así dos proyectos en el mismo sitio no comparten entrada. Si vienen las dos, tienen que coincidir. Si no, la respuesta es 403.

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

Si omitís sticky_container_id, el SDK busca <main> y raíces habituales como #__next o #root; una raíz genérica con un <main> interno usa ese contenido principal. Un ID explícito se respeta tal cual, incluso si es root. Al acoplar, el SDK reduce temporalmente el ancho de ese elemento y coloca el widget fijo como hermano, sin envolver ni mover nodos de la app; al soltarlo, restaura el ancho. Elegí un elemento cuyo ancho pueda reducirse; si no hay uno adecuado, el widget queda flotante. Si se abre un diálogo modal (aria-modal="true"), el panel se oculta detrás y reaparece al cerrarlo, conservando su posición y modo. Minimizarlo manualmente también conserva el acoplamiento.

Los campos son los que se describen en getConfig(). Responde 404 cuando el proyecto no tiene nada que mostrar (ni agente encendido, ni tickets, ni guías). Un 200 lleva Cache-Control: private, max-age=60, y cualquier otra respuesta lleva no-store. Como mucho 120 pedidos por minuto desde una IP a un proyecto.

Headers:

HeaderValor
content-typeapplication/json
x-espejo-keyLa clave pública.
accepttext/event-stream
x-espejo-user-email, x-espejo-user-hashOpcionales. La identidad verificada.

Cuerpo:

{
"conversation_id": "0f8b1c1e-5a0e-4c52-9d0b-7f1e8f3a2b64",
"message": "¿Dónde cambio mi plan?",
"context": {
"page": "espejo-snapshot/1 ...",
"url": "https://example.com/settings",
"recent": ["click button \"Billing\""],
"errors": []
},
"session_id": "3f2a9c0d4b6e8f1a2c3d4e5f6a7b8c9d"
}
CampoReglas
conversation_idUn UUID que genera tu cliente (crypto.randomUUID()) y reusa en toda la conversación. Obligatorio.
messageLo que escribió el visitante. No vacío, hasta 4.000 caracteres.
tool_resultsLos resultados de las acciones pendientes. Ver El loop de acciones.
contextLo que ve el visitante ahora: page (el snapshot), url, recent y errors, de agent.getContext(). Mándalo en cada pedido, también con resultados.
session_idOpcional. El session_id del último reporte que hizo espejo.js en esta página (window.espejo.lastReportId, 32 caracteres hex). Cualquier otra cosa se ignora.

Manda exactamente uno de message o tool_results. Los dos, o ninguno, es un 400. El contexto se corta a los límites de AGENT_CHAT_LIMITS, y el cuerpo entero no puede pasar de 769.528 bytes (413).

Los errores que se pueden decidir antes de contestar (clave, origen, agente apagado, límites de frecuencia, cuerpo inválido, estado de la conversación) llegan como un status HTTP normal con un cuerpo JSON. Si no, la respuesta es 200 con content-type: text/event-stream, y cada evento es:

event: <name>
data: <json>
EventodataSignificado
text{ "delta": "..." }Un pedazo de la respuesta. Puede haber muchos.
tool_call{ "id": "...", "name": "click", "input": { "ref": "e12" } }Una acción para la página. Puede haber varias en un turno.
done{ "conversation_id": "...", "awaiting_tools": true }El turno terminó. Con awaiting_tools: true, el servidor espera los resultados.
error{ "code": "timeout", "message": "..." }El turno falló. Los códigos están en Errores del stream.

El stream termina con exactamente un done o un error. Un stream que se cierra sin ninguno de los dos es una conexión caída: manda el mensaje de nuevo.

Las cinco acciones de la página (highlight, scroll_to, click, fill, navigate) corren en el navegador. Solo llegan las que el dueño permitió en la consola. Todo lo demás (volver a leer la página, buscar en las guías, consultar y abrir tickets) se resuelve en el servidor de Espejo y nunca llega como tool_call.

Cuando un turno termina con awaiting_tools: true:

  1. Ejecuta cada tool_call de ese turno, en orden, con el mismo agente que armó el context: agent.act({ ...call.input, type: call.name }). Los ref apuntan al snapshot de ese agente.
  2. Manda otro POST en la misma conversación con tool_results en lugar de message, y un context fresco. Un resultado por cada acción pendiente, con su id exacto: { "id": "...", "ok": true }, más error (hasta 1.000 caracteres) y snapshot (hasta 24.000) cuando act() los devolvió. Como mucho 8 resultados por pedido.
  3. Lee el stream nuevo. Repite mientras termine con awaiting_tools: true.

Un resultado que falta, un id repetido o un id que no se pidió es un 400. Resultados cuando no hay nada pendiente son un 409. Si en cambio el visitante escribe un message nuevo, las acciones pendientes se cierran como no ejecutadas y la conversación sigue desde el mensaje nuevo.

window.espejo.agent solo permite highlight y scroll_to por defecto, y pregunta antes de click, fill y navigate. Amplía su política con agent.policy({ allow: config.allowed_actions }) y contesta la confirmación con agent.on("action_confirm", handler). Sin handler, esas acciones devuelven confirmation_required: mándalo como resultado. Ver La política.

No mandes el historial: Espejo lo guarda por conversation_id. Una conversación vence 24 horas después de su último pedido. Un message nuevo en una conversación vencida la arranca de cero, vacía, con el mismo id. tool_results en una vencida recibe 409 conversation_expired. Después de 60 pedidos (contando los de resultados) o 256 KB guardados, la conversación contesta 409 conversation_limit: arranca una nueva con un UUID nuevo. Un solo pedido a la vez por conversación: un segundo recibe 409 conversation_busy.

El primer pedido con una identidad válida la ata a la conversación. Los pedidos siguientes con otra identidad, o sin ninguna, reciben 409 identity_changed.

EventSource solo hace GET, así que lee la respuesta del POST con fetch y un lector del stream:

const API = "https://app.espejo.dev";
const KEY = "pk_live_...";
const agent = window.espejo.agent; // de 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() };
}
}

Un cliente de verdad además maneja los códigos 409 (un conversationId nuevo para conversation_expired, conversation_limit e identity_changed, una espera corta para conversation_busy) y retry_after en un 429. Es lo que ya hace createChatClient.

Las tablas completas están en Errores y límites. Lo que más importa al escribir el cliente:

  • Los errores HTTP llegan antes del stream, como JSON con message y, en algunos, code: 409 con conversation_expired, conversation_limit, conversation_busy o identity_changed, y 429 con daily_cap. Un 429 sin code es el límite de frecuencia y trae retry_after en segundos.
  • Límites de frecuencia: 30 pedidos de chat por minuto desde una IP a un proyecto (los pedidos con tool_results también cuentan), y 120 por minuto para GET /v1/agent/config.
  • Techo diario: solo los pedidos con message cuentan para los mensajes por día del proyecto.
  • Un stream por pedido: no hay forma de retomar un stream que se cortó. Manda el turno de nuevo.
  • Leer el historial de una conversación. No hay un endpoint que devuelva los mensajes de una conversación. Si quieres que tu interfaz los muestre después de una recarga, guárdalos de tu lado.
  • Retomar un stream. Un stream que se corta no se puede seguir desde donde quedó. Manda el mensaje de nuevo.
  • Tickets fuera del asistente. No hay un endpoint para que el visitante abra o conteste un ticket desde tu interfaz. El visitante puede pedirle al asistente que abra uno, si el dueño lo permitió.
  • Llamar al chat desde un servidor. Cuando el proyecto restringe sus orígenes permitidos, un pedido sin un Origin permitido se rechaza. El chat está pensado para correr en el navegador del visitante.
  • Renderizado del lado del servidor. El cliente y el agente necesitan un navegador: leen la página y actúan sobre ella. Crea el cliente del lado del cliente, después de que carga la página.