Quería que los miembros de un club de membresía online pudieran preguntarle al instructor dentro del propio curso, sin abrir otra pestaña ni contratar un SaaS aparte. De ahí salió el encargo: integrar IA en WordPress de forma que el asistente viviera dentro del tema, con la identidad del instructor y el contexto del curso.

El modelo lo pone Google Gemini. El resto lo puse yo. Y como casi siempre, el peso no estuvo en las features, sino en las decisiones: qué mandar al modelo, qué hacer cuando falla, dónde guardar el historial y cuánta personalidad darle.

Este artículo es eso: las decisiones que tomé montando el asistente, y la pieza de arquitectura que toca cada una.

El problema

El instructor no puede estar en directo en cada unidad ni en cada sesión. Un alumno tiene una duda a las once de la noche y no hay nadie al otro lado. Esa es la grieta que quería tapar.

La salida fácil habría sido enchufar un Intercom, un Crisp o cualquier chatbot de terceros. La descarté rápido. Implica sacar los datos de los alumnos fuera del sistema, depender de un servicio externo que puede cambiar de precio o de política, y pagar una cuota mensual por algo que solo necesito dentro de mi plataforma.

El asistente tenía que funcionar en dos sitios: las sesiones grupales y las unidades de curso, que en el tema son dos custom post types distintos. Misma lógica, dos contextos. Eso ya empujaba a construirlo dentro del tema en lugar de pegarle un widget externo por encima.

La arquitectura en una frase

En una frase: el navegador habla con un proxy PHP, y el proxy habla con la API de Gemini.

El navegador nunca toca la API directamente. Manda su mensaje a un endpoint propio del tema (api/chat.php), y ese PHP es quien arma la petición, le añade la clave y llama a Google. Así la clave jamás llega al frontend, y tengo un único sitio donde validar quién pregunta.

Son tres piezas. La primera es el propio proxy, que valida identidad: cada petición exige un nonce de WordPress y un usuario autenticado. Sin las dos cosas, el endpoint no responde. La segunda es una tabla MySQL propia donde guardo el historial de la conversación. La tercera es el system prompt, el texto que le da al modelo su papel de instructor; de ese hablo más abajo, porque ahí hubo decisión.

El asistente aparece en la interfaz con un avatar y la identidad del instructor, no como un bot anónimo. Y le puse voz: entrada y salida con la Web Speech API del navegador, que es gratis, dejándolo preparado para enchufar voces de pago si hace falta.

Las decisiones que no eran obvias

Mandar solo los últimos 20 mensajes

Gemini cobra por tokens de entrada, y una conversación crece sin parar. Si en cada pregunta le mando el historial completo, el coste y la latencia suben con cada turno sin que la respuesta mejore.

Así que solo le paso los últimos 20 mensajes como contexto. El historial completo sigue en la base de datos (guardo hasta 100 mensajes por usuario y post), pero al modelo solo viaja la ventana reciente. Para una duda sobre la unidad que estás viendo, 20 mensajes sobran. Encima puse un rate limit de una petición cada 2 segundos por usuario, con transients de WordPress, para que nadie martillee la API por error o a propósito.

Asumir que Gemini iba a fallar

Las APIs de modelos devuelven 503 y 429 más de lo que te gustaría: el servicio saturado, tu cuota al límite. Si trato eso como un error y le muestro al alumno un «algo salió mal», el asistente queda como poco fiable cuando el problema es pasajero.

Así que asumí el fallo como caso normal. Cada petición reintenta hasta 10 veces con backoff exponencial: espera 300 ms y, si vuelve a fallar, duplica la espera (0,6 s, 1,2 s…) hasta un tope de 8 s por intento. La mayoría de los 503 se resuelven en el segundo o tercer intento sin que el usuario note nada.

set_time_limit(120);
$maxAttempts = 10;
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
    $ch = curl_init($apiUrl);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER,     ['Content-Type: application/json']);
    curl_setopt($ch, CURLOPT_POST,           true);
    curl_setopt($ch, CURLOPT_POSTFIELDS,     json_encode($data));
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);

    $response  = curl_exec($ch);
    $curlError = curl_error($ch);
    $httpCode  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($curlError) break;

    $decoded = json_decode($response, true);

    if ($httpCode !== 503 && $httpCode !== 429) break;

    if ($attempt < $maxAttempts) {
        $wait = min(300000 * (2 ** ($attempt - 1)), 8000000); // 0.3s→0.6→1.2→...→8s
        usleep($wait);
    }
}

System prompt heredable

El system prompt es lo que convierte a Gemini en «el instructor de este curso». Lo dejé configurable desde el panel de WordPress, por sesión o por unidad. Pero rellenar un prompt a mano en cada unidad de un curso largo es tedioso y se presta a olvidos.

La regla: si una unidad no tiene system prompt propio, hereda el del curso padre. Así defines el tono y el contexto una vez a nivel de curso, y solo escribes un prompt específico donde de verdad cambia algo. Menos campos que mantener y menos formas de que algo quede vacío por descuido.

Texto plano forzado

Gemini, por defecto, responde con markdown: asteriscos para negritas, almohadillas para títulos, guiones para listas. En un chat dentro del curso eso se renderiza como ruido: aparecen los símbolos en crudo o tengo que montar un parser solo para limpiarlos.

La solución fue de las más baratas del proyecto: una instrucción al final del system prompt que obliga a responder siempre en texto plano, sin markdown. Un renglón. Me ahorró toda la lógica de sanitizado en el frontend.

El papel de Claude Code

Buena parte de esto la construí con Claude Code desde la terminal. No fue pedirle «escríbeme un asistente de IA para WordPress» y pegar lo que saliera. Fue trabajar en ciclos cortos: le planteaba una pieza, revisaba lo que proponía, lo ajustaba y seguía.

Donde aporta de verdad es en el andamiaje: montar la llamada con curl, dejar la estructura de la tabla, cablear los transients del rate limit. Eso lo resuelve rápido y me ahorra la parte mecánica.

Donde tuve que llevar yo el timón fue en las decisiones de arquitectura. La ventana de 20 mensajes, el tope de reintentos, heredar el prompt del curso padre: eso no lo decide la herramienta, lo decido yo según lo que conozco del proyecto. Claude Code tiende a darte la solución correcta y genérica; la adecuada para este club salía de mi cabeza, y él la implementaba. Esa división de trabajo es la que hace que el CLI me sirva en desarrollo web real y no solo para prototipos de juguete.

¿Montártelo tú o tirar de un plugin?

¿Merece la pena montarse esto a mano? Depende, y voy a mojarme.

Si lo que quieres es un chatbot genérico de soporte en una web cualquiera, no. Hay plugins que lo hacen en una tarde y mantenerlos no es tu problema. Reinventarlo a medida ahí es ego de programador, no buena ingeniería.

Lo monté a medida porque necesitaba cosas que un plugin no me daba sin pelearme con él: la identidad del instructor, el system prompt heredable por curso, el historial en mi propia base de datos y el control fino sobre qué mando al modelo y cuánto. Cuando el asistente es parte del producto y no un añadido, controlar la pieza entera compensa el trabajo.

La pregunta útil no es «¿puedo construirlo?». Casi siempre puedes. Es «¿esta pieza es central para lo que ofrezco?». Si lo es, constrúyela y entiéndela por dentro. Si no, paga el plugin y dedica el tiempo a lo que sí importa.