Saltar al contenido principal
Con tools, el modelo de @botmesh/ai puede llamar a funciones de tu bot (buscar en el catálogo, consultar un pedido, crear una reserva) mediante el tool / function calling nativo de cada proveedor. Declaras cada herramienta una vez; el plugin la traduce para Anthropic, OpenAI, Gemini u Ollama, ejecuta un bucle de varios pasos y le devuelve los resultados al modelo hasta que escribe la respuesta final.

Declarar herramientas

Cada entrada de tools es un AITool; la clave es el nombre que ve el modelo. defineTool<TArgs>(tool) es un helper identidad: solo infiere el tipo de los argumentos del handler para que el editor autocomplete args.

Esquemas

  • JSON Schema: se envía tal cual al modelo y se valida con un subconjunto propio (type, required, properties, enum, const, items, additionalProperties: false, minimum/maximum y sus formas exclusivas, minLength/maxLength, minItems/maxItems).
  • Validadores con safeParse (zod y similares): los argumentos se validan con safeParse() y el handler recibe el data ya parseado. El esquema para el modelo sale de toJSONSchema() si el validador lo tiene (zod v4); si no, pasa jsonSchema a mano.
Los argumentos inválidos nunca llegan al handler ni al usuario: se validan antes de cualquier confirmación, así que nadie aprueba una llamada que no se puede ejecutar. El modelo recibe un resultado de error (que dice que no se le preguntó al usuario) y puede reintentar. Usa enum para conjuntos cerrados como las claves de producto: en una prueba real el modelo pasó "torta de chocolate" en vez de la clave torta_chocolate, y solo el enum lo frena antes de preguntarle al usuario.

El contexto del handler

El segundo argumento de handler es un AIToolContext:

El bucle de herramientas

Cada turno ejecuta un bucle: llama al modelo con las definiciones; mientras pida herramientas, valida los argumentos, pide confirmación si hace falta, ejecuta cada handler con su timeout y le devuelve los resultados.
  • maxSteps (por defecto 5) limita las llamadas al modelo por turno. Las herramientas pedidas en el último paso permitido no se ejecutan (el modelo nunca vería su resultado): el turno responde maxStepsMessage y emite loop.max_steps.
  • Varias llamadas en el mismo paso se ejecutan una tras otra, en orden.
  • Los errores van al modelo, nunca al usuario: un handler que lanza, argumentos inválidos, una herramienta desconocida, un timeout o una confirmación rechazada se convierten en resultados fallidos, para que el modelo se recupere o lo explique.
  • Timeouts: toolTimeoutMs (por defecto 15000, 0 lo desactiva) se aplica a todas las herramientas salvo a las que fijan su propio timeoutMs. Al vencer se aborta el signal del handler. Cada llamada al modelo mantiene el timeoutMs del plugin.
  • El historial guardado sigue siendo texto plano usuario/asistente: los turnos intermedios de herramientas no se persisten.
Herramientas y streamingUn turno con herramientas nunca se transmite en vivo: con tools configuradas, el autoresponder ignora la opción stream y responde en un solo mensaje, y streamReplyInChat() no ejecuta herramientas.

Confirmación humana

Una herramienta con confirm: true solo se ejecuta cuando una persona la aprueba. En el autoresponder el plugin le pregunta al usuario. La pregunta (confirmMessage, o una legible por defecto, ver más abajo) se hace dentro de una conversación del runtime, así que el handler termina y libera la cola FIFO del chat: la respuesta del usuario llega a la conversación y el bucle continúa. Nunca bloquea el chat. La respuesta se clasifica con classifyConfirmation():
  • Un sí claro (sí, ok, dale, sí, por favor…) ejecuta la herramienta.
  • Un no claro (no, no, gracias, cancelar…) la rechaza y se le dice al modelo que no reintente.
  • Cualquier otra cosa, incluido un sí que pide cambios (sí pero cambia a 5 unidades), no ejecuta la herramienta ni se descarta: el modelo recibe { "executed": false, "reason": "user_replied", "userReply": "…" } para volver a llamarla con los argumentos corregidos (el usuario confirma de nuevo) o responder la pregunta.
Las respuestas se comparan sin mayúsculas, tildes ni puntuación, y se ignoran las cortesías alrededor del sí/no. Las palabras de “sí” por defecto (DEFAULT_YES) son si, s, yes, y, ok, okay, confirmo, confirmar, dale, claro, de acuerdo, acepto, 1, vale, listo, perfecto, adelante, hazlo, correcto, esta bien, sure, yeah y yep; las de “no” (DEFAULT_NO) son no, n, nop, nope, nel, cancelar, cancela, cancelo, cancel, negativo, mejor no, para nada, tampoco, no gracias, 2 y 0. Cámbialas con toolConfirm.yes / toolConfirm.no; las dos listas están congeladas, así que extiende una copia (yes: [...DEFAULT_YES, 'arre']).
  • Sin doble confirmación: cuando el turno tiene confirmador, el prompt de sistema lleva una nota localizada (ai.tools.confirmNote) con las herramientas confirm: true, que le pide al modelo no preguntar “¿te lo registro?” sino llamar a la herramienta en cuanto el usuario exprese la intención. toolConfirm.systemNote la cambia por tu texto o la apaga (false).
  • toolConfirm.timeout (por defecto '2m') limita la espera. Un timeout o /reset cancela el turno en silencio; /reset también lo cancela después de la aprobación, así que el turno viejo no responde ni escribe en el historial recién borrado.
  • Los turnos de un mismo chat se serializan: un mensaje nuevo espera a que termine un turno desacoplado. Una confirmación nunca reemplaza una conversación que el usuario haya abierto entretanto; el turno se cancela y no responde encima de ese flujo.

Botones Sí/No

En los canales que muestran botones nativos (capabilities.buttons: teclados inline de Telegram, botones de respuesta de WhatsApp Cloud API, Discord, quick replies de Messenger e Instagram…) la pregunta sale como mensaje de botones con etiquetas localizadas (Sí / No, Yes / No en inglés; claves ai.tools.button.yes / ai.tools.button.no) y el mismo resumen, sin la coletilla de respuesta escrita (ai.tools.confirmButtons: Voy a: {tool}{args}\n¿Confirmas?).
  • Pulsar un botón resuelve la confirmación igual que escribir sí o no, y la interacción se reconoce (answerCallbackQuery en Telegram, el ack de 3 segundos de Discord). Las respuestas escritas siguen funcionando junto a los botones, también una respuesta con cambios, que vuelve al modelo.
  • Ya respondida (o caducada / cancelada), los botones se quitan editando el mensaje a texto con la elección (→ Sí) donde el provider puede editar y pinta botones nativos (Telegram, Discord).
  • Una pulsación sobre una confirmación que ya no está pendiente (respondida, sustituida por otra más nueva, caducada, /reset) se reconoce y se ignora antes de enrutar nada: cada confirmación tiene sus propios ids de botón, así que una pulsación vieja nunca responde una confirmación más nueva ni una pregunta posterior de una conversación.
  • Canales que pintan botones nativos pero no editan mensajes (WhatsApp Cloud API, Messenger, Instagram, Gupshup, TikTok): los botones se quedan en pantalla. Ahí, una pulsación sobre una confirmación que ya no está pendiente recibe una respuesta corta localizada (ai.tools.confirmExpired: “Esa confirmación ya no está activa.” / “That confirmation is no longer active.”); toolConfirm.expiredNotice: false la descarta en silencio. Un chat en handoff con un humano nunca recibe este aviso (la pulsación solo se reconoce). Opcionalmente, toolConfirm.answeredNotice: true responde también ai.tools.confirmAnswered (“Respuesta recibida: Sí.” / “Answer received: Yes.”) cuando una pulsación responde; va apagado por defecto porque esos canales ya muestran la elección como mensaje del usuario. Ninguno de los dos avisos sale donde los botones se quitan editando ni donde la confirmación es un menú de texto numerado (Baileys, Evolution, WPPConnect, Twilio, email, GoHighLevel): ahí una respuesta posterior es solo texto.
  • Los canales sin botones nativos (WhatsApp vía Baileys, Evolution, WPPConnect, Twilio, SMS, email, GoHighLevel…) mantienen la pregunta escrita con la coletilla (sí/no); ahí una respuesta posterior es texto normal y no hay aviso de confirmación caducada.
  • toolConfirm.buttons: false pide siempre la respuesta escrita. El confirmMessage de una herramienta se usa tal cual en los dos modos. Un bot.locale() que cambia ai.tools.confirm se reutiliza para los botones si no traduces también ai.tools.confirmButtons.
El texto por defecto nunca muestra la descripción de la herramienta (escrita para el modelo) ni JSON crudo. Es el mensaje localizado ai.tools.confirm con el title de la herramienta y una línea corta por argumento; un único argumento lista muestra una línea por elemento, y los elementos con cantidad se leen como cantidad × producto:
Es el texto en español, el que se usa cuando ctx.lang es es (por ejemplo con lang: 'es' en el bot). Por defecto sale en inglés (About to: Create order … Do you confirm? (yes/no)), como todos los textos internos (ver i18n). Un chat en un idioma sin textos internos (por ejemplo fr) recibe el idioma por defecto del bot si es es o en, y si no, inglés. Cámbialo por idioma con bot.locale('es', { ai: { tools: { confirm: '¿Ejecuto {tool}?{args}' } } }) ({tool} es el título y {args} un salto de línea más las líneas • , o vacío), o usa confirmMessage en la herramienta.

Valores legibles y totales

Claves de catálogo como pan_de_bono le sirven al modelo, no al usuario. Dos opciones de la herramienta mejoran la pregunta por defecto sin escribirla tú:
  • labels traduce valores de argumentos a etiquetas humanas, por clave de argumento (también dentro de los ítems de una lista). Los valores sin etiqueta se muestran tal cual; el modelo y el handler siguen recibiendo las claves crudas.
  • confirmLines(args) devuelve las líneas que se muestran en lugar de los argumentos, dentro del mismo envoltorio localizado. Úsala para mostrar lo que calculó tu código (precios, total), así el usuario nunca confirma un número que se inventó el modelo. confirmMessage sigue mandando sobre ambas.
Si armas tu propio texto, argLines(args, ctx, labels) y summarizeValue(valor, ctx, depth, labels) están exportadas. Dentro de tu propia conversación pasa confirmWith(c). Tampoco bloquea la cola y, en un canal con botones nativos, pregunta con los mismos botones Sí/No que el autoresponder (misma semántica: las respuestas escritas siguen funcionando, la pulsación se reconoce, los botones se quitan editando donde se puede y una pulsación posterior se reconoce y se ignora en vez de responder tu siguiente c.ask()). En los demás canales, o con buttons: false, pregunta un sí/no escrito con c.ask(). Los botones van al chat del ctx que pasas a replyInChat (o de la opción ctx).
confirmWith(c, { yes, no, timeout, buttons, ctx, answeredNotice }) clasifica la respuesta igual que el autoresponder: 'approved', 'denied' o { reply }. Un error de control de la conversación (timeout, cancelación, conversación reemplazada) sale del bucle como siempre, para que lo gestione el runtime. Sin confirmador (por ejemplo un ctx.tools.ai.ask() suelto), las herramientas confirm: true se deniegan y se le dice al modelo que no había confirmación humana disponible. También puedes escribir tu propio ToolConfirmer: una función async que recibe { tool, callId, args, description, title, prompt, previous? } y resuelve 'approved', 'denied', { reply: string } (la respuesta libre del usuario, que se le pasa al modelo) o 'cancelled'. 'cancelled' aborta el turno entero: el bucle lanza AIToolCancelledError (con el nombre en tool).

Efectos secundarios repetidos

A veces el modelo vuelve a llamar por su cuenta a una herramienta con efectos (por ejemplo para “corregir” el total de un pedido después de que el usuario dijo “no, nada más”). El cliente recuerda por chat las ejecuciones correctas de las herramientas confirm: true y dedupe (en memoria, 30 minutos; /reset y ctx.tools.ai.clearToolHistory(chatKey) las olvidan) y las usa de tres formas:
  • Aviso en la confirmación: si una herramienta confirm: true ya se ejecutó en el chat con los mismos argumentos, la pregunta empieza con una línea localizada (ai.tools.confirmRepeat): Ojo: ya hice «Crear pedido» hace un momento (resultado: …). Si confirmas, se hará otra vez. El confirmador la recibe también como previous. Una llamada con argumentos distintos (cambiar 3 → 2 unidades) es una acción nueva: sin aviso ni previous. El resumen del resultado omite los campos demasiado anidados para leerse (listas de objetos).
  • Prompt de sistema: los turnos siguientes listan las acciones ya hechas (ai.tools.doneNote) y le piden al modelo no repetirlas salvo que el usuario lo pida explícitamente.
  • dedupe (opcional por herramienta): una llamada con los mismos argumentos validados que una ejecución correcta dentro de la ventana no se ejecuta ni se confirma; el modelo recibe { "executed": false, "reason": "duplicate", "previous": { "result", "secondsAgo" } }. dedupe: 'args' usa una ventana de 10 minutos; dedupe: { windowMs: 60_000 } fija la tuya. También sirve para herramientas sin confirm.
Una ejecución cuyo handler devuelve un objeto con error no vacío (por ejemplo { error: 'No existe el pedido P-123' }) no hizo nada, así que no se recuerda: ni aviso de repetición, ni línea de “ya ejecutadas”, ni bloqueo de dedupe. toolConfirm.systemNote: false también apaga la lista del prompt de sistema.

Buenas prácticas para herramientas con efectos

Estas reglas salen de pruebas en vivo con modelos reales en WhatsApp. Sin ellas, el modelo calcula mal el total de un pedido en texto libre, le muestra claves de catálogo al cliente o dice que “reemplazó” un pedido sin tener ninguna herramienta para hacerlo.
  1. Nunca dejes que el modelo calcule dinero. Precios, subtotales y totales se calculan en tu código: ofrece una herramienta de cotización (sin confirm, sin efectos) para el “¿cuánto cuesta…?”, vuelve a calcular el total en el handler que guarda el pedido y muéstralo en la confirmación con confirmLines (o confirmMessage). Devuelve el total calculado en el resultado para que el modelo lo repita en vez de calcularlo.
  2. Valida los valores de catálogo con enum. Claves de producto, tamaños, sedes…: los argumentos se validan antes de la confirmación, así que un producto inventado vuelve al modelo como error y el usuario nunca confirma un pedido imposible. Acompaña las claves con labels (o nombres en confirmLines) para que el usuario vea “Pan de bono” y no pan_de_bono.
  3. Cada herramienta de crear trae las de cambiar, cancelar y listar. Si el modelo solo puede crear, “corrige” un pedido creando otro o dice que cambió algo que no podía cambiar. Una herramienta de listado (mis_pedidos) le da los ids para cambiar o cancelar.
  4. Usa dedupe en las de crear (dedupe: 'args'), para que un pedido idéntico no se registre dos veces seguidas cuando el modelo vuelve a llamar la herramienta por su cuenta.
  5. Devuelve un { error } explícito para ids desconocidos ({ error: 'No existe un pedido activo P-123. Consulta mis_pedidos.' }), nunca un ok o null silencioso. El modelo lo lee y se lo dice al usuario; la ejecución no se recuerda como hecha.
  6. Mantén confirm: true en todo lo que tenga efectos (crear, cambiar, cancelar, pagar, reservar). Las de solo lectura (menú, cotizar, listar) no lo necesitan.
  7. Apóyate en la nota de sistema, no la pelees. Con un confirmador, el prompt de sistema le dice al modelo qué herramientas confirma el sistema, que no pregunte “¿te lo registro?” él mismo, que no mencione el proceso de confirmación y que nunca diga que hizo, cambió, reemplazó o canceló algo que ningún resultado de herramienta confirmó (y que lo diga con claridad cuando no tiene herramienta para lo que le piden). Déjala activa (toolConfirm.systemNote) salvo que la reemplaces por un texto equivalente.
Una panadería completa con todo lo anterior:
La plantilla ai-full de create-botmesh trae las mismas herramientas, con los pedidos guardados en ctx.user. Hay una versión completa y ejecutable (en inglés) en el ejemplo del asistente de panadería (examples/bakery-ai). Corre el mismo bot en WhatsApp con Baileys (QR) o con Twilio (webhook + @botmesh/server) e incluye configuración tipada por variables de entorno y tests con MockAIProvider.

Eventos de auditoría

Cada paso del bucle emite un AIToolEvent, tanto a onToolEvent como en el bot como 'ai.tool':
Cada evento lleva at (ms epoch), step (empieza en 1) y, cuando aplica, tool, callId, chatKey, args, durationMs y error.
args puede contener datos personalesLos eventos incluyen los argumentos que generó el modelo. Trátalos como datos personales en tu sistema de auditoría. El log de depuración del plugin solo registra metadatos (herramienta, tipo, paso, duración).

Proveedores

Las llamadas en streaming (stream(), streamReplyInChat()) nunca envían herramientas.

Usar herramientas desde tu código

Con tools configuradas, ctx.tools.ai.ask() y ctx.tools.ai.replyInChat() ejecutan el bucle solos. Ambos aceptan { ctx, confirm, signal, chatKey }. Para control total, runTools() devuelve el resultado del bucle:
runToolLoop() es el mismo bucle sin AIClient: le pasas las herramientas, una función generate y los mensajes.

Testing

MockAIProvider puede simular llamadas a herramientas: devuelve { toolCalls } desde reply para que el “modelo” llame a una herramienta, y texto plano para terminar. Construye un AIClient con tus herramientas y comprueba el resultado:
mock.calls guarda cada petición que envió el cliente (con sus messages y tools), así que también puedes comprobar qué vio el modelo.

Siguientes pasos