Skip to content

Integrar con un agente de IA ​

Esta página está pensada para dártela a leer a un agente de codificación (Claude Code, Cursor, Copilot, Windsurf…) cuando quieras que él mismo integre el chat en tu app. No es una guía para humanos — es contexto denso y autocontenido para que el agente no tenga que ir a buscar el resto del sitio ni adivinar una API que no existe.

Cópiale a tu agente el bloque de abajo entero (edita los <placeholders> primero si ya conoces los valores, o déjalos para que te los pregunte).

El prompt ​

text
Eres un agente de codificación ayudando a integrar un cliente de chat personalizado contra un
agente de la plataforma `ur`. Sigue estos pasos.

1. Instala el SDK headless (sin dependencias de runtime, sin UI propia):

   npm install @nykk/ur-agent-client

   (pnpm add / yarn add son equivalentes).

2. Necesitas dos datos — pídemelos si no los tienes ya:
   - `agent`: el `id` (UUID) del agente `ur`. Es el que trae ya puesto el snippet del panel.
     También se acepta el `identifier`, pero es un slug derivado del nombre y cambia cada vez que
     se renombra el agente: la integración empezaría a recibir `404`. Usa el `id`.
   - `apiBase`: el origen del backend `ur` (ej. `https://ur.tuempresa.com`). Si el cliente se sirve
     desde el mismo origen que el backend, puedes omitirlo (usa `window.location.origin` por
     defecto).
   El canal del SDK de ese agente debe estar habilitado. Se crea activado junto con el agente, así
   que normalmente no hay que tocar nada; si recibes `404`, dile al usuario que lo compruebe en el
   panel: ficha del agente → **Canales** → **«Integración a medida con tu propia interfaz»**
   (`@nykk/ur-agent-client`). Si recibes `403`, lo que falta es el dominio de la web entre los
   orígenes permitidos de ese mismo canal. Ese es el canal; el panel no lo llama `js_sdk` en ningún
   sitio, aunque ese sea su identificador interno y el valor por defecto de la opción `channel`.
   También es el canal si lo que construyes es un widget: el «Chat embebible» es el de
   `@nykk/ur-agent-widget` y no tiene nada que ver con este cliente.

3. Esta es la superficie que debes usar. Hay tres miembros más (`onToolEvent`, `onUsage` y la
   opción `getAuthHeaders`) que no sirven para una integración real: los dos primeros solo disparan
   en el chat de pruebas del panel (ver el punto 7), y `getAuthHeaders` está obsoleta (el token del
   usuario va en `getUserToken`). Fuera de ellos no inventes métodos, eventos u opciones que no
   aparezcan aquí (no existe `connect()`, no existe `onMessage`):

   class UrAgentClient {
     constructor(options: {
       agent: string;
       apiBase?: string;
       channel?: string;   // por defecto 'js_sdk' — no lo cambies; cualquier valor que no sea 'js_sdk' o 'widget' da 400
       subjectId?: string; // reanuda un hilo concreto en vez del persistido por defecto
       storage?: { getItem(key: string): string | null; setItem(key: string, value: string): void };
       // ^ persistencia del subjectId por defecto; por defecto usa localStorage
       context?: Record<string, unknown>; // datos por turno para las tools, agrupados por nombre de INSTANCIA de tool
       locale?: string | readonly string[];
       // ^ los idiomas del visitante, el preferido primero (navigator.languages, o el idioma elegido en la app):
       //   el mensaje automático llega en el primero que el agente tenga traducido. Va en start() y sendMessage().
       //   Sin él, el servidor usa Accept-Language. Si los tipos del SDK instalado no lo tienen, súbelo (llegó tras 0.2.9).
       getUserToken?: () => string | null | undefined | Promise<string | null | undefined>;
       // ^ solo si el agente identifica al usuario (getConfig() trae authMode): devuelve un JWT que
       //   firma el BACKEND de la app para el usuario con sesión (pídeselo con fetch a una ruta suya).
       //   Nunca se firma en el navegador. Vacío o null = anónimo. Se llama antes de cada /chat,
       //   /history y /start; ante un 401 token_expired, se llama otra vez y se repite una vez.
     });

     readonly subjectId: string;
     readonly isNewSubject: boolean; // true si no hay nada que restaurar vía loadHistory()

     onToken(fn: (delta: string) => void): () => void;   // delta de texto del turno en curso
     onNotice(fn: (content: string) => void): () => void;
     // ^ el mensaje automático del agente (bienvenida), si tiene uno: llega tras start() o, si no, con la primera respuesta
     onConversationEnd(fn: (content: string) => void): () => void;
     // ^ se agotaron los turnos de la conversación: `content` es el texto de cierre (llega antes que onDone).
     //   Desde ahí deshabilita la caja de texto; lo que se envíe después se descarta en el backend.
     onDone(fn: (threadId: string) => void): () => void;
     onError(fn: (message: string, info?: { status?: number; code?: string }) => void): () => void;
     // Dos orígenes: si el turno falló en el backend, `message` es un texto de catálogo, editable
     // en el panel y apto para enseñar. Si la petición ni abrió el stream, lo genera el SDK y es
     // técnico y en inglés: `Chat request failed (404|403|429|401)` o el mensaje del error de red
     // (`Failed to fetch`…). Trátalo SIEMPRE como diagnóstico: regístralo y pinta tu propia copia.
     // Con un 401, info.code es token_missing | token_invalid | token_expired: pide al usuario
     // que vuelva a iniciar sesión en la app.

     setContext(context: Record<string, unknown> | undefined): void; // surte efecto en el siguiente sendMessage()
     abort(): void;                 // cancela un sendMessage() en curso; NO dispara onError ni onDone
     // ⚠ Con createChatStore eso deja el store en status:'sending' para siempre (sus send()
     // posteriores son no-op) y solo reset() lo desbloquea, borrando los mensajes. Usa abort()
     // solo al desmontar. Si necesitas un botón de «parar» que conserve el hilo, usa
     // UrAgentClient directamente y acumula tú los mensajes.
     start(): Promise<void>;
     // ^ llámalo al ENSEÑAR el chat (no al cargar la página si va tras un botón), tras loadHistory() y solo
     //   si no había historial: el agente abre la conversación con su mensaje automático (llega por onNotice).
     //   Sin mensaje automático o con conversación ya existente no hace nada. Nunca lanza ni dispara onError.
     resetConversation(): string;   // nuevo subjectId, conversación nueva de verdad en el backend
     expireIfIdle(hours: number | null | undefined): boolean;
     // ^ si no se ha enviado nada en `hours` horas, estrena conversación y devuelve true (no hay historial)
     loadHistory(): Promise<{
       threadId: string | null;
       messages: {
         role: 'user' | 'bot'; content: string; time: string | null;
         isAutoMessage?: boolean; isConversationEnd?: boolean;
       }[];
       conversationEnded?: boolean; // se agotaron los turnos: la caja de texto sigue cerrada
     }>;
     sendMessage(text: string): Promise<void>; // resuelve cuando el streaming termina; nunca lanza
     getConfig(): Promise<{
       style: {};                     // vacío en este canal: el título y los colores son tuyos
       maxTurns?: number;             // límite de turnos por conversación: si viene, NO ofrezcas «nueva conversación»
       conversationTtlHours?: number; // pásalo a expireIfIdle() antes de cargar el historial
       authMode?: 'optional' | 'required'; // el agente identifica al usuario: hace falta getUserToken
     }>; // los límites de conversación del agente; lanza si falla (404 = canal apagado)
   }

   function createChatStore(client: UrAgentClient): {
     getState(): {
       messages: {
         id: string;
         role: 'user' | 'bot';
         content: string;
         time: string | null;
         isAutoMessage?: boolean;     // aviso automático del agente
         isError?: boolean;           // bubble sintético creado al recibir onError
         pending?: boolean;           // el bubble del bot sigue acumulando tokens
         isConversationEnd?: boolean; // texto de cierre al agotarse los turnos: estilo propio
       }[];
       status: 'idle' | 'loading-history' | 'sending' | 'error';
       error: string | null;
       errorCode?: string;            // con un 401: token_missing | token_invalid | token_expired
       conversationEnded: boolean;    // true → deshabilita la caja de texto
     };
     subscribe(listener: () => void): () => void; // NO llama al listener al suscribirte — lee getState() tú mismo justo después si necesitas el valor inicial
     loadHistory(): Promise<void>; // sustituye messages por el historial previo
     start(): Promise<void>;       // al enseñar el chat, tras loadHistory(): sin mensajes, pide la bienvenida del agente (primera burbuja, isAutoMessage)
     send(text: string): Promise<void>;          // no-op mientras status === 'sending' o con conversationEnded
     reset(): void;                               // limpia messages y conversationEnded, y llama a resetConversation()
   };

4. Patrón recomendado — funciona igual en cualquier framework, así que constrúyelo así salvo que
   te pida explícitamente usar `UrAgentClient` directamente:

   import { UrAgentClient, createChatStore } from '@nykk/ur-agent-client';

   const client = new UrAgentClient({ agent: '<id-del-agente>', apiBase: '<apiBase>' });
   const store = createChatStore(client);
   const config = await client.getConfig().catch(() => null);

   const expired = client.expireIfIdle(config?.conversationTtlHours);
   if (!client.isNewSubject && !expired) {
     await store.loadHistory(); // restaura la conversación previa de este navegador
   }
   // Cuando el chat se ve (aquí, o al abrir el panel si va detrás de un botón): el agente habla primero
   await store.start();

   // UI: renderiza store.getState().messages, un input de texto, y al enviar: store.send(texto)
   // Con store.getState().conversationEnded, deshabilita el input (el último mensaje, isConversationEnd, es el cierre)
   // Botón "nueva conversación": store.reset() y luego store.start() — solo si !config?.maxTurns

5. Conecta `store` a la reactividad de mi framework usando exactamente su contrato
   `getState()`/`subscribe()` — no reimplementes el acumulado de tokens a mano, ya lo hace
   `createChatStore`. Dime qué framework uso (React, Angular, Vue, Svelte, o ninguno/vanilla) si no
   lo sabes por el resto del proyecto, y adapta el store con el primitivo de estado idiomático de
   ese framework (signal, ref, store, useState, o un simple render manual).

6. Las respuestas del agente vienen en Markdown. Renderízalo (p. ej. `marked` + `DOMPurify`) en vez
   de pintarlas como texto plano.

7. Detalles que no debes pasar por alto:
   - No añadas cabeceras a mano. La identidad de conversación es un `subjectId` generado por el
     propio navegador y persistido en `localStorage`. Si el agente identifica al usuario
     (`getConfig()` trae `authMode`), pasa `getUserToken`: el token lo firma el backend de la app
     con su clave, y el navegador solo lo pide. Ninguna clave ni secreto va al código del cliente.
     Si la app no tiene esa ruta de token, pregúntamelo antes de inventarla.
   - `onToolEvent`/`onUsage` (en `UrAgentClient`, no expuestos por `createChatStore`) solo disparan
     en el chat de pruebas del panel — no los uses ni los esperes en una integración real. Tampoco
     uses la opción `getAuthHeaders`: está obsoleta.
   - `context` envía datos por turno a las tools del agente, agrupados por el **nombre de la
     instancia** de tool que los consume — no por el nombre de la plantilla: una plantilla puede
     tener varias instancias. El panel muestra el bloque exacto de cada agente en la ficha del canal
     del SDK. Las claves que ninguna tool declare se descartan (vuelven en `X-Ur-Context-Dropped`),
     y un campo con el tipo equivocado descarta el bloque entero de esa instancia. No es una
     credencial: nadie lo verifica y cualquiera puede falsificarlo. Quién es el usuario va en
     `getUserToken`, nunca en `context`.
   - Un mensaje bloqueado por seguridad llega como una respuesta normal (tokens + fin), no como
     error. No intentes detectarlo.
   - El fin de conversación sí se distingue, y hay que atenderlo: si el agente tiene límite de
     turnos, tras la última respuesta llega el texto de cierre (`onConversationEnd`, o en el store
     un mensaje con `isConversationEnd` y `conversationEnded: true`). Pinta ese mensaje distinto de
     las respuestas del agente y deshabilita la caja de texto; no ofrezcas «nueva conversación»
     mientras `getConfig()` traiga `maxTurns`.
   - El estilo/CSS es tu elección completa; el SDK no renderiza nada ni impone clases. El título y
     los colores van en tu código: el panel no los configura para este canal.

Recetas por framework ​

Lo que sigue es la traducción de «conecta store a la reactividad de mi framework» (paso 5 del prompt) para los casos más comunes. Los dos primeros son la versión mínima de las apps de referencia de Ejemplos, recortada a la capa de adaptación; el resto son el mismo patrón aplicado al framework correspondiente.

React ​

Igual que use-ur-agent-chat.ts del ejemplo React: useSyncExternalStore es literalmente el contrato getState()/subscribe() que ya expone el store, sin adaptador intermedio.

tsx
import { useState, useSyncExternalStore } from 'react';
import { createChatStore, UrAgentClient } from '@nykk/ur-agent-client';

function useUrAgentChat() {
  const [{ client, store }] = useState(() => {
    const client = new UrAgentClient({ agent: '<id-del-agente>', apiBase: '<apiBase>' });
    return { client, store: createChatStore(client) };
  });

  const state = useSyncExternalStore(store.subscribe, store.getState);
  // en un useEffect: if (!client.isNewSubject) void store.loadHistory();

  return { state, send: store.send, reset: store.reset };
}

Angular ​

Igual que agent-chat.service.ts del ejemplo Angular: un signal que se reescribe dentro de subscribe().

ts
import { Injectable, OnDestroy, signal } from '@angular/core';
import { createChatStore, UrAgentClient, type UrAgentChatState } from '@nykk/ur-agent-client';

@Injectable({ providedIn: 'root' })
export class AgentChatService implements OnDestroy {
  private readonly client = new UrAgentClient({ agent: '<id-del-agente>', apiBase: '<apiBase>' });
  private readonly store = createChatStore(this.client);
  private readonly unsubscribe: () => void;

  readonly state = signal<UrAgentChatState>(this.store.getState());

  constructor() {
    this.unsubscribe = this.store.subscribe(() => this.state.set(this.store.getState()));
    if (!this.client.isNewSubject) { void this.store.loadHistory(); }
  }

  send(text: string): void { void this.store.send(text); }
  reset(): void { this.store.reset(); }
  ngOnDestroy(): void { this.unsubscribe(); this.client.abort(); }
}

Vue ​

Mismo patrón con un shallowRef (el estado se sustituye entero en cada cambio, nunca se muta) y onScopeDispose para desuscribirse.

ts
import { onScopeDispose, shallowRef } from 'vue';
import { createChatStore, UrAgentClient } from '@nykk/ur-agent-client';

export function useUrAgentChat() {
  const client = new UrAgentClient({ agent: '<id-del-agente>', apiBase: '<apiBase>' });
  const store = createChatStore(client);
  const state = shallowRef(store.getState());

  const unsubscribe = store.subscribe(() => (state.value = store.getState()));
  onScopeDispose(() => { unsubscribe(); client.abort(); });
  if (!client.isNewSubject) void store.loadHistory();

  return { state, send: store.send, reset: store.reset };
}

Svelte ​

createChatStore no llama al listener al suscribirte (a diferencia del contrato de store nativo de Svelte, que sí lo hace) — un readable con un start que lee getState() una vez y luego reenvía cada cambio cierra esa diferencia:

ts
import { readable } from 'svelte/store';
import { createChatStore, UrAgentClient } from '@nykk/ur-agent-client';

const client = new UrAgentClient({ agent: '<id-del-agente>', apiBase: '<apiBase>' });
const store = createChatStore(client);

export const chatState = readable(store.getState(), (set) => {
  set(store.getState());
  if (!client.isNewSubject) void store.loadHistory();
  return store.subscribe(() => set(store.getState()));
});
export const send = store.send;
export const reset = store.reset;

Sin framework (vanilla) ​

El caso base del que derivan todos los anteriores: subscribe + una función de render.

ts
import { createChatStore, UrAgentClient } from '@nykk/ur-agent-client';

const client = new UrAgentClient({ agent: '<id-del-agente>', apiBase: '<apiBase>' });
const store = createChatStore(client);

function render() {
  const { messages, status } = store.getState();
  // pinta `messages` en el DOM que tengas
}

store.subscribe(render);
render();
if (!client.isNewSubject) void store.loadHistory();

// al enviar: void store.send(inputEl.value);
// botón reset: store.reset();

Si tu agente necesita más detalle ​

Este prompt cubre el 90% de una integración típica de chat. Para lo que se quedó fuera a propósito, dirige a tu agente a:

  • SDK — referencia — UrAgentClient y createChatStore completos, con todos los tipos.
  • Contexto de cliente — pasar datos de tu app a las tools.
  • Usuarios identificados — que el agente sepa quién es el usuario: el token que firma el backend de la app y getUserToken.
  • Seguridad y límites — el modelo de confianza del canal y cómo se manifiesta un bloqueo.
  • /llms.txt — mapa del sitio en el formato llms.txt, pensado para que cualquier agente lo cargue de un vistazo antes de tocar código.

Y si lo que quiere integrar no es un chat:

  • API HTTP — inferencias, RAGs y el listado de agentes desde un backend, con token Bearer.