Tema
SDK — referencia
Superficie pública completa de @nykk/ur-agent-client. No hay nada más: no existe connect() ni onMessage. La única credencial es el token del usuario de getUserToken, para un agente que identifica a su usuario.
UrAgentClient
ts
new UrAgentClient(options: UrAgentClientOptions)UrAgentClientOptions
| Campo | Tipo | Descripción |
|---|---|---|
agent | string | El id (UUID) del agente — es el que trae el snippet del panel. También se acepta su identifier, pero es un slug derivado del nombre y cambia al renombrar el agente. |
apiBase? | string | Origen del backend ur. Por defecto, window.location.origin; fuera del navegador (SSR, tests) no hay window y queda vacío, así que ahí pásalo siempre explícito. |
channel? | string | 'js_sdk' (por defecto) o 'widget'. Se comprueba contra el estado y los orígenes de ese canal, y elige qué devuelve getConfig() (solo widget trae apariencia). Una UI propia, aunque sea un widget a medida, va por js_sdk. Cualquier otro valor lo rechaza el backend con 400. |
subjectId? | string | Reanuda un hilo concreto en vez del persistido por defecto. Nunca se escribe en storage. Hasta 256 caracteres. Quien conozca un subjectId puede leer ese hilo y escribir en él, porque el canal público no pide nada más: que no se pueda adivinar (un UUID aleatorio), nunca el id o el email de tu usuario. Con un agente que identifica a su usuario, la conversación es además de ese usuario, y otro con el mismo subjectId cae en otra. |
storage? | UrAgentClientStorage | Persistencia por agente de tres claves: el subjectId por defecto (ur-widget-subject-<agente>), la hora del último mensaje enviado con él (ur-widget-activity-<agente>, la que mira expireIfIdle()) y el identificador anti-abuso que el SDK manda como X-Widget-Fingerprint (ur-widget-fingerprint-<agente>), con el que el backend cuenta los límites de mensajes por hora aunque el visitante empiece conversación nueva. Por defecto, localStorage. Pasa tu propia implementación (getItem/setItem) en entornos sin localStorage (SSR, ciertos WebViews); si no persiste de verdad entre cargas (un Map en memoria, por ejemplo), cada recarga estrena conversación y reinicia ese contador. |
context? | Record<string, unknown> | Datos por turno para las tools del agente, agrupados por el nombre de la instancia de tool que los consume (no por el de la plantilla). Ver Contexto de cliente. |
locale? | string | readonly string[] | Los idiomas del visitante, el preferido primero, como etiquetas BCP 47 ('eu', 'es-ES'): normalmente navigator.languages, o el selector de idioma de tu web. Va en start() y en sendMessage() (nunca en getConfig() ni en loadHistory()) para que el mensaje automático llegue en ese idioma si el agente lo tiene: cada etiqueta se prueba tal cual y luego por su idioma base (eu-ES → eu). Sin coincidencia, o sin la opción, el servidor mira la cabecera Accept-Language del navegador y, si tampoco, usa el texto por defecto del agente. Lo que no es una etiqueta se ignora, sin error. Llegó después de la 0.2.9: si los tipos del SDK instalado no tienen locale, súbelo. |
getUserToken? | () => string | null | undefined | Promise<string | null | undefined> | El token del usuario de tu aplicación, para un agente que lo identifica: un JWT que firma tu backend, nunca el navegador. Se llama antes de cada petición a /chat, /history y /start (nunca en /config), y lo que devuelve, sin espacios en los extremos, va como Authorization: Bearer <token>. Vacío, null o undefined: la petición va anónima, que vale si el agente tiene el usuario como opcional y da 401 si lo exige. Ante un 401 token_expired se vuelve a llamar y la petición se repite una vez, si el token nuevo es distinto y no vacío. Se llama en cada petición: si firmar te cuesta, guarda el token en caché en tu lado. Cambiar de token (iniciar o cerrar sesión) es cambiar de conversación. |
getAuthHeaders? | () => Record<string,string> | Promise<Record<string,string>> | Obsoleta: usa getUserToken. Cabeceras extra que se resuelven una vez por petición y se fusionan en /chat, /history y /start. El backend ya no lee X-Widget-Auth. Un Authorization devuelto aquí sí llega y se verifica como token del usuario, pero sin el reintento de getUserToken; y si getUserToken da un token, el suyo sustituye a este. |
Propiedades
subjectId: string(getter) — la identidad de conversación en uso.isNewSubject: boolean(readonly) —truesi elsubjectIdse acaba de generar (no hay nada que restaurar víaloadHistory()).
Métodos
onToken(handler: (delta: string) => void): () => void— delta de texto del turno en curso. Devuelve una función para des-registrar el handler.onNotice(handler: (content: string) => void): () => void— el mensaje automático único que un agente puede tener configurado (una bienvenida, un aviso legal): el primer mensaje de la conversación. Dispara como máximo una vez por(agente, subjectId, channel): desdestart(), antes de que el visitante escriba, o —si no se llamó o no llegó a hacerse— con el primersendMessage(), antes de su primeronToken. Si no te suscribes, el texto no se pierde:loadHistory()lo devuelve igual, marcado conisAutoMessage: true.onToolEvent(handler: (event: UrAgentClientToolEvent) => void): () => void— actividad de tools del agente. Solo se emite en el chat de pruebas del panel; en una integración real (widget/js_sdk) nunca llega, así que no lo trates como parte del contrato.onUsage(handler: (usage: UrAgentClientUsage) => void): () => void— tokens/coste del turno. Igual queonToolEvent, solo en el chat de pruebas del panel.onConversationEnd(handler: (content: string) => void): () => void— la conversación ha agotado el máximo de turnos del agente. Recibe el mensaje Límite de conversación del catálogo, para pintarlo después de la respuesta. Llega antes queonDone. Desde ahí, quita o deshabilita la caja de texto: el backend descarta todo lo que se envíe en esa conversación y vuelve a contestar solo con este evento (sinonTokendelante). Si en la misma respuesta llegara texto después, se descarta sin llamar aonToken. Al recargar,loadHistory()lo refleja conconversationEnded: true.onDone(handler: (threadId: string) => void): () => void— el turno ha terminado.onError(handler: (message: string, info?: UrAgentClientErrorInfo) => void): () => void— fallo de red o del backend. Dos orígenes distintos, y el texto no es intercambiable:- Si el turno ya había arrancado y falla en el backend, llega un mensaje de catálogo (nunca la excepción real: es un canal público).
- Si la petición no llega a abrir el stream, el mensaje lo genera el propio SDK y es técnico y en inglés:
Chat request failed (<status>)para una respuesta HTTP no ok (404canal deshabilitado o agente inexistente,403origen no permitido,429rate limit,400unsubjectIdde más de 256 caracteres,413una petición de más de 1 MB,401el token del usuario identificado no pasa), o el mensaje del error de red (Failed to fetch…).
Trátalo como un código de diagnóstico, no como un texto de cara al usuario: registra
messagey pinta tu propia copia.infosolo llega con una respuesta HTTP de error: sustatusy, en un401,code(token_missing,token_invalidotoken_expired), la señal para pedirle al usuario que vuelva a iniciar sesión.setContext(context: Record<string, unknown> | undefined): void— sustituye el contexto de cliente. Surte efecto en el siguientesendMessage().abort(): void— cancela unsendMessageen curso sin dispararonError(tampocoonDone: no se emite ningún evento).abort()+createChatStoreComo no llega ni
onDonenionError, un store decreateChatStorese queda enstatus: 'sending'y sussend()posteriores son no-op. Para desbloquearlo hay que llamar areset(), que empieza conversación nueva y limpia los mensajes. Si necesitas un botón de «parar» que conserve el hilo, usa el cliente directamente y acumula tú los mensajes.start(): Promise<void>— tu UI está enseñando el chat: en una conversación que aún no existe, el backend la abre con el mensaje automático del agente y este llega poronNotice, antes de que el visitante escriba. Llámalo al abrir el chat (al montarlo, si siempre está a la vista), después deloadHistory()y solo si no había historial; y otra vez trasresetConversation()si el chat sigue a la vista. No lo llames al cargar la página si el chat va detrás de un botón: abriría una conversación por visita. Sobre una conversación que ya existe, o con un agente sin mensaje automático, no hace nada. Nunca lanza ni disparaonError: si falla, el mensaje llega con el primersendMessage(). Dos llamadas a la vez comparten petición, y unsendMessage()enviado mientras está en vuelo lo espera. El mensaje llega en el idioma del visitante si el agente lo tiene (la opciónlocale, si noAccept-Language), y la conversación guarda ese texto:loadHistory()devuelve el mismo.resetConversation(): string— aborta cualquier envío en curso, genera y persiste unsubjectIdnuevo (hilo nuevo de verdad en el backend), y lo devuelve. Si el agente tiene límite de turnos (getConfig().maxTurns), no lo ofrezcas como botón: una conversación nueva empieza la cuenta de cero.expireIfIdle(hours: number | null | undefined): boolean— si no se ha enviado ningún mensaje en las últimashourshoras, olvida la conversación (resetConversation()) y devuelvetrue: no hay historial que cargar. PásalegetConfig().conversationTtlHoursantes deloadHistory(). No hace nada sin plazo, con unsubjectIdexplícito, sinstorageo si aún no consta ningún envío (una conversación sin mensajes, o anterior a esta versión del SDK: cuenta desde el siguiente).getConfig(): Promise<UrAgentClientConfig>— los límites de conversación del agente (maxTurns,conversationTtlHours), si identifica a su usuario (authMode) y, solo en el canalwidget, el título y los colores de su Apariencia; enjs_sdk,stylellega vacío y sintitle. Va siempre sin el token degetUserToken. Lanza si la respuesta no es ok (Config request failed (<status>),404si el canal está apagado). Si falla, sigue sin límites.loadHistory(): Promise<UrAgentClientHistory>— turnos previos de estesubjectId, si hay.threadId: nullymessages: []si no hay nada que restaurar (no es un error). A diferencia desendMessage, este sí lanza si la petición falla: unUrAgentClientHttpErrorconstatusy, en un401,code.sendMessage(text: string): Promise<void>— envía el mensaje y procesa el streaming invocando los handlers registrados arriba. Resuelve cuando el stream termina; no hace falta esperar nada más para saber que el turno acabó — usaonDone/onErrorpara eso. Nunca lanza: los fallos van aonError.
La política de reintentos la pones tú
sendMessage() y loadHistory() hacen un intento por llamada, con una excepción: si el servidor responde que el token del usuario ha caducado (401 token_expired), el SDK le pide otro a getUserToken y repite esa petición una vez, solo si el token nuevo es distinto y no vacío. Fuera de eso no reintenta por su cuenta, a propósito: cuántas veces, con qué espera y ante qué errores depende de tu aplicación, y un reintento automático de un mensaje de chat rara vez es lo que quiere el usuario. Envuélvelos en el nivel de tu framework si lo necesitas.
Tipos
ts
interface UrAgentClientStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
interface UrAgentClientTokenUsage {
inputTokens: number;
outputTokens: number;
}
interface UrAgentClientUsageCost {
inputCost: number;
outputCost: number;
totalCost: number;
}
interface UrAgentClientUsage extends UrAgentClientTokenUsage {
cost: UrAgentClientUsageCost | null; // null si el modelo no tiene precios cargados
}
interface UrAgentClientHistoryMessage {
role: 'user' | 'bot';
content: string;
time: string | null;
usage?: UrAgentClientTokenUsage; // solo chat de pruebas del panel
toolCalls?: unknown; // solo chat de pruebas del panel
isAutoMessage?: boolean;
isSummaryMarker?: boolean; // resumen interno del hilo: no lo pintes
isSummarized?: boolean; // mensaje ya cubierto por un resumen anterior
isConversationEnd?: boolean; // el mensaje de cierre: siempre el último, con conversationEnded
}
interface UrAgentClientHistory {
threadId: string | null;
messages: UrAgentClientHistoryMessage[];
conversationEnded?: boolean; // se agotaron los turnos: deja la caja de texto cerrada
usage?: UrAgentClientUsage; // solo chat de pruebas del panel
modelName?: string; // solo chat de pruebas del panel
}
interface UrAgentClientConfig {
style: { color?: string; colorText?: string; colorBot?: string }; // '#rrggbb'; vacío en js_sdk
title?: string; // solo en el canal widget
maxTurns?: number; // máximo de turnos por conversación; sin él, no hay límite
conversationTtlHours?: number; // horas sin actividad para olvidarla; pásalo a expireIfIdle()
authMode?: 'optional' | 'required'; // el agente identifica al usuario: pasa getUserToken
}
interface UrAgentClientErrorInfo {
status?: number; // el código HTTP de la respuesta
code?: string; // solo en un 401: token_missing | token_invalid | token_expired
}
class UrAgentClientHttpError extends Error { // lo que rechaza loadHistory() con una respuesta no ok
readonly status: number;
readonly code: string | undefined; // como UrAgentClientErrorInfo.code
}
interface UrAgentClientToolEvent {
name: string;
status: 'start' | 'end';
args?: Record<string, unknown>;
}Si el agente tiene activado el resumen de conversaciones largas, el historial incluye un mensaje role: 'bot' con isSummaryMarker: true cuyo contenido es ese resumen interno: fíltralo antes de pintar, o tu usuario verá un resumen de su propia conversación como si se lo hubiera dicho el agente.
createChatStore
ts
function createChatStore(client: UrAgentClient): UrAgentChatStoreCapa opcional de acumulación de mensajes sobre un UrAgentClient ya creado. Framework-agnóstico — un simple getState()/subscribe(), exactamente la forma que espera useSyncExternalStore en React (ver el ejemplo React) y trivial de reflejar en un signal de Angular (ver el ejemplo Angular). Se suscribe internamente a onToken/onNotice/onConversationEnd/onDone/onError del cliente que le pasas — sigues pudiendo registrar tus propios handlers sobre ese mismo cliente para lo que el store no cubre (onToolEvent/onUsage, que solo llegan en el chat de pruebas del panel).
UrAgentChatStore
getState(): UrAgentChatStatesubscribe(listener: () => void): () => void— no llama al listener al suscribirte; leegetState()justo después si necesitas el valor inicial.loadHistory(): Promise<void>— sustituyemessagespor el historial previo, ya sin el resumen interno (isSummaryMarker): lo que queda se puede pintar tal cual. También fijaconversationEndedsegún el servidor. Al contrario queclient.loadHistory(), no lanza: un fallo dejastatus: 'error'y el texto enerror(y en un401, el motivo enerrorCode), sin tocarmessages. Llamar como mucho una vez por montaje, normalmente solo si!client.isNewSubject.start(): Promise<void>— llámalo al enseñar el chat, después deloadHistory(): si no hay mensajes, pide el mensaje automático del agente (client.start()), que entra como primera burbuja conisAutoMessage. Con mensajes ya, o conconversationEnded, no hace nada. No lanza.send(text: string): Promise<void>— añade el mensaje del usuario y procesa el streaming de la respuesta. No-op mientrasstatus === 'sending'y conconversationEnded. Si hay unstart()en vuelo, lo espera antes de añadir el mensaje, para que la bienvenida quede encima.reset(): void— limpiamessagesyconversationEnded, y llama aclient.resetConversation(). Con el chat a la vista, llama astart()después.
UrAgentChatState
ts
interface UrAgentChatMessage {
id: string;
role: 'user' | 'bot';
content: string;
time: string | null;
isAutoMessage?: boolean; // el aviso automático del agente
isError?: boolean; // bubble sintético creado por el store al recibir `onError`
pending?: boolean; // el bubble del bot sigue acumulando tokens
isConversationEnd?: boolean; // el mensaje de cierre al agotarse los turnos: dale estilo propio
}
interface UrAgentChatState {
messages: UrAgentChatMessage[];
status: 'idle' | 'loading-history' | 'sending' | 'error';
error: string | null;
errorCode?: string; // con `error`, en un 401: token_missing | token_invalid | token_expired
conversationEnded: boolean; // se agotaron los turnos: deshabilita la caja de texto
}Si tu cliente no es JavaScript
Este paquete es la vía soportada para integrar un chat en el navegador, junto con el widget. Fuera de JavaScript no hay vía soportada para hablar con un agente: el protocolo de /embed/* que usa el paquete no es un contrato público.