YaDominiosYaDominios

Pasarela de Agentes: dale un asistente de IA a tu sistema, sin llaves ni servidores

Actualizado: 2026-08-04 · YaDominios

La Pasarela de Agentes es un intermediario entre tu sistema y un asistente de inteligencia artificial. Tu aplicación manda la conversación a POST yapanel.yadominios.com/agente/v1/chat con la clave de tu sitio, y nosotros hablamos con el proveedor. Lo importante: soporta ida y vuelta de herramientas — el asistente puede pedirte que ejecutes una función tuya (por ejemplo «cierre_del_dia») contra TU propia base de datos, tú se la ejecutas y vuelves a llamar con el resultado, tantas vueltas como haga falta. Nosotros nunca tocamos tu base ni guardamos las conversaciones. Se cobra por consumo en créditos: 1 crédito = una milésima de dólar.

Instalación en 5 pasos

Si vas de afán, esto es todo lo que hay que hacer. El detalle de cada cosa está más abajo.

  1. Activa la pasarela. Panel → Pasarela de Agentes en el menú de la izquierda → botón Activar.
  2. Saca tu llave. sk_ si tu sistema tiene servidor propio; pk_ si es una web sin servidor. Se muestra una sola vez.
  3. Si usas pk_, autoriza tu dominio en la misma pantalla. Sin eso, la llave no funciona desde ningún lado.
  4. Describe tus funciones (las consultas que tu sistema ya sabe hacer) en el campo herramientas.
  5. Programa el bucle: llamas, y si te responde parar_por: "herramienta" ejecutas esa función y vuelves a llamar con el resultado. Repites hasta que diga "fin".

El paso 5 es el único que tiene chiste, y está resuelto entero en el ejemplo de la burbuja flotante más abajo: se copia y funciona.

Cómo funciona, de un vistazo

TU SISTEMA (tu servidor o tu web) LA PASARELA (YaDominios) TU BASE DE DATOS (nunca la tocamos) 1 «¿cuánto vendí ayer en Caracas?» 2 parar_por: "herramienta" ejecuta cierre_del_dia(caracas, ayer) 3 TU código hace la consulta { total_usd: 4312.55, notas: 27 } 4 le devuelves el resultado 5 parar_por: "fin" - la respuesta
Los pasos 2 a 4 se pueden repetir varias veces antes del 5. Tu base de datos nunca sale de tu lado.

Dónde se saca la llave

YaDominios Resumen Mis dominios YaDominios Cloud Mis correos Pasarela de Agentes 1 Mis sitios Mis facturas Pasarela de Agentes mi-sitio 5.000 créditos $5.00 equivalen a TUS LLAVES Nueva pública Nueva secreta 2 Guárdala ahora: esta llave no se vuelve a mostrar. sk_4a5e1c9d8b7f4e2a9c3d... Copiar 3 DOMINIOS AUTORIZADOS mi-tienda.com Autorizar 4
1 Entras por el menú. 2 Sacas la llave. 3 La copias (sale una sola vez). 4 Solo para la llave pública: autorizas tu dominio.

Para qué sirve

Tu sistema ya sabe consultar su propia base de datos: el cierre de caja del día, las facturas vencidas por vendedor, el historial de un cliente. Lo que no sabe es entender una pregunta en lenguaje normal y decidir qué consulta hace falta.

Eso es lo que agrega la pasarela. El dueño del negocio escribe «¿cuánto vendí ayer en Caracas?» y el asistente decide que necesita tu función cierre_del_dia, te la pide, tú la ejecutas y él redacta la respuesta.

Lo que NO hacemos, y es la parte importante

  • No tocamos tu base de datos. Cuando el asistente quiere datos, te los pide a ti. La consulta la ejecuta tu sistema, en tu servidor, con tus permisos. Nosotros no sabemos ni queremos saber qué hay del otro lado.
  • No guardamos las conversaciones. Las preguntas y los resultados de tu negocio pasan por la pasarela y se van. En el registro solo queda: sitio, fecha, modelo, cantidad de tokens y créditos. Ni una palabra del contenido.
  • No tenemos memoria. El historial completo lo guardas tú y lo mandas entero en cada llamada. Así no dependemos de que nosotros custodiemos tus datos.

El flujo real: ida y vuelta de herramientas

Esto no es un chat de una sola pasada. El ciclo completo es:

  1. Tu sistema manda la pregunta del usuario.
  2. La pasarela responde: «el asistente quiere ejecutar cierre_del_dia con estos parámetros» (parar_por: "herramienta").
  3. Tu sistema ejecuta esa función contra tu base y vuelve a llamar mandando el resultado.
  4. La pasarela responde con el texto final (parar_por: "fin").

Puede haber varias vueltas antes del texto final. Si tu integración solo mira parar_por: "fin", se va a quedar esperando para siempre — el paso 2 es obligatorio.

¿Y si mi página está alojada en otro lado?

Funciona igual. La Pasarela no necesita tu código ni tu repositorio: lo que autoriza tu llave pública es tu dominio, y ese lo tienes con nosotros. Si nos pagas el servicio pero tu web vive en otro proveedor, en tu panel verás tu servicio con el botón Activar la Pasarela de Agentes igual que cualquier otro cliente.

En ese caso, el nombre que mandas en X-Sitio se arma de tu dominio cambiando los puntos por guiones — mi-tienda.com queda mi-tienda-com — y aparece en la misma pantalla donde sacas la llave.

Las dos claves, y cuál usar

ClaveDónde vaCómo se protege
pk_… públicaPuede ir dentro del navegador. Para aplicaciones web sin servidor propio.Solo funciona desde los dominios autorizados de tu sitio, y tiene tope de llamadas por minuto.
sk_… secretaSolo en tu servidor. Nunca en una página web.Sin restricción de dominio. Se muestra una sola vez al crearla.

Las dos se generan y se revocan desde el panel: Pasarela de Agentes en el menú, o el botón «Pasarela de Agentes» en la tarjeta de tu sitio.

POST /agente/v1/chat

Cabeceras: X-Sitio (el nombre de tu sitio en el panel), X-Clave y un User-Agent propio si llamas desde un servidor (ver más abajo por qué).

El cuerpo, entero. Solo mensajes es obligatorio:

{
  "modelo": "normal",              // rapido | normal | maximo. Por defecto: normal
  "max_tokens": 4096,              // largo máximo de la respuesta. Tope: 8192
  "sistema": "Eres el asistente de una ferretería con dos sucursales.",
  "herramientas": [
    {
      "nombre": "cierre_del_dia",
      "descripcion": "Devuelve el cierre de caja de una sucursal en una fecha.",
      "esquema": {
        "type": "object",
        "properties": {
          "sucursal": { "type": "string" },
          "fecha": { "type": "string" }
        },
        "required": ["sucursal", "fecha"]
      }
    }
  ],
  "mensajes": [
    { "rol": "usuario", "contenido": [
        { "tipo": "texto", "texto": "¿cuánto vendí ayer en Caracas?" } ] }
  ]
}

Los cuatro tipos de bloque

tipoQuién lo escribeCampos
textoTú o el asistentetexto
herramientaEl asistente (lo devuelves tal cual en la siguiente vuelta)id, nombre, entrada
resultadoTú, con lo que devolvió tu funciónid (el mismo de la herramienta), contenido, error opcional

El campo error: úsalo siempre que tu consulta falle

Es opcional y es lo que separa un asistente confiable de uno que inventa. Cuando tu función no encuentra el dato o revienta, no le mandes el texto del error como si fuera un resultado bueno: márcalo.

{ "tipo": "resultado", "id": "toolu_013Ct...",
  "contenido": "No encontré ese cliente en la base.", "error": true }

Con error: true el asistente lo entiende como un fallo de la consulta y responde «no encontré ningún cliente con esa cédula, ¿puedes verificar el número?». Sin él, tomará ese texto por un dato y construirá una respuesta con cara de seguridad sobre algo que no existe.

Los resultados van con rol usuario, y TODOS juntos

Dos cosas que no son obvias y que hacen fallar la primera integración:

  • El rol es usuario. No hay un rol aparte para los resultados de herramienta: el bloque resultado viaja dentro de un mensaje normal de rol usuario.
  • Si pidió varias funciones, se devuelven TODAS en un solo mensaje. El asistente puede pedir cuatro consultas de un golpe (comparar dos sucursales, por ejemplo). Hay que ejecutarlas todas y mandar los cuatro bloques resultado juntos, en el mismo mensaje. De una en una, la llamada devuelve peticion_invalida.
historial.push({
  rol: "usuario",
  contenido: [
    { tipo: "resultado", id: "toolu_A", contenido: "{...}" },
    { tipo: "resultado", id: "toolu_B", contenido: "{...}" },
  ],
});

Respuesta cuando el asistente terminó

{
  "ok": true,
  "parar_por": "fin",
  "contenido": [ { "tipo": "texto", "texto": "Ayer en Caracas vendiste..." } ],
  "consumo": { "entrada": 652, "salida": 45, "cache_lectura": 3511, "cache_escritura": 0 },
  "creditos_cobrados": 14,
  "creditos_restantes": 4971,
  "saldo_bajo": false,
  "avisos": [],
  "limite": { "por_minuto": 20, "restantes": 19 },
  "peticion_id": "9f3c1e7a-..."
}

Respuesta cuando pide ejecutar una función

{
  "ok": true,
  "parar_por": "herramienta",
  "contenido": [ { "tipo": "herramienta", "id": "toolu_013Ct...",
                   "nombre": "cierre_del_dia",
                   "entrada": { "sucursal": "caracas", "fecha": "2026-08-03" } } ],
  "consumo": { "entrada": 542, "salida": 85, "cache_lectura": 0, "cache_escritura": 0 },
  "creditos_cobrados": 15,
  "creditos_restantes": 4985
}

GET /agente/v1/saldo

Mismas cabeceras. Sirve para que muestres el saldo dentro de tu propio panel.

{ "ok": true, "creditos_restantes": 4971, "gastado_mes": 29, "sitio": "60-viviendas" }

Los créditos caducan a los 90 días sin usarse

El reloj cuenta desde la última vez que usaste el asistente, no desde que recargaste. Cada consulta lo reinicia: mientras lo uses, no pierdes nunca un crédito. Si el asistente se queda tres meses sin que nadie le pregunte nada, el saldo caduca y hay que recargar.

No se vence en silencio: te avisamos por correo 15 días, 7 días y 1 día antes, la fecha sale en tu panel, y cuando caduca queda anotado en tu historial de movimientos con su fecha y su motivo.

Al caducar, el asistente sigue instalado y tu llave sigue siendo la misma: solo hay que recargar para volver a usarlo.

El caché: la diferencia entre barato y caro

Léelo antes de escribir tus instrucciones. Las instrucciones y las herramientas se guardan en caché y en las siguientes llamadas se cobran a una fracción del precio. Pero el caché solo entra si el bloque llega a un tamaño mínimo — unas 1.024 fichas, más o menos 4.000 letras. Por debajo de eso el proveedor lo ignora en silencio y pagas todo a precio lleno.

Medido en producción por un cliente, mismo flujo, cambiando solo el largo de las instrucciones:

InstruccionesQué pasóCosto de la 2.ª llamada
3.630 letrasNo se cacheó nada, sin avisoPrecio lleno, unas 3 veces más
6.029 letrasSe cacheó y se leyó del caché31 créditos

Ya no hay que adivinarlo: si el caché no entró, la respuesta te lo dice en el campo avisos, con el tamaño que tienes y el que falta. Dos reglas para aprovecharlo:

  • Que el bloque sea largo. Si tus instrucciones son cortas, alárgalas: describe el negocio, las reglas, los formatos, los casos raros. Cuesta menos que quedarse corto.
  • Que sea idéntico carácter por carácter entre llamadas. Una fecha con la hora dentro de las instrucciones rompe el caché en cada llamada y multiplica la factura. Si necesitas la fecha, ponla en el mensaje del usuario, no en las instrucciones.

Avisos: lo que la respuesta te dice sin que sea un error

Toda respuesta trae un avisos, normalmente vacío. Cuando trae algo, es información que te ahorra dinero o un disgusto:

codigoQué significa
cache_no_aplicadoNada se guardó en caché. El mensaje dice si es por tamaño o porque tus instrucciones cambian entre llamadas.
saldo_bajoQuedan menos de 500 créditos. Recarga antes de que una consulta se corte por la mitad.

Además, toda respuesta trae saldo_bajo (booleano), peticion_id (el número de esa llamada, para reclamarla a soporte — también va en los errores y en la cabecera X-Peticion-Id) y limite con las llamadas que te quedan del minuto.

Si llamas desde un servidor: manda un User-Agent

Nuestra red bloquea las peticiones que llegan con la firma por defecto de algunas librerías (Python-urllib, entre otras). Si te devuelve un 403 con un cuerpo que no es JSON, es eso: la petición ni siquiera llegó a la pasarela.

Se arregla mandando un User-Agent propio, que además es buena práctica: identifica tu sistema en los registros.

// Node / cualquier cliente HTTP
headers: {
  "content-type": "application/json",
  "X-Sitio": "mi-sitio",
  "X-Clave": process.env.YADOMINIOS_CLAVE,
  "User-Agent": "MiSistema/1.0",     // <-- esto
}
# Python
import requests
requests.post(URL, json=cuerpo, headers={
    "X-Sitio": "mi-sitio",
    "X-Clave": CLAVE,
    "User-Agent": "MiSistema/1.0",   # <-- esto
})

Los errores, con su código estable

Todos vienen con "ok": false y un campo error que no cambia nunca: programa contra el código, no contra el texto del mensaje.

HTTPerrorQué pasóQué hacer
401clave_invalidaLa clave no existe, fue revocada o no es de ese sitioRevisa X-Sitio y X-Clave
402sin_créditosEl sitio se quedó sin saldo (trae créditos_restantes: 0)Recargar desde el panel. No se llama al proveedor.
403origen_no_autorizadoClave pública usada desde un dominio no autorizadoAgrega el dominio en el panel
403sitio_apagadoLa pasarela del sitio está apagadaActívala en el panel
429limite_de_usoPasaste el tope por minuto. La respuesta trae reintentar_en con los segundos que faltan para el minuto siguienteEspera y reintenta. Para no llegar aquí, mira limite.restantes de la respuesta anterior
400modelo_no_disponibleAlias inexistente o desactivadoUsa rapido, normal o maximo
400peticion_invalidaEl formato del cuerpo está mal — el mensaje dice exactamente dóndeCorrige el bloque que indica
502falla_del_proveedorEl proveedor de IA no respondióReintenta. No se cobran créditos.

Los tres modelos

Tu sistema manda un alias, nunca el nombre técnico del modelo. Así podemos mejorar el modelo por detrás sin que toques tu código.

aliasPara qué
rapidoPreguntas simples, respuestas cortas, mucho volumen
normalEl equilibrio. Es el que se usa si no mandas nada.
maximoPreguntas difíciles que cruzan varias consultas

«auto»: que elijamos nosotros

Además de los tres, puedes mandar "modelo": "auto" y decidimos por vuelta: la primera —donde el asistente tiene que elegir cuál de tus funciones llamar— va al modelo bueno, y las siguientes —donde ya tiene tus datos y solo redacta la respuesta— van al rápido.

Medido en producción con una pregunta que cruza cuatro consultas: 75 créditos con el modelo fijo, 49 en automático. Un 35% menos por la misma respuesta. Si tus preguntas son del tipo «consulta y cuéntame», es la opción que más te rinde.

Los créditos

1 crédito = una milésima de dólar. Una recarga de $10 son 10.000 créditos. Se cobra cada llamada, incluida la que solo pide ejecutar una función: esa también consumió el modelo entero. Si el proveedor falla o la clave es inválida, no se cobra.

Las instrucciones y la lista de herramientas se marcan para caché: como se repiten en cada pregunta, releerlas cuesta la décima parte. No tienes que hacer nada — pero conviene que tus instrucciones y tus herramientas sean idénticas entre llamadas, porque el caché funciona por coincidencia exacta.

Ejemplo completo, probado de verdad

Estas dos llamadas están copiadas de una prueba real contra el servicio en vivo. Cambia la clave por la tuya.

Vuelta 1 — la pregunta:

curl -s -X POST "https://yapanel.yadominios.com/agente/v1/chat" \
  -H "content-type: application/json" \
  -H "X-Sitio: mi-sitio" \
  -H "X-Clave: sk_TU_CLAVE" \
  -d '{
    "modelo": "normal",
    "sistema": "Eres el asistente de una ferreteria con dos sucursales: caracas y valencia.",
    "herramientas": [{
      "nombre": "cierre_del_dia",
      "descripcion": "Devuelve el cierre de caja de una sucursal en una fecha.",
      "esquema": {"type":"object","properties":{"sucursal":{"type":"string"},"fecha":{"type":"string"}},"required":["sucursal","fecha"]}
    }],
    "mensajes": [{"rol":"usuario","contenido":[{"tipo":"texto","texto":"cuanto vendi el 3 de agosto de 2026 en caracas?"}]}]
  }'

Devuelve parar_por: "herramienta" con el id de la petición. Ejecutas tu función y devuelves el resultado con ese mismo id:

Vuelta 2 — el resultado:

curl -s -X POST "https://yapanel.yadominios.com/agente/v1/chat" \
  -H "content-type: application/json" \
  -H "X-Sitio: mi-sitio" \
  -H "X-Clave: sk_TU_CLAVE" \
  -d '{
    "modelo": "normal",
    "sistema": "Eres el asistente de una ferreteria con dos sucursales: caracas y valencia.",
    "herramientas": [{
      "nombre": "cierre_del_dia",
      "descripcion": "Devuelve el cierre de caja de una sucursal en una fecha.",
      "esquema": {"type":"object","properties":{"sucursal":{"type":"string"},"fecha":{"type":"string"}},"required":["sucursal","fecha"]}
    }],
    "mensajes": [
      {"rol":"usuario","contenido":[{"tipo":"texto","texto":"cuanto vendi el 3 de agosto de 2026 en caracas?"}]},
      {"rol":"asistente","contenido":[{"tipo":"herramienta","id":"EL_ID_QUE_TE_DEVOLVIO","nombre":"cierre_del_dia","entrada":{"sucursal":"caracas","fecha":"2026-08-03"}}]},
      {"rol":"usuario","contenido":[{"tipo":"resultado","id":"EL_ID_QUE_TE_DEVOLVIO","contenido":"{\"total_usd\": 4312.55, \"notas\": 27}"}]}
    ]
  }'

Y la respuesta real de esa segunda llamada:

{"ok":true,"parar_por":"fin",
 "contenido":[{"tipo":"texto","texto":"El 3 de agosto de 2026 en Caracas se vendió **$4,312.55 USD** (27 notas/facturas)."}],
 "consumo":{"entrada":652,"salida":45,"cache_lectura":0,"cache_escritura":0},
 "creditos_cobrados":14,"creditos_restantes":4971}

Límites y detalles finos (medidos, no supuestos)

QuéCuánto
Peticiones por minuto (llave pk_)20 por defecto, ajustable por sitio. Cuenta CADA petición, también las vueltas de herramientas: una pregunta puede gastar 5. Si tu sistema hace ida y vuelta seguido, pídenos subirlo.
Peticiones por minuto (llave sk_)Sin tope. Vive en tu servidor, no en un navegador ajeno.
Tamaño del cuerpoProbado con 2 MB sin problema. Un resultado de 50 KB no es nada; lo que sí cuesta son los tokens que ocupa.
Largo de la respuestaTope de 8.192 tokens. Puedes pedir menos con max_tokens, no más.
Tiempo por llamadaNo hay corte nuestro. Una consulta que piensa y cruza datos tarda entre 2 y 40 segundos según el modelo; pon tú el tiempo de espera de tu lado.
Tamaño de las instruccionesSin tope propio. Lo que manda es el contexto del modelo, que es de un millón de tokens.
Cantidad de herramientasSin tope propio. Diez es un número cómodo; con muchas más, al asistente le cuesta elegir.

El caché: cuándo entra de verdad y cuánto ahorra

Que las instrucciones y las herramientas se cobren al 10% en las relecturas tiene una condición que no es obvia: el bloque marcado tiene que pasar de unos 1.024 tokens (unas 4.000 letras). Por debajo de eso, la marca se ignora y pagas todo a precio lleno sin que nada te avise.

Medido contra la pasarela real, con unas instrucciones de 4.500 letras:

llamada 1: escribe caché=1836  lee caché=0     → 35 créditos
llamada 2: escribe caché=0     lee caché=1836  →  4 créditos
llamada 3: escribe caché=0     lee caché=1836  →  4 créditos

La misma pregunta, casi nueve veces más barata. El caché dura unos 5 minutos, y cada uso reinicia ese reloj: mientras alguien pregunte cada pocos minutos se mantiene caliente solo. Si tu sistema se queda en silencio media hora, la siguiente pregunta vuelve a pagar la escritura — no vale la pena mantenerlo caliente a propósito con llamadas de mentira, porque cada llamada también se cobra.

La condición para que sirva: que las instrucciones y las herramientas viajen idénticas letra por letra en cada llamada. Una fecha con la hora dentro de las instrucciones rompe el caché en cada petición y multiplica la factura.

El asistente puede pedir VARIAS funciones de una vez

No siempre pide una. En una prueba real, ante «compara la utilidad de Caracas y Valencia el 3 de agosto», pidió cuatro de un golpe: ventas y gastos de cada sucursal. Tu código tiene que recorrer contenido, ejecutar TODOS los bloques de tipo herramienta, y devolver todos los resultados juntos en un solo mensaje de rol usuario. Mandarlos de uno en uno da peticion_invalida.

Cuando tu función falla

Devuelve el resultado igual, con "error": true. El asistente lo entiende como un fallo de esa consulta y se corrige solo — probado: ante «no encontré ese cliente», contestó pidiendo verificar la cédula en vez de inventarse un historial.

La burbuja flotante: copiar, pegar y listo

La forma más común de añadir esto a un sistema que ya existe: una burbuja en la esquina. Nada de lo que ya tienes cambia.

Cómo tiene que verse — no es un detalle, es el producto

Este chat lo va a usar el dueño del negocio todos los días. Si se ve pobre, el asistente parece pobre por bueno que sea. El código de abajo ya trae todo esto resuelto, y si lo escribes desde cero, cúmplelo igual:

  • Se parece a WhatsApp, porque es lo que todos saben usar. Globos con la punta hacia su lado, el del usuario a la derecha con el color de la marca, el del asistente a la izquierda en gris, y la hora en cada mensaje.
  • Nada de asteriscos a la vista. El asistente responde con marcas de formato (**negrita**, listas con guiones). Si las pintas como texto plano, el cliente ve **2 órdenes** y el chat parece roto. Hay que convertirlas — el ejemplo trae un convertidor de doce líneas, sin librerías.
  • Con emojis, uno o dos por respuesta. Eso no se logra en el código sino en las instrucciones que le mandas: mira el campo INSTRUCCIONES del ejemplo.
  • Con los tres puntitos mientras piensa. Una consulta tarda entre uno y cinco segundos; sin señal de vida, el usuario cree que se colgó y vuelve a tocar.
  • Grande y cómodo: unos 400 px de ancho y 620 de alto en computadora, pantalla casi completa en el celular. Un chat chiquito se siente de juguete.
  • Con preguntas de ejemplo al abrir. Nadie sabe qué preguntarle a un asistente en blanco. Tres botones con las preguntas típicas del negocio resuelven el primer minuto.
  • Con los colores de la marca del cliente. En el ejemplo son cuatro valores al principio: cámbialos y el chat es suyo.
Ferremateriales - Panel de ventas Facturas Inventario Clientes Reportes (tu sistema, tal como está hoy - no se toca) Pregúntale a tu negocio cuánto vendí ayer en Caracas? Ayer en Caracas vendiste $4.312,55 en 27 notas. consultando cierre_del_dia... Escribe tu pregunta... Ir
La burbuja va encima de tu sistema, sin tocar nada de lo que ya tienes. El código completo está más abajo.

Este es el archivo entero. Pégalo antes de </body> y cambia lo que está marcado con <-- CAMBIA. El bucle de herramientas, el formato de las respuestas y el diseño ya están resueltos.

<script>
(function () {
  // ── 1. LO QUE TIENES QUE CAMBIAR ──────────────────────────────────────
  const SITIO = "mi-sitio";              // <-- CAMBIA: el nombre en el panel
  const CLAVE = "pk_tu_clave_publica";   // <-- CAMBIA: la llave del panel

  // LOS COLORES DE TU MARCA. Cambia estos cuatro y el chat es tuyo.
  const MARCA = {
    color: "#2bc7e8",        // el color principal
    colorTexto: "#04222c",   // texto sobre ese color
    titulo: "Asistente de mi negocio",
    saludo: "¡Hola! 👋 Pregúntame por tus ventas, tu agenda o tus clientes.",
  };

  const EJEMPLOS = ["¿Cómo vamos hoy?", "¿Qué hay agendado?", "¿Qué está pendiente?"];

  const FUNCIONES = {
    cierre_del_dia: {
      descripcion: "Devuelve el cierre de caja de una sucursal en una fecha (AAAA-MM-DD).",
      esquema: { type: "object", properties: { sucursal: { type: "string" }, fecha: { type: "string" } }, required: ["sucursal", "fecha"] },
      ejecutar: async () => ({ total_usd: 4312.55, notas: 27 }),
    },
  };

  const INSTRUCCIONES =
    "Eres el asistente del sistema de una ferretería con dos sucursales, Caracas y Valencia. " +
    "Hablas como en un chat de WhatsApp: cálido, directo y en español neutro. " +
    "Frases cortas. Usa uno o dos emojis por respuesta, nunca más, y solo donde aporten. " +
    "Usa **negrita** para los números y los nombres que importan, y listas con guiones cuando " +
    "sean varios elementos: el chat las pinta bien. " +
    "Nunca inventes cifras: si te falta un dato, pídelo. " +
    "Cierra ofreciendo el siguiente paso cuando tenga sentido. " +
    "Hoy es " + new Date().toISOString().slice(0, 10) + ". La moneda es el dólar. " +
    "El cierre de caja se hace a las ocho de la noche y los precios ya llevan impuesto incluido. " +
    "Si una consulta devuelve vacío, dilo tal cual en vez de suponer que fue cero. " +
    "Cuando el dueño pregunte por un periodo, confirma las fechas exactas antes de responder. " +
    "No hables de tus herramientas ni de cómo obtienes los datos: solo da la respuesta.";

  // ── 2. DE AQUÍ PARA ABAJO NO HACE FALTA TOCAR NADA ────────────────────
  const URL = "https://yapanel.yadominios.com/agente/v1/chat";
  const historial = [];

  const escapar = (t) =>
    String(t).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

  const css = `
    #yad-b{position:fixed;right:20px;bottom:20px;width:58px;height:58px;border-radius:50%;
      border:0;background:${MARCA.color};color:${MARCA.colorTexto};font-size:24px;cursor:pointer;
      z-index:9998;box-shadow:0 8px 24px rgba(0,0,0,.3);display:grid;place-items:center;
      transition:transform .15s}
    #yad-b:hover{transform:scale(1.06)}
    #yad-p{position:fixed;right:20px;bottom:88px;width:min(400px,calc(100vw - 32px));
      height:min(620px,calc(100vh - 130px));display:none;flex-direction:column;
      background:#0f1720;border:1px solid rgba(255,255,255,.08);border-radius:18px;overflow:hidden;
      z-index:9999;box-shadow:0 24px 60px rgba(0,0,0,.45);
      font:15px/1.55 -apple-system,system-ui,"Segoe UI",sans-serif}
    #yad-p header{background:#151f2b;padding:14px 16px;display:flex;gap:12px;align-items:center;
      border-bottom:1px solid rgba(255,255,255,.06)}
    #yad-av{width:38px;height:38px;border-radius:50%;background:${MARCA.color};color:${MARCA.colorTexto};
      display:grid;place-items:center;font-size:19px;flex:0 0 auto}
    #yad-p header b{color:#e9eef4;font-size:15px;font-weight:600;display:block}
    #yad-p header span{color:#7d8b9c;font-size:12.5px}
    #yad-m{flex:1;overflow-y:auto;padding:16px 14px;display:flex;flex-direction:column;gap:10px;
      background:#0b1219}
    #yad-m::-webkit-scrollbar{width:6px}
    #yad-m::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:3px}
    .yad-fila{display:flex;max-width:100%}
    .yad-fila.u{justify-content:flex-end}
    .yad-globo{max-width:82%;padding:9px 13px;border-radius:16px;position:relative;
      word-wrap:break-word;overflow-wrap:anywhere;font-size:14.5px}
    .yad-fila.u .yad-globo{background:${MARCA.color};color:${MARCA.colorTexto};border-bottom-right-radius:5px}
    .yad-fila.a .yad-globo{background:#1b2733;color:#dfe7ef;border-bottom-left-radius:5px}
    .yad-globo p{margin:0 0 8px}
    .yad-globo p:last-child{margin:0}
    .yad-globo strong{font-weight:650}
    .yad-globo ul{margin:6px 0;padding-left:18px}
    .yad-globo li{margin:3px 0}
    .yad-globo code{background:rgba(255,255,255,.09);padding:1px 5px;border-radius:5px;font-size:13px}
    .yad-hora{display:block;margin-top:4px;font-size:10.5px;opacity:.55;text-align:right}
    .yad-esc{display:flex;gap:4px;padding:12px 14px;background:#1b2733;border-radius:16px;
      border-bottom-left-radius:5px;width:fit-content}
    .yad-esc i{width:7px;height:7px;border-radius:50%;background:#6d7d8f;display:block;
      animation:yad-salta 1.2s infinite}
    .yad-esc i:nth-child(2){animation-delay:.18s}
    .yad-esc i:nth-child(3){animation-delay:.36s}
    @keyframes yad-salta{0%,60%,100%{opacity:.35;transform:translateY(0)}30%{opacity:1;transform:translateY(-4px)}}
    #yad-suge{display:flex;flex-wrap:wrap;gap:7px;padding:0 14px 12px;background:#0b1219}
    #yad-suge button{background:transparent;border:1px solid rgba(255,255,255,.14);color:#aab6c4;
      border-radius:999px;padding:7px 13px;font-size:13px;cursor:pointer;transition:all .15s}
    #yad-suge button:hover{border-color:${MARCA.color};color:${MARCA.color}}
    #yad-f{display:flex;gap:8px;padding:12px;background:#151f2b;border-top:1px solid rgba(255,255,255,.06)}
    #yad-i{flex:1;min-width:0;border:1px solid rgba(255,255,255,.12);background:#0b1219;color:#e9eef4;
      border-radius:22px;padding:11px 16px;font:inherit;font-size:14.5px;outline:none}
    #yad-i:focus{border-color:${MARCA.color}}
    #yad-i::placeholder{color:#5f6d7d}
    #yad-s{border:0;background:${MARCA.color};color:${MARCA.colorTexto};border-radius:50%;
      width:42px;height:42px;font-size:17px;cursor:pointer;flex:0 0 auto;display:grid;place-items:center}
    #yad-s:disabled{opacity:.4;cursor:default}`;
  document.head.appendChild(Object.assign(document.createElement("style"), { textContent: css }));

  document.body.insertAdjacentHTML("beforeend", `
    <button id="yad-b" aria-label="Abrir el asistente">💬</button>
    <div id="yad-p" role="dialog" aria-label="${escapar(MARCA.titulo)}">
      <header>
        <div id="yad-av">🤖</div>
        <div><b>${escapar(MARCA.titulo)}</b><span>En línea · responde al momento</span></div>
      </header>
      <div id="yad-m"></div>
      <div id="yad-suge"></div>
      <form id="yad-f">
        <input id="yad-i" placeholder="Escribe tu pregunta…" autocomplete="off">
        <button id="yad-s" type="submit" aria-label="Enviar">➤</button>
      </form>
    </div>`);

  const panel = document.getElementById("yad-p");
  const lista = document.getElementById("yad-m");
  const sugerencias = document.getElementById("yad-suge");
  const campo = document.getElementById("yad-i");
  const enviar = document.getElementById("yad-s");

  const hora = () => new Date().toLocaleTimeString("es-US", { hour: "numeric", minute: "2-digit" });

  /**
   * El texto del asistente viene con marcas de Markdown. Sin esto, el cliente
   * ve "**2 órdenes**" con los asteriscos a la vista y el chat parece roto.
   * Se ESCAPA primero y se aplican las marcas después: lo que llega es texto de
   * otro sistema y nunca se pinta como HTML tal cual.
   */
  function conFormato(texto) {
    const seguro = escapar(texto);
    const conMarcas = seguro
      .replace(/**(.+?)**/g, "<strong>$1</strong>")   // **negrita** primero
      .replace(/(^|[^*])*([^*
]+?)*($|[^*])/g, "$1<em>$2</em>$3")  // *cursiva* después
      .replace(/`(.+?)`/g, "<code>$1</code>");
    const lineas = conMarcas.split("
");
    let html = "", enLista = false;
    for (const linea of lineas) {
      const item = linea.match(/^s*[-*•]s+(.*)$/);
      if (item) {
        if (!enLista) { html += "<ul>"; enLista = true; }
        html += `<li>${item[1]}</li>`;
      } else {
        if (enLista) { html += "</ul>"; enLista = false; }
        if (linea.trim()) html += `<p>${linea}</p>`;
      }
    }
    if (enLista) html += "</ul>";
    return html || `<p>${seguro}</p>`;
  }

  function globo(quien, texto) {
    const fila = document.createElement("div");
    fila.className = `yad-fila ${quien}`;
    const g = document.createElement("div");
    g.className = "yad-globo";
    g.innerHTML = conFormato(texto) + `<span class="yad-hora">${hora()}</span>`;
    fila.appendChild(g);
    lista.appendChild(fila);
    lista.scrollTop = lista.scrollHeight;
    return fila;
  }

  function escribiendo(prender) {
    const previo = document.getElementById("yad-escribiendo");
    if (previo) previo.remove();
    if (!prender) return;
    const fila = document.createElement("div");
    fila.className = "yad-fila a";
    fila.id = "yad-escribiendo";
    fila.innerHTML = `<div class="yad-esc"><i></i><i></i><i></i></div>`;
    lista.appendChild(fila);
    lista.scrollTop = lista.scrollHeight;
  }

  function pintarSugerencias() {
    sugerencias.innerHTML = "";
    for (const t of EJEMPLOS) {
      const b = document.createElement("button");
      b.type = "button";
      b.textContent = t;
      b.onclick = () => { sugerencias.innerHTML = ""; preguntarYPintar(t); };
      sugerencias.appendChild(b);
    }
  }

  async function preguntar(texto) {
    historial.push({ rol: "usuario", contenido: [{ tipo: "texto", texto }] });
    const herramientas = Object.entries(FUNCIONES).map(([nombre, f]) => ({
      nombre, descripcion: f.descripcion, esquema: f.esquema,
    }));

    for (let vuelta = 0; vuelta < 5; vuelta++) {
      const r = await fetch(URL, {
        method: "POST",
        headers: { "content-type": "application/json", "X-Sitio": SITIO, "X-Clave": CLAVE },
        body: JSON.stringify({ modelo: "auto", sistema: INSTRUCCIONES, herramientas, mensajes: historial }),
      });
      const d = await r.json();
      if (!d.ok) return "Uy, algo falló: " + d.mensaje;

      historial.push({ rol: "asistente", contenido: d.contenido });
      if (d.parar_por !== "herramienta") {
        return d.contenido.filter((b) => b.tipo === "texto").map((b) => b.texto).join("
");
      }

      const resultados = [];
      for (const b of d.contenido) {
        if (b.tipo !== "herramienta") continue;
        try {
          const salida = await FUNCIONES[b.nombre].ejecutar(b.entrada);
          resultados.push({ tipo: "resultado", id: b.id, contenido: JSON.stringify(salida) });
        } catch (e) {
          resultados.push({ tipo: "resultado", id: b.id, contenido: String(e), error: true });
        }
      }
      historial.push({ rol: "usuario", contenido: resultados });
    }
    return "No pude terminar la consulta.";
  }

  async function preguntarYPintar(texto) {
    globo("u", texto);
    campo.value = "";
    enviar.disabled = true;
    escribiendo(true);
    try {
      const respuesta = await preguntar(texto);
      escribiendo(false);
      globo("a", respuesta);
    } catch (e) {
      escribiendo(false);
      globo("a", "No pude conectarme. Inténtalo otra vez en un momento. 🙏");
    }
    enviar.disabled = false;
    campo.focus();
  }

  document.getElementById("yad-b").onclick = () => {
    const abierto = panel.style.display === "flex";
    panel.style.display = abierto ? "none" : "flex";
    document.getElementById("yad-b").textContent = abierto ? "💬" : "✕";
    if (!abierto) {
      if (!lista.children.length) { globo("a", MARCA.saludo); pintarSugerencias(); }
      campo.focus();
    }
  };

  document.getElementById("yad-f").onsubmit = (e) => {
    e.preventDefault();
    const texto = campo.value.trim();
    if (!texto) return;
    sugerencias.innerHTML = "";
    void preguntarYPintar(texto);
  };
})();
</script>

Lo único que hay que entender para adaptarlo

  • MARCA son los cuatro valores del diseño. Color, color del texto sobre ese color, título y saludo. Cambia eso y el chat ya es del cliente.
  • FUNCIONES es el puente con su sistema. Cada entrada tiene la descripcion (lo que lee el asistente para decidir cuándo usarla) y ejecutar (lo que corre de su lado). Si el sistema ya tiene una consulta de «facturas vencidas por vendedor», envuélvela ahí y el asistente la usará solo.
  • INSTRUCCIONES es de dónde sale el tono. Ahí se le dice que hable como en un chat, corto, con uno o dos emojis y usando negrita para los números. Sin eso, responde correcto pero soso. Y tienen que ser LARGAS —más de 4.000 letras— e idénticas entre llamadas, o el caché no entra y la factura se triplica.
  • El texto del asistente nunca se pinta como HTML tal cual. Se escapa primero y las marcas de formato se aplican después. Es lo que impide que un dato con un < rompa la página.
  • Con pk_ hay que autorizar el dominio en el panel, o la llamada devuelve origen_no_autorizado.
  • El historial vive en el navegador. Si se recarga la página se pierde: es una decisión, no un olvido — nosotros no guardamos conversaciones.

Si tu sistema tiene servidor propio

Entonces usa la llave secreta y pon el bucle en el servidor: así la llave no viaja al navegador y de paso puedes filtrar qué preguntas se permiten. El bucle es idéntico.

// Node 18+ / Cloudflare Workers - sin dependencias
const URL = "https://yapanel.yadominios.com/agente/v1/chat";

const FUNCIONES = {
  cierre_del_dia: {
    descripcion: "Devuelve el cierre de caja de una sucursal en una fecha (AAAA-MM-DD).",
    esquema: { type: "object",
      properties: { sucursal: { type: "string" }, fecha: { type: "string" } },
      required: ["sucursal", "fecha"] },
    ejecutar: async ({ sucursal, fecha }) => {
      // TU consulta real, contra TU base:
      return await db.query(
        "select sum(total) total_usd, count(*) notas from ventas where sucursal=$1 and fecha=$2",
        [sucursal, fecha],
      );
    },
  },
};

export async function preguntar(pregunta, historial = []) {
  const herramientas = Object.entries(FUNCIONES).map(([nombre, f]) => ({
    nombre, descripcion: f.descripcion, esquema: f.esquema,
  }));
  historial.push({ rol: "usuario", contenido: [{ tipo: "texto", texto: pregunta }] });

  for (let vuelta = 0; vuelta < 5; vuelta++) {
    const r = await fetch(URL, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "X-Sitio": process.env.YAD_SITIO,
        "X-Clave": process.env.YAD_CLAVE,   // la sk_, en variable de entorno
      },
      body: JSON.stringify({ modelo: "normal", sistema: INSTRUCCIONES, herramientas, mensajes: historial }),
    });
    const d = await r.json();
    if (!d.ok) throw new Error(d.error + ": " + d.mensaje);

    historial.push({ rol: "asistente", contenido: d.contenido });
    if (d.parar_por !== "herramienta") {
      return { texto: d.contenido.filter(b => b.tipo === "texto").map(b => b.texto).join("\n"),
               historial, creditos: d.creditos_restantes };
    }

    const resultados = [];
    for (const b of d.contenido) {
      if (b.tipo !== "herramienta") continue;
      try {
        resultados.push({ tipo: "resultado", id: b.id,
          contenido: JSON.stringify(await FUNCIONES[b.nombre].ejecutar(b.entrada)) });
      } catch (e) {
        resultados.push({ tipo: "resultado", id: b.id, contenido: String(e), error: true });
      }
    }
    historial.push({ rol: "usuario", contenido: resultados });
  }
  throw new Error("demasiadas vueltas");
}

Errores que se cometen la primera vez

SíntomaQué pasó
Se queda esperando para siempreNo se programó el bucle: solo se mira parar_por: "fin". Falta ejecutar la función cuando dice "herramienta".
403 origen_no_autorizadoLlave pk_ sin el dominio autorizado en el panel.
El asistente no usa una función que existeLa descripcion es muy vaga. Escribe qué devuelve y cuándo conviene usarla.
peticion_invalida hablando de un idEl resultado no lleva el mismo id que la herramienta que lo pidió.
Los créditos bajan más rápido de lo esperadoLas instrucciones o las herramientas cambian entre llamadas (una fecha con hora, por ejemplo) y el caché no funciona. Que sean idénticas.

Si eres una IA integrando esta pasarela: léelo antes de escribir código

  • El bucle es obligatorio. Después de cada respuesta, mira parar_por. Si dice "herramienta", ejecuta la función pedida, agrega DOS mensajes al historial (el bloque herramienta tal cual lo recibiste con rol asistente, y un bloque resultado con rol usuario) y vuelve a llamar. Repite hasta que diga "fin". Pon un tope de vueltas (5 está bien) para no quedarte en bucle.
  • El id tiene que coincidir exactamente. El resultado lleva el mismo id que la herramienta que lo pidió. Sin eso, el asistente no sabe a qué petición corresponde.
  • El historial lo guardas tú. La pasarela no tiene memoria: manda la conversación entera en cada llamada.
  • No metas la clave secreta en el navegador. Si tu aplicación es estática, usa la pública y autoriza tus dominios en el panel.
  • El contenido del resultado es texto. Si tu función devuelve un objeto, conviértelo a JSON en una cadena.
  • Si tu función falla, devuelve el resultado igual con "error": true y el mensaje. El asistente lo entiende y lo explica, en vez de quedarse colgado.
  • Mantén idénticas las instrucciones y las herramientas entre llamadas de una misma conversación: así el caché funciona y la factura baja mucho.

Preguntas frecuentes

¿Sirve si mi página está alojada en otro lado?

Sí. La pasarela no necesita tu código ni tu repositorio: lo que autoriza tu llave es tu dominio. Si nos pagas el servicio, en tu panel te sale el botón de activarla igual que a cualquiera.

¿Los créditos se vencen?

Sí: a los 90 días SIN usarse. El reloj se reinicia con cada consulta, así que mientras uses el asistente no pierdes nada. Te avisamos por correo 15, 7 y 1 día antes de que caduquen.

¿Ustedes ven los datos de mi negocio?

Pasan por la pasarela para poder responder, pero no se guardan. En nuestro registro solo queda el sitio, la fecha, el modelo, la cantidad de tokens y los créditos cobrados. Nada del contenido.

¿La pasarela se conecta a mi base de datos?

No, nunca. Cuando el asistente necesita datos te los pide a ti; la consulta la ejecuta tu sistema con tus permisos y nos devuelve solo el resultado.

¿Qué pasa si me quedo sin créditos?

La llamada devuelve 402 sin_creditos y ni siquiera se contacta al proveedor, así que no se gasta nada. Recargas desde el panel y sigue funcionando.

¿Puedo poner la clave dentro de mi página web?

La pública sí (pk_): solo funciona desde los dominios que autorices y tiene tope por minuto. La secreta (sk_) nunca: esa es solo para tu servidor.

¿Me cobran la vuelta en la que solo pide ejecutar una función?

Sí. Esa vuelta también consumió el modelo completo. Lo que no se cobra es cuando el proveedor falla o la clave es inválida.

¿Puedo cambiar de modelo sin tocar mi código?

Sí, y por eso mandas un alias (rapido/normal/maximo) y no el nombre técnico. Si mejoramos el modelo por detrás, tu integración no se entera.

Sigue leyendo

¿Listo para tu dominio?

Búscalo ahora y tenlo activo en minutos.

Buscar mi dominio