En marzo escribí sobre por qué pusimos un agente de IA en WhatsApp para Lueira: la apuesta de producto de que una escuela de deportes de montaña no debería perder una reserva solo porque son las once de la noche de un martes y no hay nadie en el mostrador. Aquel artículo trataba sobre el porqué: la investigación de clientes, la elección de canal, la cuestión de la confianza. Este trata sobre el cómo, y es la parte que de verdad me quitó el sueño: conseguir que un modelo de lenguaje compruebe disponibilidad real, cree una reserva real y envíe un enlace de pago real, sin dejarlo acercarse a la lógica atómica de instructor-más-material-más-franja horaria que construimos para el motor de reservas allá por junio de 2023. El agente tenía que convertirse en un cliente nuevo de ese motor, no en una nueva fuente de verdad.
1. Dar al modelo un conjunto pequeño de herramientas, no vía libre
La primera decisión, y la que condiciona todo lo que viene después, es negarse a dejar que el modelo genere acciones en texto libre y luego parsearlas. Es tentador: el modelo devuelve algo como «reserva el martes a las 10 con Marc para dos personas» y escribes una expresión regular para convertir eso en una escritura en base de datos. He visto ese patrón en suficientes demos como para saber exactamente cómo falla en producción: fechas ambiguas, nombres de instructores inventados, ids que no existen, formatos que se desvían cada vez que tocas el prompt.
Lo que hacemos en su lugar es definir un conjunto pequeño y explícito de funciones que el modelo puede invocar —check_availability, create_booking, send_payment_link— cada una con un esquema JSON estricto. El único trabajo del modelo es entender qué quiere el cliente en lenguaje natural y traducirlo en una llamada bien tipada a una de estas funciones. No escribe SQL, no inventa un id de franja, y no decide por su cuenta que una reserva está confirmada. Una versión simplificada del esquema de create_booking tiene este aspecto:
{
"name": "create_booking",
"description": "Crea una reserva para una franja, instructor y material concretos. Devuelve un id de reserva y un enlace de pago, o un error de conflicto si la franja ya no está disponible.",
"parameters": {
"type": "object",
"properties": {
"school_id": { "type": "string" },
"activity_id": { "type": "string" },
"slot_id": {
"type": "string",
"description": "Identificador opaco devuelto por check_availability. El modelo no puede inventar este valor."
},
"customer": {
"type": "object",
"properties": {
"name": { "type": "string" },
"phone": { "type": "string" },
"email": { "type": "string" }
},
"required": ["name", "phone"]
},
"party_size": { "type": "integer", "minimum": 1 }
},
"required": ["school_id", "activity_id", "slot_id", "customer", "party_size"]
}
}
Fíjate en el campo slot_id. Es un identificador opaco que solo existe porque check_availability lo devolvió momentos antes: el modelo no puede construir uno de la nada, y no puede convertir en silencio «las 10 del martes» en una franja sin que haya de por medio una consulta de disponibilidad real. Esa única decisión de diseño elimina toda una categoría de reservas alucinadas antes incluso de llegar a la validación.
2. El agente no se salta la cola
Tener una llamada a herramienta tipada no significa que confiemos en ella. El modelo puede estar extremadamente seguro y aun así equivocarse: sobre qué instructor está realmente libre, sobre si el material sigue en stock, sobre si otra persona reservó la misma franja hace cuatro segundos en otro hilo de WhatsApp distinto. Así que cada llamada a herramienta que el modelo propone pasa exactamente por la misma lógica de validación y transacción que pasa una reserva normal hecha desde la web: la misma comprobación atómica de instructor, material y franja juntos que describimos al diseñar el motor de reservas. El LLM propone; el motor dispone.
En código, esa frontera se ve más o menos así:
func handleCreateBooking(ctx context.Context, call ToolCall) (ToolResult, error) {
var args CreateBookingArgs
if err := json.Unmarshal(call.Arguments, &args); err != nil {
return ToolResult{}, fmt.Errorf("invalid arguments from model: %w", err)
}
// El trabajo del modelo termina aquí. A partir de este punto, la misma
// transacción atómica de comprobar-y-reservar que usa el flujo de
// reserva web es lo único que puede tocar el estado de las reservas.
booking, err := bookingEngine.CreateBooking(ctx, CreateBookingInput{
SlotID: args.SlotID,
Customer: args.Customer,
PartySize: args.PartySize,
})
if errors.Is(err, ErrSlotNoLongerAvailable) {
return ToolResult{
Content: "Esa franja ya no está disponible.",
Retryable: true,
}, nil
}
if err != nil {
return ToolResult{}, err
}
return ToolResult{
BookingID: booking.ID,
PaymentLink: booking.PaymentLink,
}, nil
}
El comentario de ese handler resume toda la filosofía del proyecto: a partir del momento en que el modelo entrega una llamada estructurada, queda fuera del bucle. Nada de lo que dice es lo bastante fiable como para tocar el estado de las reservas directamente. Si esto parece mucha ceremonia para lo que parece un chatbot, lo es, porque el chatbot no es lo que importa. La reserva sí.
3. Cuando la franja desaparece a mitad de conversación
Como revalidamos todo, también tenemos que gestionar el caso que intentábamos evitar: la franja que el modelo sugirió hace cinco segundos ha desaparecido para cuando create_booking se ejecuta de verdad. Alguien la ha cogido desde la app, o otra conversación de WhatsApp se ha adelantado a esta. En una integración ingenua esto vuelve como un error, y un error en una interfaz de chat es un callejón sin salida: el cliente solo ve «algo ha ido mal» y tiene que empezar de nuevo, normalmente frustrándose y llamando directamente a la escuela, lo cual echa por tierra todo el sentido de haber construido esto.
Así que el resultado de la herramienta create_booking no es solo éxito o fracaso: lleva suficiente estructura para que el agente se recupere con elegancia. Cuando el motor de reservas devuelve un conflicto, la respuesta de la herramienta le dice al modelo que la franja ya no está disponible y le devuelve una nueva llamada a check_availability con los mismos parámetros. El siguiente mensaje del modelo al cliente se genera entonces a partir de datos reales y actuales —«esa franja de las 10 con Marc se acaba de ocupar, pero tiene libres las 11:30 y las 15:00 hoy»— en lugar de una disculpa genérica. El cliente nunca ve el reintento; solo ve un agente que parece saber lo que está pasando en tiempo real. Ese comportamiento no es el modelo siendo listo. Es el resultado directo de darle un fallo estructurado sobre el que puede actuar, en vez de una traza de error que no puede.
4. Conversaciones que sobreviven a un hueco de tres horas
Lo otro que rompe a los agentes de calidad demo en producción es asumir que una conversación es un único ciclo de petición-respuesta, como una sesión web. WhatsApp no funciona así. Un cliente puede preguntar por disponibilidad a las 9 de la mañana, distraerse, y responder al mediodía desde otro teléfono tras cambiar de dispositivo. Desde el punto de vista del modelo, eso tiene que parecer una conversación continua, aunque nada en el transporte garantice esa continuidad.
En la práctica eso significa que el estado que el agente necesita —qué ha preguntado ya el cliente, qué franja se discutió por última vez, si un enlace de pago sigue pendiente— no puede vivir en memoria atada a un único proceso que gestiona un mensaje. Tiene que reconstruirse a partir de un historial de conversación persistente, indexado por la identidad de WhatsApp del cliente, no por ninguna sesión concreta. Cada vez que llega un mensaje, reconstruimos suficiente contexto para que el modelo retome exactamente donde se quedaron las cosas, incluyendo cuál es de verdad el estado del motor de reservas ahora mismo, no el que tenía hace tres horas. Ese último detalle importa más de lo que parece: si le diéramos al modelo una fotografía de disponibilidad de hace tres horas, volveríamos exactamente a fiarnos de información obsoleta, justo lo que decidimos no hacer. La conversación es recuperable; la disponibilidad que hay dentro de ella no es algo que cacheemos y reutilicemos: se vuelve a comprobar, cada vez, contra el motor en vivo.
Nada de esto hace más listo al agente. Si acaso, todo el diseño es un ejercicio para asegurarse de que no necesite serlo. La única contribución del modelo es convertir «puedo reservar una clase con Marc el próximo martes por la mañana» en una pregunta estructurada que el motor de reservas ya sabe responder de forma segura. Todo lo que de verdad mueve dinero o retiene una plaza sigue pasando por las mismas garantías que construimos para el flujo web. Ese era el objetivo desde el principio: el agente es una nueva forma de hablar con Lueira, no una nueva forma de saltárselo.
