- Guías
- Arma tu propio widget de chat
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.
Tres formas de armarlo
Sección titulada «Tres formas de armarlo»| 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 directo | La 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 el cliente
Sección titulada «Carga el cliente»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 mínimo
Sección titulada «Un chat mínimo»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.
createChatClient a fondo
Sección titulada «createChatClient a fondo»Opciones
Sección titulada «Opciones»| Opción | Default | Significado |
|---|---|---|
key | ninguno | La clave pública del proyecto (pk_...). Obligatoria. |
baseUrl | https://app.espejo.dev | URL base de la API. |
onConfirm | ninguno | Se 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. |
onEvent | ninguno | Recibe cada evento de la tabla de abajo. Una excepción adentro no corta el chat. |
agent | uno propio | Un 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. |
allow | las cinco acciones | Recorta las acciones del agente que arma el cliente. Se ignora con agent. |
storage | sessionStorage | Dó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. |
identity | ninguno | { email, hash }, la identidad verificada del visitante. |
Métodos y propiedades
Sección titulada «Métodos y propiedades»| Miembro | Qué 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. |
conversationId | El id de la conversación actual. |
busy | true mientras corre un turno. |
queue | Los mensajes que esperan, en orden, como { id, text }[]. |
paused | true 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.
Eventos
Sección titulada «Eventos»type | Campos | Cuándo |
|---|---|---|
text | delta | Un pedazo de la respuesta. Agrégalo a la burbuja actual. |
tool_start | call, label, target?, value?, destination? | Una acción está por correr, antes de la confirmación. |
tool | call, label, target?, value?, destination?, result | La 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. |
done | conversationId | El asistente terminó de contestar el mensaje. |
error | code, message, retryAfter? | El turno se cortó. retryAfter (segundos) solo viene con rate_limited. |
queue | queue, paused | La cola cambió. |
dequeued | id, message | Un 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.
Lo que el cliente hace por ti
Sección titulada «Lo que el cliente hace por ti»- 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
onConfirmcuando hace falta) y manda los resultados, hasta que el asistente deja de pedir. Se detiene a las 25 rondas en un mismo mensaje coniteration_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 eventoerror. - 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 areset()al cargar o pasastorage: null. - Adjunta la última grabación. Cuando
espejo.jsestá en la página y el visitante hizo un reporte, el pedido lleva susession_id, así un ticket que abra el asistente puede enlazar el replay.
Códigos de error
Sección titulada «Códigos de error»code | Causa | Qué mostrar |
|---|---|---|
no_agent | El proyecto no tiene un agente encendido (404). | Oculta tu chat, o di que no está disponible. |
forbidden | Clave 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_request | La API rechazó el pedido (400, 413 o cualquier otro 4xx que no esté en esta tabla). | Un error genérico. |
rate_limited | Más de 30 pedidos por minuto desde una IP al proyecto (429). | Pide esperar retryAfter segundos. |
daily_cap | El proyecto llegó a sus mensajes por día (429). | Di que el asistente vuelve mañana (medianoche UTC). |
conversation_limit | La conversación es demasiado larga (409). | Ofrece una conversación nueva y llama a reset(). |
conversation_expired | El reintento con una conversación nueva también falló (409). | Ofrece una conversación nueva. |
conversation_busy | Otro pedido seguía contestando después del reintento (409). | Pide probar de nuevo en un momento. |
identity_changed | El reintento con una conversación nueva también falló (409). | Ofrece una conversación nueva. |
conflict | Cualquier otro 409. | Llama a reset() y prueba de nuevo. |
unavailable | El agente está mal configurado o el servicio está caído (5xx). | Prueba más tarde. |
network | El 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, internal | El 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. Confalse, no muestres el chat.allowed_actions: las acciones que permitió el dueño, entrehighlight,scroll_to,click,fillynavigate.theme:auto,lightodark.autosignifica “adaptarse a la página”.name: el nombre del asistente (hasta 40 caracteres), onullpara tu nombre por defecto. Insértalo siempre como texto.icon:chat,sparkles,headset,bot,help, la URLhttps://de una imagen, onullpara el de fábrica.sections: las secciones a mostrar, en el orden del dueño, entrehome,support(el chat),ticketsyguides.supportsolo aparece mientrasenabledestrue.ticketsademá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.
Identidad y tickets
Sección titulada «Identidad y tickets»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_...",});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.// 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-emailyx-espejo-user-hash.
Guías y tickets en tu interfaz
Sección titulada «Guías y tickets en tu interfaz»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ú:
renderen la lista dice cómo quiere el dueño que se abran las guías:nativedentro de tu interfaz (muestramarkdown), olinken laurlpropia de la guía, en una pestaña nueva.markdownes contenido del dueño, pero muéstralo como no confiable: nuestro widget solo activa links e imágeneshttps://y muestra cualquier HTML como texto.
Tickets
Sección titulada «Tickets»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-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.
El protocolo HTTP
Sección titulada «El protocolo HTTP»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).
La puerta: clave y origen
Sección titulada «La puerta: clave y origen»Todos los endpoints públicos (/v1/agent/config, /v1/agent/chat,
/v1/guides, /v1/tickets) revisan lo mismo primero:
- El header
x-espejo-keycon la clave pública. Sin él la respuesta es403Missing x-espejo-key header.Con una clave desconocida o revocada, es403Invalid ingest key. - La cuenta tiene que estar activa. Si no,
403This account is deactivated. Recording is off. - El
Origintiene que estar permitido. Un proyecto sin orígenes permitidos acepta cualquiera. Con una lista, un pedido desde otro origen, o sinOrigin, recibe403Origin <origin> is not allowed for this project., oOrigin (none) is not allowed for this project.cuando falta el header. Los navegadores siempre mandanOriginen 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").
GET /v1/agent/config
Sección titulada «GET /v1/agent/config»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.
POST /v1/agent/chat
Sección titulada «POST /v1/agent/chat»Headers:
| Header | Valor |
|---|---|
content-type | application/json |
x-espejo-key | La clave pública. |
accept | text/event-stream |
x-espejo-user-email, x-espejo-user-hash | Opcionales. 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"}| Campo | Reglas |
|---|---|
conversation_id | Un UUID que genera tu cliente (crypto.randomUUID()) y reusa en toda la conversación. Obligatorio. |
message | Lo que escribió el visitante. No vacío, hasta 4.000 caracteres. |
tool_results | Los resultados de las acciones pendientes. Ver El loop de acciones. |
context | Lo 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_id | Opcional. 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).
El stream
Sección titulada «El stream»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>| Evento | data | Significado |
|---|---|---|
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.
El loop de acciones
Sección titulada «El loop de acciones»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:
- Ejecuta cada
tool_callde ese turno, en orden, con el mismo agente que armó elcontext:agent.act({ ...call.input, type: call.name }). Losrefapuntan al snapshot de ese agente. - Manda otro
POSTen la misma conversación contool_resultsen lugar demessage, y uncontextfresco. Un resultado por cada acción pendiente, con suidexacto:{ "id": "...", "ok": true }, máserror(hasta 1.000 caracteres) ysnapshot(hasta 24.000) cuandoact()los devolvió. Como mucho 8 resultados por pedido. - 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.
La conversación vive en Espejo
Sección titulada «La conversación vive en Espejo»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.
Ejemplo: leer el stream con fetch
Sección titulada «Ejemplo: leer el stream con fetch»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.jslet 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.
Errores y límites para tu propio cliente
Sección titulada «Errores y límites para tu propio cliente»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
messagey, en algunos,code:409conconversation_expired,conversation_limit,conversation_busyoidentity_changed, y429condaily_cap. Un429sincodees el límite de frecuencia y traeretry_afteren segundos. - Límites de frecuencia: 30 pedidos de chat por minuto desde una IP a un
proyecto (los pedidos con
tool_resultstambién cuentan), y 120 por minuto paraGET /v1/agent/config. - Techo diario: solo los pedidos con
messagecuentan 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.
Lo que todavía no hay
Sección titulada «Lo que todavía no hay»- 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
Originpermitido 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.