YaDominiosYaDominios

Agent Gateway: give your system an AI assistant, with no keys and no servers

Updated: 2026-08-04 · YaDominios

The Agent Gateway sits between your system and an AI assistant. Your app posts the conversation to yapanel.yadominios.com/agente/v1/chat with your site key, and we talk to the provider. The key point: it supports multi-turn tool use — the assistant can ask you to run one of your own functions (say `cierre_del_dia`) against YOUR database; you run it and call again with the result, as many rounds as needed. We never touch your database and never store the conversations. Billing is per use in credits: 1 credit = one thousandth of a dollar.

Install it in 5 steps

If you are in a hurry, this is the whole thing. The detail of each part is further down.

  1. Turn the gateway on. Dashboard → Agent Gateway in the left menu → Activate.
  2. Get your key. sk_ if your system has its own server; pk_ if it is a web app with no backend. It is shown once.
  3. If you use pk_, authorize your domain on that same screen. Without it the key works from nowhere.
  4. Describe your functions — the queries your system already knows how to run — in the herramientas field.
  5. Write the loop: you call, and if the answer comes back with parar_por: "herramienta" you run that function and call again with the result. Repeat until it says "fin".

Step 5 is the only one with any trick to it, and it is solved end to end in the floating-bubble example below: copy it and it works.

How it works, at a glance

YOUR SYSTEM (your server or your web app) THE GATEWAY (YaDominios) YOUR DATABASE (we never touch it) 1 "how much did I sell yesterday?" 2 parar_por: "herramienta" run cierre_del_dia(caracas, yesterday) 3 YOUR code runs the query { total_usd: 4312.55, notas: 27 } 4 you send back the result 5 parar_por: "fin" - the answer
Steps 2 to 4 can repeat several times before step 5. Your database never leaves your side.

Where the key comes from

YaDominios Overview My domains YaDominios Cloud My email Agent Gateway 1 My sites My invoices Agent Gateway my-site 5,000 credits $5.00 worth YOUR KEYS New public New secret 2 Save it now: this key is never shown again. sk_4a5e1c9d8b7f4e2a9c3d... Copy 3 AUTHORIZED DOMAINS my-store.com Authorize 4
1 Open it from the menu. 2 Create the key. 3 Copy it (shown once). 4 Public key only: authorize your domain.

What it is for

Your system already knows how to query its own database. What it does not know is how to understand a question in plain language and decide which query is needed. That is what the gateway adds.

What we do NOT do — the important part

  • We never touch your database. When the assistant needs data it asks you. The query runs on your server, with your permissions. We do not know what is on the other side.
  • We do not store conversations. Questions and business results pass through and are gone. Our log keeps only site, date, model, token counts and credits.
  • No memory. You keep the full history and send it whole on every call.

The real flow: multi-turn tool use

This is not a single-pass chat:

  1. Your system sends the user's question.
  2. The gateway replies: “the assistant wants to run cierre_del_dia with these parameters” (parar_por: "herramienta").
  3. Your system runs that function against your database and calls again with the result.
  4. The gateway returns the final text (parar_por: "fin").

There can be several rounds. An integration that only looks for parar_por: "fin" will hang forever — step 2 is not optional.

What if my site is hosted somewhere else?

It works just the same. The gateway does not need your code or your repository: what your public key authorizes is your domain, and that one is with us. If you pay us for the service but your site lives with another provider, your dashboard shows it with the same Turn on the Agent Gateway button as anyone else.

In that case the name you send in X-Sitio is built from your domain with dots turned into dashes — my-store.com becomes my-store-com — and it is shown on the same screen where you create the key.

The two keys

KeyWhere it goesHow it is protected
pk_… publicCan live in the browser, for web apps with no server of their own.Works only from your site's authorized domains, plus a per-minute cap.
sk_… secretServer only. Never in a web page.No domain restriction. Shown once, at creation.

POST /agente/v1/chat

Headers: X-Sitio (your site's name in the dashboard) and X-Clave. Body fields are in Spanish because the contract is ours and versioned: modelo, sistema, herramientas, mensajes.

{
  "modelo": "normal",
  "sistema": "You are the assistant of a hardware store.",
  "herramientas": [{
    "nombre": "cierre_del_dia",
    "descripcion": "Returns a branch's daily cash close for a date.",
    "esquema": {"type":"object","properties":{"sucursal":{"type":"string"},"fecha":{"type":"string"}},"required":["sucursal","fecha"]}
  }],
  "mensajes": [{"rol":"usuario","contenido":[{"tipo":"texto","texto":"how much did I sell yesterday in Caracas?"}]}]
}

Block types

tipoWritten byFields
textoYou or the assistanttexto
herramientaThe assistant — echo it back verbatim next roundid, nombre, entrada
resultadoYou, with what your function returnedid (same as the tool's), contenido, optional error

GET /agente/v1/saldo

{ "ok": true, "creditos_restantes": 4971, "gastado_mes": 29, "sitio": "my-site" }

Credits expire after 90 days without use

The clock counts from the last time you used the assistant, not from when you topped up. Every question restarts it: as long as you use it, you never lose a credit. If the assistant goes three months without a single question, the balance expires and you top up again.

It never expires silently: we email you 15 days, 7 days and 1 day before, the date shows in your dashboard, and when it does expire the movement is recorded with its date and reason.

After expiry the assistant stays installed and your key stays the same — you only need to top up to use it again.

Errors, with stable codes

Every error carries "ok": false and an error field that never changes. Program against the code, not the message text.

HTTPerrorWhat happened
401clave_invalidaKey missing, revoked, or not for that site
402sin_creditosOut of credits (carries creditos_restantes: 0). The provider is not called.
403origen_no_autorizadoPublic key used from a domain you did not authorize
403sitio_apagadoThe site's gateway is off
429limite_de_usoPer-minute cap exceeded (carries reintentar_en, seconds)
400modelo_no_disponibleUnknown alias. Valid: rapido, normal, maximo
400peticion_invalidaMalformed body — the message names the exact block
502falla_del_proveedorThe AI provider failed. No credits are charged.

“auto”: let us pick

Besides the three, you can send "modelo": "auto" and we decide per round: the first one —where the assistant has to choose which of your functions to call— goes to the strong model, and the following ones —where it already has your data and only writes the answer— go to the fast one.

Measured in production on a question spanning four queries: 75 credits with a fixed model, 49 on auto. 35% less for the same answer.

Credits

1 credit = one thousandth of a dollar. A $10 top-up is 10,000 credits. Every call is charged, including the one that only asks you to run a function — that one consumed the whole model too. Nothing is charged when the provider fails or the key is invalid.

Limits and fine print (measured, not assumed)

WhatHow much
Requests per minute (pk_ key)20 by default, adjustable per site. It counts EVERY request, tool rounds included: one question can spend 5. If your system loops often, ask us to raise it.
Requests per minute (sk_ key)No cap. It lives on your server, not in someone else's browser.
Body sizeTested at 2 MB with no trouble. A 50 KB result is nothing; what costs is the tokens it takes.
Response lengthCapped at 8,192 tokens. You can ask for less with max_tokens, not more.
Time per callWe do not cut it off. A call that thinks and crosses data takes 2 to 40 seconds depending on the model; set your own timeout.
Instruction sizeNo cap of ours. The model's context window is the limit, and it is one million tokens.
Number of toolsNo cap of ours. Ten is comfortable; with many more the assistant struggles to choose.

The cache: when it really kicks in, and how much it saves

Instructions and tools being billed at 10% on re-reads comes with a condition that is not obvious: the marked block has to exceed about 1,024 tokens (roughly 4,000 characters). Below that the marker is ignored and you pay full price with nothing warning you.

Measured against the live gateway with a 4,500-character system prompt:

call 1: cache write=1836  cache read=0     → 35 credits
call 2: cache write=0     cache read=1836  →  4 credits
call 3: cache write=0     cache read=1836  →  4 credits

Same question, nearly nine times cheaper. The cache lasts about 5 minutes and every use resets that clock: as long as someone asks every few minutes it stays warm by itself. If your system goes quiet for half an hour, the next question pays the write again — it is not worth keeping it warm with fake calls, since every call is billed too.

The condition for it to work: instructions and tools must travel byte-identical on every call. A timestamp inside the instructions breaks the cache on every request and multiplies the bill.

The assistant can request SEVERAL functions at once

It does not always ask for one. In a real test, asked to "compare the profit of Caracas and Valencia on August 3rd", it requested four at once: sales and expenses for each branch. Your code must walk contenido, run EVERY herramienta block, and return all results together in a single message with role usuario. Sending them one at a time gives peticion_invalida.

When your function fails

Return the result anyway, with "error": true. The assistant reads it as a failed lookup and recovers on its own — tested: given "I could not find that customer", it asked to double-check the ID instead of inventing a purchase history.

The floating bubble: copy, paste, done

The most common way to add this to a system that already exists: a bubble in the corner. Nothing you already have changes.

How it must look — this is not a detail, it is the product

The business owner will use this chat every day. If it looks cheap, the assistant looks cheap no matter how good it is. The code below already solves all of this; if you write your own, meet the same bar:

  • It looks like WhatsApp, because that is what everyone knows. Bubbles with a tail on their side, the user's on the right in the brand colour, the assistant's on the left in grey, with a timestamp on each message.
  • No asterisks on screen. The assistant answers with formatting marks (**bold**, dashed lists). Render them as plain text and the customer sees **2 orders** and the chat looks broken. Convert them — the example ships a twelve-line converter, no libraries.
  • With emojis, one or two per answer. That does not come from the code but from the instructions you send: see the INSTRUCCIONES field in the example.
  • With the three dots while it thinks. A query takes one to five seconds; with no sign of life the user thinks it froze and taps again.
  • Big and comfortable: around 400 px wide and 620 tall on desktop, near full screen on mobile. A tiny chat feels like a toy.
  • With sample questions on open. Nobody knows what to ask a blank assistant. Three buttons with the typical questions solve the first minute.
  • In the customer's brand colours. Four values at the top of the example: change them and the chat is theirs.
My hardware store · Dashboard Invoices Inventory Reports (your system, exactly as it is today - untouched) Ask your business how much did I sell yesterday? Yesterday in Caracas you sold $4,312.55 across 27 tickets. running cierre_del_dia... Type your question... Go
The bubble sits on top of your system without touching anything you already have. The full code is below.

This is the whole file. Paste it before </body> and change what is marked <-- CAMBIA. The tool loop, the answer formatting and the design are already solved.

<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>

The only things you need to understand to adapt it

  • MARCA holds the four design values. Colour, text colour on that colour, title and greeting.
  • FUNCIONES is the bridge to their system. Each entry has a descripcion (what the assistant reads to decide when to use it) and ejecutar (what runs on their side).
  • INSTRUCCIONES is where the tone comes from. That is where you tell it to speak like a chat, short, with one or two emojis and bold for the numbers. Without it, answers are correct but dull. And they must be LONG —over 4,000 characters— and identical between calls, or the cache never hits and the bill triples.
  • The assistant's text is never rendered as raw HTML. It is escaped first and the formatting marks applied after.
  • With pk_ you must authorize the domain in the dashboard, or the call returns origen_no_autorizado.

If your system has its own server

Then use the secret key and put the loop on the server: the key never reaches the browser, and you can filter which questions are allowed. The loop is identical.

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

const FUNCIONES = {
  cierre_del_dia: {
    descripcion: "Returns the daily sales close for a branch on a date (YYYY-MM-DD).",
    esquema: { type: "object",
      properties: { sucursal: { type: "string" }, fecha: { type: "string" } },
      required: ["sucursal", "fecha"] },
    ejecutar: async ({ sucursal, fecha }) => {
      // YOUR real query, against YOUR database:
      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": "my-site",
        "X-Clave": process.env.YADOMINIOS_CLAVE,   // the sk_ key, from the environment
      },
      body: JSON.stringify({ modelo: "normal", herramientas, mensajes: historial }),
    });
    const d = await r.json();
    if (!d.ok) throw new Error(d.error + ": " + d.mensaje);

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

    historial.push({ rol: "asistente", contenido: d.contenido });
    const resultados = [];
    for (const b of d.contenido) {
      if (b.tipo !== "herramienta") continue;
      const salida = await FUNCIONES[b.nombre].ejecutar(b.entrada);
      resultados.push({ tipo: "resultado", id: b.id, contenido: JSON.stringify(salida) });
    }
    historial.push({ rol: "usuario", contenido: resultados });
  }
  throw new Error("too many rounds");
}

Mistakes everyone makes the first time

SymptomWhat happened
It hangs foreverThe loop was never written: the code only looks for parar_por: "fin". It has to run the function when it says "herramienta".
403 origen_no_autorizadoA pk_ key with no authorized domain in the dashboard.
The assistant ignores a function that existsThe descripcion is too vague. Write what it returns and when it should be used.
peticion_invalida mentioning an idThe resultado does not carry the same id as the herramienta that asked for it.
Credits drop faster than expectedInstructions or tools change between calls (a timestamp with the hour, say) and the cache never hits. Keep them byte-identical.

If you are an AI integrating this gateway: read this first

  • The loop is mandatory. After each response check parar_por. On "herramienta", run the requested function, append TWO messages (the herramienta block verbatim with role asistente, then a resultado block with role usuario) and call again. Repeat until "fin". Cap the rounds (5 is fine).
  • The id must match exactly between the tool request and your result.
  • You keep the history. The gateway is stateless — send the whole conversation every call.
  • Never put the secret key in a browser. Static apps use the public key plus authorized domains.
  • contenido is a string. Serialize objects to JSON.
  • If your function fails, still return the resultado with "error": true and the message — the assistant explains it instead of hanging.
  • Keep instructions and tools byte-identical across a conversation so the cache hits and the bill drops.

Frequently asked questions

Does it work if my site is hosted elsewhere?

Yes. The gateway needs neither your code nor your repository: what your key authorizes is your domain. If you pay us for the service, your dashboard shows the same activation button as everyone else.

Do credits expire?

Yes: after 90 days WITHOUT use. Every question restarts the clock, so as long as you use the assistant you lose nothing. We email you 15, 7 and 1 day before they expire.

Do you see my business data?

It passes through so the assistant can answer, but it is not stored. Our log keeps only the site, date, model, token counts and credits charged — nothing of the content.

Does the gateway connect to my database?

Never. When the assistant needs data it asks you; the query runs on your system with your permissions and you return only the result.

What happens when I run out of credits?

The call returns 402 sin_creditos and the provider is never contacted, so nothing is spent. Top up in the dashboard and it keeps working.

Can I put the key inside my web page?

The public one (pk_) yes: it only works from domains you authorize and has a per-minute cap. The secret one (sk_) never — that is server-only.

Am I charged for the round that only asks to run a function?

Yes. That round consumed the full model. What is never charged is a provider failure or an invalid key.

Keep reading

Ready for your domain?

Search for it now and have it live in minutes.

Search my domain